diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..86566b9 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,53 @@ +name: Go + +on: + push: + branches: ["main"] + paths: + - "go/**" + - ".github/workflows/go.yml" + pull_request: + branches: ["main"] + paths: + - "go/**" + - ".github/workflows/go.yml" + +jobs: + go: + name: build / vet / test / gofmt + runs-on: ubuntu-latest + defaults: + run: + working-directory: go + steps: + - uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + # Track the module's own directive so a go.mod bump moves CI with it. + go-version-file: go/go.mod + cache-dependency-path: go/go.sum + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... + + - name: Gofmt + run: test -z "$(gofmt -l .)" + + docker: + name: docker build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # The Dockerfile COPYs go/go.mod and go/, so the build context is the + # REPO ROOT, not go/. + - name: Build the Go node image + run: docker build -f go/Dockerfile -t sec-af-go:test . diff --git a/README.md b/README.md index 6450afb..e7deba7 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ af install https://github.com/Agent-Field/sec-af af run sec-af ``` -`af install` clones the repo, provisions an isolated Python environment, and registers the `sec-af` node with your control plane. On first `af run` you're prompted for the required `OPENROUTER_API_KEY` — stored encrypted and reused across every node, so you enter it only once. Then run an audit: +`af install` follows the repository manifest to the maintained Go package and registers it as the `sec-af` node with your control plane. If an older Python `sec-af` is installed, it is replaced in place, retaining the same node id, triggers, and node-scoped secrets. On first `af run` you're prompted for the required `OPENROUTER_API_KEY` — stored encrypted and reused across every node, so you enter it only once. Then run an audit: ```bash af call sec-af.audit --in '{"repo_url": "https://github.com/dolevf/Damn-Vulnerable-GraphQL-Application"}' @@ -246,6 +246,27 @@ af call sec-af.audit --in '{"repo_url": "https://github.com/dolevf/Damn-Vulnerab New to AgentField? Install the control plane first with `curl -fsSL https://agentfield.ai/install.sh | bash`, or use the Docker / Railway options below. +To install the Python node deliberately, clone this repository and install the +checkout as a local path. Local-path installs do not follow `superseded_by`: + +```bash +git clone https://github.com/Agent-Field/sec-af +af install ./sec-af +``` + +### Go implementation + +The maintained node lives under [`go/`](go/README.md), and installing the bare +repository URL gives you this implementation as `sec-af` on its default port +`8013` — no Python environment is provisioned on that path. It registers the +same reasoners under the same names and draws the same control-plane DAG. The +Python implementation remains available through `python -m sec_af.app`, the root +Docker Compose stack, or the local-path install escape hatch (`git clone` then +`af install ./sec-af`). The Go add-on Compose file +(`docker-compose.go.yml`) uses the node id `sec-af-go` only so both +implementations can run against one control plane during a changeover. Build, +run, and Docker/compose docs live in [`go/README.md`](go/README.md). + ### One-Click Deploy (Railway) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/sec-af) diff --git a/agentfield-package.yaml b/agentfield-package.yaml index fae8a33..360c8e3 100644 --- a/agentfield-package.yaml +++ b/agentfield-package.yaml @@ -4,6 +4,21 @@ version: 0.1.0 description: Security review agent node (scans repositories for vulnerabilities) author: Agent-Field +# The Go node in go/ is the maintained security-audit node: same reasoners, same +# interface, one static binary, no per-node venv to build. Installing this repo +# installs that instead — so `af install https://github.com/Agent-Field/sec-af` +# is the one thing a user has to know, before and after the switch. +# +# go/ declares this same name deliberately, so the switch is a replacement in +# place: same node id, same triggers, node-scoped secrets kept. Only one of the +# two can be installed at a time, which is the point. +# +# This manifest stays here as the redirect, so the Python node is still what +# `python -m sec_af.app` and docker-compose run. The redirect is a git-install +# behaviour only: to install this node deliberately, clone the repo and install +# the checkout as a local path. +superseded_by: https://github.com/Agent-Field/sec-af//go + entrypoint: start: python -m sec_af.app healthcheck: /health diff --git a/docker-compose.go.yml b/docker-compose.go.yml new file mode 100644 index 0000000..5b234a6 --- /dev/null +++ b/docker-compose.go.yml @@ -0,0 +1,105 @@ +# SEC-AF Go node — opt-in ADD-ON to the Python stack. +# +# The Python docker-compose.yml is the DEFAULT stack (the AgentField control +# plane `agentfield` + the Python `sec-af` node on :8003) and is left 100% +# untouched. This file adds ONLY the Go node, registered under a DISTINCT +# identity so both nodes can run against one control plane simultaneously: +# +# sec-af-go -> node id "sec-af-go", :8013 +# +# Run story (two commands, Python stack first): +# +# docker compose up -d # Python stack + control plane +# docker compose -f docker-compose.go.yml up -d # adds the Go node +# +# This is a SEPARATE compose project (name: sec-af-go) that joins the Python +# stack's network as an EXTERNAL reference, so AGENTFIELD_SERVER= +# http://agentfield:8080 resolves and the Go node shares the Python stack's +# workspaces volume over that network. The control plane (service `agentfield`) +# lives in the Python project, so there is NO `depends_on` here — bring the +# Python stack up first. +# +# AND WAIT FOR IT TO ANSWER. Unlike the Python node, which keeps serving in +# "degraded mode" and retries registration every 10s, the Go SDK returns the +# registration error from Serve and cmd/sec-af treats it as fatal — so this +# container EXITS if the control plane is not reachable at start, and +# `restart: unless-stopped` below turns that into a restart loop until it is. +# The same applies when the control plane is restarted under a running Go node. +# See "Parity notes" in go/README.md. +# +# COMPOSE_PROJECT_NAME caveat: the external network/volume names below +# (sec-af_default, sec-af_workspaces) are the Python project's +# default-project-name resources. The Python docker-compose.yml has NO explicit +# `name:`, so its project name defaults to the compose directory's basename — +# `sec-af` when the repo is checked out as a directory named `sec-af`. If you +# set COMPOSE_PROJECT_NAME for the Python stack (or the checkout directory is +# named something else), override the external `name:` fields below to match +# `_default` and `_workspaces`. +name: sec-af-go + +services: + sec-af-go: + build: + context: . + dockerfile: go/Dockerfile + args: + AFORGE_BASE_URL: ${AFORGE_BASE_URL:-https://agentfield.ai/downloads/aforge} + AFORGE_VERSION: ${AFORGE_VERSION:-v0.1.0} + environment: + - AGENTFIELD_SERVER=http://agentfield:8080 # CP service name in sec-af's compose is "agentfield" + - AGENTFIELD_API_KEY=${AGENTFIELD_API_KEY:-} + - NODE_ID=sec-af-go + - PORT=8013 + - AGENT_CALLBACK_URL=http://sec-af-go:8013 + - HARNESS_PROVIDER=${HARNESS_PROVIDER:-aforge} + - AGENTFIELD_AFORGE_COMMAND=${AGENTFIELD_AFORGE_COMMAND:-exec} + - HARNESS_MODEL=${HARNESS_MODEL:-openrouter/moonshotai/kimi-k2.5} + - AI_MODEL=${AI_MODEL:-openrouter/moonshotai/kimi-k2.5} + # SEC_AF_AFORGE_BIN is deliberately NOT forwarded, exactly as the Python + # sec-af service does not forward it (docker-compose.yml). Compose loads + # the repo-root .env for BOTH stacks and .env.example documents + # `SEC_AF_AFORGE_BIN=/absolute/path/to/aforge` as the way to point a + # non-container run at a HOST binary; a bare `- SEC_AF_AFORGE_BIN` + # passthrough would inject that host path into the container, where + # config.AIConfigFromEnv resolves it as the harness BinPath and every + # harness call fails on a missing executable instead of using the image's + # checksum-verified /usr/local/bin/aforge (go/Dockerfile). + # XDG_DATA_HOME is deliberately NOT set, matching the Python sec-af + # service (docker-compose.yml), so config.ProviderEnv falls back to + # /opencode-shared-data in both containers — same directory, + # same (ephemeral) lifetime. See go/Dockerfile for the long form. + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} + - SEC_AF_WORKSPACES_DIR=/workspaces + ports: + - "8013:8013" + volumes: + - workspaces:/workspaces + # Same cadence as the Python sec-af service (docker-compose.yml) and as the + # image's own HEALTHCHECK (go/Dockerfile), so both nodes in one stack report + # health on the same schedule. A compose-level healthcheck SUPERSEDES the + # image directive, so these values are the ones that actually run. + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8013/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + restart: unless-stopped + +# Join the Python stack's default network so `agentfield` (the control plane) +# resolves by service name. external => Compose does NOT create it; the Python +# stack must be up first (see COMPOSE_PROJECT_NAME caveat in the header). +networks: + default: + external: true + name: sec-af_default + +# Share the Python stack's workspaces volume (_ = +# sec-af_workspaces). There is no opencode-data volume: the Python sec-af +# service does not declare one either, and without XDG_DATA_HOME the opencode +# shared-data directory lives under the container's tempdir, exactly as it does +# for the Python node. +volumes: + workspaces: + external: true + name: sec-af_workspaces diff --git a/go/.gitignore b/go/.gitignore new file mode 100644 index 0000000..82b4719 --- /dev/null +++ b/go/.gitignore @@ -0,0 +1,9 @@ +bin/ +go.work +go.work.sum +coverage.out +*.test + +# Python bytecode from scripts/gen_*.py (the generators run under the repo's venv). +__pycache__/ +*.pyc diff --git a/go/Dockerfile b/go/Dockerfile new file mode 100644 index 0000000..2dd7885 --- /dev/null +++ b/go/Dockerfile @@ -0,0 +1,138 @@ +# SEC-AF Go node — multi-stage build. +# +# Build from the SEC-AF repo ROOT so the go/ module is in the build context and +# the paths below (go/go.mod, go/) resolve. docker-compose.go.yml builds it +# exactly this way (build.context: ., dockerfile: go/Dockerfile): +# +# docker build -f go/Dockerfile -t sec-af-go:latest . +# +# The AgentField Go SDK is a REAL versioned require resolved from +# proxy.golang.org, so there is no SDK clone stage, no GOWORK=off and no +# `replace` dance: `go mod download` pulls everything from the module proxy, +# cache-keyed on go.mod/go.sum. + +# --------------------------------------------------------------------------- +# Stage 0 — aforge: fetch the released AForge CLI from the public download host +# and verify it against the release checksums (which hash the DECOMPRESSED +# binaries). Lifted from this repo's own Python Dockerfile so both images ship +# the identical, checksum-verified binary. Both ARGs are overridable: +# +# docker build --build-arg AFORGE_BASE_URL=... --build-arg AFORGE_VERSION=... . +# --------------------------------------------------------------------------- +FROM debian:bookworm-slim AS aforge + +ARG AFORGE_BASE_URL=https://agentfield.ai/downloads/aforge +ARG AFORGE_VERSION=v0.1.0 +# Provided automatically by BuildKit; defaults to the builder's own arch. +ARG TARGETARCH + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /out + +RUN set -eux; \ + arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \ + curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/aforge-linux-${arch}.gz" -o aforge.gz; \ + gunzip -c aforge.gz > aforge; \ + rm -f aforge.gz; \ + curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/checksums.txt" -o checksums.txt; \ + tr -d '\r' < checksums.txt \ + | grep " aforge-linux-${arch}$" \ + | sed 's/ aforge-linux-.*/ aforge/' > aforge.sha256; \ + test -s aforge.sha256; \ + sha256sum -c aforge.sha256; \ + rm -f checksums.txt aforge.sha256; \ + chmod +x aforge + + +# --------------------------------------------------------------------------- +# Stage 1 — builder: fetch modules from the proxy, build the static binary. +# Go 1.23 satisfies go.mod's `go 1.21` directive. +# --------------------------------------------------------------------------- +FROM golang:1.23-bookworm AS builder + +WORKDIR /src + +# Prime the module cache from go.mod/go.sum first so dependency downloads cache +# independently of source edits (this layer re-runs only when they change). +COPY go/go.mod go/go.sum ./ +RUN go mod download + +COPY go/ ./ +ENV CGO_ENABLED=0 GOOS=linux +RUN go build -trimpath -ldflags="-s -w" -o /out/sec-af ./cmd/sec-af + + +# --------------------------------------------------------------------------- +# Stage 2 — runtime: slim Debian mirroring the Python image (opencode CLI + a +# non-root secaf user), shipping the single static Go binary instead of a Python +# runtime. The entrypoint generates opencode.json from HARNESS_MODEL at +# container start, so the env var is honored instead of a baked-in model. +# --------------------------------------------------------------------------- +FROM debian:bookworm-slim AS runtime + +ARG OPENCODE_VERSION=1.17.15 + +# AGENTFIELD_SERVER is deliberately NOT baked in: sec-af's Python image does not +# set it either (Dockerfile runtime ENV block), so both images fall back to the +# node's own default, http://localhost:8080 (app.py:39, go/internal/node/node.go:180) +# — which is what go/README.md's env table documents. docker-compose.go.yml sets +# it explicitly for the compose stack; a bare `docker run` keeps the localhost +# default instead of reaching for a hostname that only exists inside compose. +ENV HARNESS_PROVIDER=aforge \ + AGENTFIELD_AFORGE_COMMAND=exec \ + HARNESS_MODEL=openrouter/minimax/minimax-m2.5 \ + AI_MODEL=openrouter/minimax/minimax-m2.5 \ + PORT=8013 \ + NODE_ID=sec-af \ + HOME=/home/secaf \ + PATH=/home/secaf/.opencode/bin:${PATH} \ + SEC_AF_WORKSPACES_DIR=/workspaces + +# XDG_DATA_HOME is deliberately NOT set, for the same reason as +# AGENTFIELD_SERVER above: sec-af's Python image does not set it (Dockerfile +# runtime ENV block) and neither does its compose file, so +# `AIIntegrationConfig.provider_env()` (src/sec_af/config.py:123) takes its +# `os.path.join(tempfile.gettempdir(), "opencode-shared-data")` fallback in the +# container. Setting it here — as the pr-af image does, where BOTH sides set it +# and it IS parity — would give the Go node a different opencode data directory +# with a different lifetime (a persistent volume instead of an ephemeral /tmp) +# and would mean config.ProviderEnv never exercises the fallback branch it was +# written to port. + +# System deps: ca-certificates (HTTPS to the LLM provider + git hosts), curl +# (healthcheck + opencode installer), git (audit clones the target repository). +# Create the non-root secaf user (uid/gid 10001) and install the opencode CLI as +# that user so it lands under /home/secaf/.opencode (on PATH above). +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git && \ + groupadd --gid 10001 secaf && \ + useradd --uid 10001 --gid secaf --create-home --home-dir /home/secaf --shell /bin/sh secaf && \ + su -s /bin/sh secaf -c "curl -fsSL https://opencode.ai/install | bash -s -- --version ${OPENCODE_VERSION} --no-modify-path" && \ + mkdir -p /workspaces && \ + chown -R secaf:secaf /workspaces /home/secaf && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=builder /out/sec-af /usr/local/bin/sec-af +COPY --from=aforge /out/aforge /usr/local/bin/aforge +COPY go/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +USER secaf +# Cwd must be writable by secaf: the AgentField Go SDK creates its schema output +# dir under the process cwd when a harness call carries no Cwd, and `/` is +# root-owned. /workspaces is secaf-owned and is SEC_AF_WORKSPACES_DIR already. +WORKDIR /workspaces + +EXPOSE 8013 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD curl -f http://localhost:8013/health || exit 1 + +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +CMD ["/usr/local/bin/sec-af"] diff --git a/go/Makefile b/go/Makefile new file mode 100644 index 0000000..7662eb0 --- /dev/null +++ b/go/Makefile @@ -0,0 +1,77 @@ +# SEC-AF Go port — build/test targets. Run from the go/ directory. +# Mirrors the acceptance gate CI runs (.github/workflows/go.yml): +# go build ./... && go vet ./... && go test ./... && test -z "$(gofmt -l .)" +# +# The AgentField Go SDK is a REAL versioned require resolved from +# proxy.golang.org (no replace directive), so no GOWORK/replace dance is needed +# for CI/Docker. A gitignored go.work is the local dev path; set GOWORK=off to +# ignore it explicitly. + +.PHONY: build vet test check fmt fmtcheck lint run docker-build docker-up docker-down + +# Compile every package in the module. +build: + go build ./... + +# Static analysis (go vet) across the module. +vet: + go vet ./... + +# Unit tests across the module. +test: + go test ./... + +# Fail when any file is not gofmt-clean (the CI gate). +fmtcheck: + @test -z "$$(gofmt -l .)" || (gofmt -l . && echo "gofmt: files above need formatting" && exit 1) + +# The full local gate CI runs. +check: build vet test fmtcheck + +# Format every Go source file in place. +fmt: + gofmt -w . + +# Optional lint pass; no-op with a hint when golangci-lint is not installed. +# +# The if/else is load-bearing: `probe && golangci-lint run || echo ...` chains +# left to right in ONE shell, so the `|| echo` arm fires for a real lint failure +# just as it does for a missing binary — printing "not installed" over the +# findings and exiting 0. Only the ABSENCE of the tool may be a no-op; a lint +# failure must fail the target. +lint: + @if command -v golangci-lint >/dev/null 2>&1; then \ + golangci-lint run; \ + else \ + echo "golangci-lint not installed; skipping (install: https://golangci-lint.run)"; \ + fi + +# Run the SEC-AF node (sec-af, default port 8013). +run: + go run ./cmd/sec-af + +# --- Docker (multi-stage image + Go-node compose add-on) ------------------ +# The Dockerfile expects the REPO ROOT as the build context (it COPYs +# go/go.mod, go/ ...), so the context is the parent directory. The compose +# add-on (docker-compose.go.yml) lives at the repo root and joins the Python +# sec-af stack's network + workspaces volume. +# The tag is `sec-af-go`, NOT `sec-af`: the root README documents +# `docker build -t sec-af .` for the PYTHON image, which resolves to the same +# `sec-af:latest`, and a user following both docs would silently overwrite one +# image with the other. `sec-af-go` also matches the CI job's tag +# (.github/workflows/go.yml) and the compose service/node id. +# Override the image tag with: make docker-build IMAGE=myrepo/sec-af-go:dev +IMAGE ?= sec-af-go:latest + +docker-build: + docker build -f Dockerfile -t $(IMAGE) .. + +# Bring up the Go node (sec-af-go:8013) as an ADD-ON to the Python stack. Start +# the Python stack first (`docker compose up` — it owns the control plane + +# shared network); this add-on joins that network as an external reference. +docker-up: + docker compose -f ../docker-compose.go.yml up --build + +# Tear the Go-node add-on down. +docker-down: + docker compose -f ../docker-compose.go.yml down diff --git a/go/README.md b/go/README.md new file mode 100644 index 0000000..3c958cb --- /dev/null +++ b/go/README.md @@ -0,0 +1,242 @@ +# SEC-AF — Go node + +A Go implementation of the SEC-AF security-audit node. It registers the same +reasoner surface under the same names as the Python node, exposes a +byte-compatible HTTP API, and reaches every sub-agent through the control plane, +so the control-plane DAG UI renders the same multi-node orchestration graph as +the Python node (see [Pipeline DAG on the control plane](#pipeline-dag-on-the-control-plane)). +The Python package under `src/sec_af/` is untouched; this implementation lives +entirely under `go/`. + +One binary: + +| Binary | Node ID | Default port | Role | +|----------|----------|--------------|---------------------------------------------| +| `sec-af` | `sec-af` | `8013` | Full audit pipeline (recon → hunt → prove → remediation) | + +Module path: `github.com/Agent-Field/sec-af/go`. + +## Install + +Installing the bare repository URL follows the root manifest's redirect to this +package and registers the Go node as `sec-af` on `:8013`: + +```bash +af install https://github.com/Agent-Field/sec-af +af run sec-af +af call sec-af.audit --in '{"repo_url":"https://github.com/dolevf/Damn-Vulnerable-GraphQL-Application"}' +``` + +To install the Python node deliberately, clone the repository and run +`af install ./sec-af`; local-path installs do not follow the redirect. `NODE_ID` +and `PORT` still override the Go defaults if you want a different id or port. + +## Pipeline DAG on the control plane + +An audit is not one execution. Every phase and every sub-agent runs as a +**tracked child execution** of the parent `audit`, and the control-plane UI +renders the run as that DAG: + +``` +audit +├── recon_phase +│ ├── run_architecture_mapper ┐ +│ ├── run_dependency_auditor ├ gather (3) +│ ├── run_config_scanner ┘ +│ ├── run_data_flow_mapper ┐ gather (2) — skipped when depth == quick +│ └── run_security_context_profiler ┘ +├── hunt_phase +│ ├── run__hunter × N semaphore max(1, min(max_concurrent_hunters=4, N)) +│ └── run_deduplicator only when ≥1 fingerprint-unique finding +├── prove_phase +│ └── run_verifier × K K = min(len(findings), prover cap) semaphore 3 +└── remediation_phase + └── run_remediation × M M = confirmed/likely findings without a remediation +``` + +Python draws that graph with `await router.call(f"{NODE_ID}.", ...)`; +the Go port makes the identical `Agent.Call` with the same target name and the +same kwargs, so the node/edge multiset is the same. The registered surface is 34 +reasoners: `audit` plus the 33 router reasoners, tagged +`["security","audit","red-team"]`. + +Two nodes that are registered but never `.call`ed — `run_cwe_expansion` and the +individual `run_tracer` / `run_sanitization_analyzer` / `run_exploit_hypothesizer` +/ `run_verdict_agent` reasoners — are part of the surface because Python +registers them; the verifier runs those four in process, exactly as Python does, +so they contribute no DAG node on a normal audit. + +## Depending on the AgentField Go SDK + +This module depends on the AgentField Go SDK +(`github.com/Agent-Field/agentfield/sdk/go`) via a **real, committed `require`** +resolved from `proxy.golang.org` — there is **no `replace` directive** and no +sibling checkout to lay out. `go build ./...` works out of the box against the +pinned SDK version in `go.mod`. + +- **CI / Docker.** `go mod download` pulls the SDK (and every other dependency) + straight from the module proxy. No `GOWORK=off`, no sparse clone. +- **Dev — optional Go workspace.** A gitignored `go.work` (spanning this module + and a local `agentfield/sdk/go` checkout) is the way to develop against + unreleased SDK changes; with it present, `go build ./...` picks up local SDK + edits live. It is never committed. + +Bumping the SDK is a deliberate, reviewable change: bump the `require` version +in `go.mod`, and move the Docker builder image tag together with it if the SDK's +own `go` directive ever advances past `go 1.21`. + +## Build & run locally + +From `go/`: + +```bash +make build # go build ./... +make vet # go vet ./... +make test # go test ./... +make fmtcheck # test -z "$(gofmt -l .)" +make check # build + vet + test + fmtcheck (the CI gate) +make fmt # gofmt -w . +make run # run the node (sec-af, :8013) +``` + +`make run` needs a control plane reachable at `AGENTFIELD_SERVER` (default +`http://localhost:8080`). The node reads all configuration from the environment +at startup. + +## Docker + +The image is a multi-stage build: `go mod download` + `go build` in a +`golang:1.23` stage, then a slim Debian runtime mirroring the Python image — +the pinned `opencode` CLI (`1.17.15`), the checksum-verified `aforge` binary, +a non-root `secaf` user (uid/gid 10001), and a `docker-entrypoint.sh` that +generates `opencode.json` from `HARNESS_MODEL` at container start. + +The build context is the **repo root** so the `go/` module is in context: + +```bash +# from the repo root +docker build -f go/Dockerfile -t sec-af-go:latest . +``` + +The tag is `sec-af-go`, not `sec-af`: the root README's `docker build -t sec-af .` +builds the **Python** image under `sec-af:latest`, and the two are different +artifacts (different entrypoint, different port). + +### Compose: opt-in add-on to the Python stack + +`docker-compose.go.yml` (at the repo root) is an **add-on**, not a standalone +stack. It defines only the Go node and joins the Python stack's compose network +as an external reference, sharing the control plane (`agentfield`) and the +`workspaces` volume the Python stack brings up. The Python `docker-compose.yml` +is left untouched. Start the Python stack first, then layer the Go node: + +```bash +docker compose up -d # Python stack (control plane + sec-af :8003) +docker compose -f docker-compose.go.yml up -d # adds sec-af-go :8013 +``` + +Adds: + +| Service | Port | Node id | Notes | +|-------------|--------|-------------|---------------------| +| `sec-af-go` | `8013` | `sec-af-go` | full audit pipeline | + +The control plane (`:8080`) and the `workspaces` volume come from the Python +stack — the Go add-on joins them via the external `sec-af_default` network and +`sec-af_workspaces` volume. This assumes the Python stack was brought up with +the default project name `sec-af` (the Python compose has no explicit `name:`, +so its project name is the checkout directory's basename); see the compose file +header for the `COMPOSE_PROJECT_NAME` override. Health: +`curl -f http://localhost:8013/health`. + +## Environment variables + +The node is configured entirely through the environment. + +| Variable | Purpose | +|-----------------------------|------------------------------------------------------------------| +| `OPENROUTER_API_KEY` | LLM provider key (OpenRouter) — required | +| `AGENTFIELD_URL` | Control-plane URL — checked **first** in this repo | +| `AGENTFIELD_SERVER` | Control-plane URL fallback (default `http://localhost:8080`) | +| `AGENTFIELD_API_KEY` | Control-plane API key (if the CP has auth enabled) | +| `AGENT_CALLBACK_URL` | Base URL the control plane uses to reach this node | +| `NODE_ID` | Node ID (default `sec-af`) | +| `PORT` | Listen port (default `8013`) | +| `HARNESS_PROVIDER` | Harness provider (default `aforge`; `opencode` to roll back). `SEC_AF_PROVIDER` wins when both are set | +| `AGENTFIELD_AFORGE_COMMAND` | AForge headless command — `exec` (default) or `do` | +| `HARNESS_MODEL` | Harness model (`SEC_AF_MODEL` wins when both are set) | +| `AI_MODEL` | Model for direct `.ai()` calls (`SEC_AF_AI_MODEL` wins) | +| `SEC_AF_AFORGE_BIN` | AForge executable override (falls back to `AFORGE_BIN`, then `aforge` on PATH) | +| `SEC_AF_OPENCODE_BIN` | OpenCode executable override (default `opencode`) | +| `SEC_AF_MAX_TURNS` | Harness turn cap (default `50`) | +| `SEC_AF_AI_MAX_RETRIES` | Retry count for `.ai()` gate calls (default `3`) | +| `SEC_AF_AI_INITIAL_BACKOFF_SECONDS` | First retry backoff in seconds (default `2.0`) | +| `SEC_AF_AI_MAX_BACKOFF_SECONDS` | Backoff ceiling in seconds (default `8.0`) | +| `SEC_AF_OPENCODE_SERVER` | OpenCode server URL (falls back to `OPENCODE_SERVER`; unset by default) | +| `SEC_AF_WORKSPACES_DIR` | Clone destination for remote repo URLs (default `/workspaces`; falls back to `~/.sec-af/workspaces` when not writable) | +| `SEC_AF_REPO_PATH` | Local checkout used when `repo_url` is neither a directory nor a URL | + +The image ships the released AForge CLI (fetched and checksum-verified at build +time from `https://agentfield.ai/downloads/aforge`, pinned by the +`AFORGE_VERSION` build arg) and runs `exec` by default. OpenCode remains +installed and can be selected with `HARNESS_PROVIDER=opencode` without +rebuilding. + +Note on model defaults — there are three, and they are all deliberate (each one +mirrors the Python node's corresponding artifact): + +| Where | Default | Why | +| --- | --- | --- | +| Code (`internal/config`, `src/sec_af/config.py`) | `minimax/minimax-m2.5` | Bare slug; the fallback when nothing is set. | +| Docker image (`go/Dockerfile`, root `Dockerfile`) | `openrouter/minimax/minimax-m2.5` | Same model, `openrouter/`-prefixed for the harness router. | +| Compose, `.env.example`, `agentfield-package.yaml` | `openrouter/moonshotai/kimi-k2.5` | A different model — the one an installed or composed node runs. | + +`SEC_AF_MODEL` / `SEC_AF_AI_MODEL` win over `HARNESS_MODEL` / `AI_MODEL`, and +either env var wins over every default above. + +## Parity notes + +Behaviour is a 1:1 port of `src/sec_af/`, including its quirks. Four +differences are deliberate and are documented at their call sites: + +- **Callback URL.** Python computes `http://127.0.0.1:${PORT:-8004}` while its + `main()` listens on `${PORT:-8080}` — the two defaults disagree, so an unset + `PORT` makes the Python node advertise an address it is not listening on. The + Go node sets its public URL from `AGENT_CALLBACK_URL` when set and otherwise + lets the SDK derive `http://localhost:`. +- **`/health` body.** Python's route returns + `{"status":"healthy","version":"0.1.0"}`; the SDK's built-in route returns + `{"status":"ok"}` with the same 200. Every consumer here (the Dockerfile + healthcheck, the compose healthcheck, the manifest) only checks the status + code, so the SDK route is used as-is. The SDK also serves `/status`, which is + what the control plane's own health monitor polls. +- **AI credentials.** The Go SDK's `ai.Config` rejects an empty API key at + construction, so the AI client is attached only when `OPENROUTER_API_KEY` is + set. Python accepts the empty key and fails at call time instead; either way + an `.ai()` call without a key fails. +- **Control plane unreachable at boot.** This one is SDK-level, not something + the port chose, and it is the only one with an operational consequence. The + Python SDK's `ConnectionManager` treats registration as best-effort: it logs + `AgentField server unavailable - running in degraded mode`, keeps serving, and + retries every 10s, so the Python node survives a control plane that is not up + yet or that restarts under it. The Go SDK's `Agent.Serve` returns the + registration error (`client.RegisterNode` has no retry), and + `cmd/sec-af/main.go` treats that as fatal — the process binds its port, logs + `node.register.failed`, and exits. In practice the Go node therefore + restart-loops (compose sets `restart: unless-stopped`) until the control plane + answers, instead of degrading in place. Start the Python stack — which owns + the control plane — first, and expect the add-on container to retry until that + stack is healthy. + +## Deployment: `af install` + +Because the root package redirects git installs here, install the Go node with +the repository's bare URL: + +```bash +af install https://github.com/Agent-Field/sec-af +``` + +This resolves to `go/agentfield-package.yaml` (node id `sec-af`, default port +`8013`) and builds `./cmd/sec-af`. A prior Python `sec-af` installation is +replaced in place, retaining its node id, triggers, and node-scoped secrets. diff --git a/go/agentfield-package.yaml b/go/agentfield-package.yaml new file mode 100644 index 0000000..10f6d23 --- /dev/null +++ b/go/agentfield-package.yaml @@ -0,0 +1,61 @@ +config_version: v1 +# This is THE SEC-AF node. It deliberately shares the root manifest's name: the +# root declares `superseded_by` pointing here, so installing this repo installs +# this package, and a user who already has the Python sec-af gets it replaced in +# place — same name, same node id, same triggers, secrets kept. Installing the +# root as a local path (the documented escape hatch) is the one way to get the +# Python node, and it necessarily takes this name over. +name: sec-af +version: 0.1.0 +description: AI-Native Security Analysis and Red-Teaming Agent +author: Agent-Field +language: go # explicit (also auto-detected from go/go.mod) + +entrypoint: + build: ./cmd/sec-af + start: bin/sec-af + healthcheck: /health + +agent_node: + node_id: sec-af + # 8013 rather than the Python node's 8080/8003: during the changeover both may + # be running, and triggers resolve by node id, not port. + default_port: 8013 + +# Same keys AND the same defaults as the root manifest — the Go node reads +# exactly the same variables, and `af install https://github.com/Agent-Field/sec-af` +# follows the root manifest's `superseded_by` to this one, replacing an existing +# Python sec-af in place. The control plane materialises `default:` into the +# node's environment when the user has stored no value, so a different default +# here would silently switch the LLM out from under an in-place upgrade. +user_environment: + required: + - name: OPENROUTER_API_KEY + description: LLM provider key (OpenRouter) + type: secret + scope: global + optional: + - name: AGENTFIELD_SERVER + description: Control-plane URL + default: http://localhost:8080 + - name: AGENTFIELD_API_KEY + description: Control-plane API key (if auth is enabled) + type: secret + scope: global + - name: HARNESS_PROVIDER + description: Coding-agent harness provider (aforge | claude-code | codex | gemini | opencode) + default: aforge + - name: AGENTFIELD_AFORGE_COMMAND + description: AForge headless command the SDK runs (`exec` or `do`); agentfield>=0.1.130 reads it, default `exec` + default: exec + - name: SEC_AF_AFORGE_BIN + description: Optional path to the AForge binary (defaults to aforge on PATH) + - name: HARNESS_MODEL + description: Model the harness uses + default: openrouter/moonshotai/kimi-k2.5 + - name: AI_MODEL + description: Model for direct AI calls + default: openrouter/moonshotai/kimi-k2.5 + - name: SEC_AF_WORKSPACES_DIR + description: Directory where repos to scan are cloned (falls back to ~/.sec-af/workspaces if not writable) + default: /workspaces diff --git a/go/cmd/sec-af/main.go b/go/cmd/sec-af/main.go new file mode 100644 index 0000000..fbc0373 --- /dev/null +++ b/go/cmd/sec-af/main.go @@ -0,0 +1,52 @@ +// Command sec-af is the Go SEC-AF security-audit node — the port of +// src/sec_af/app.py's module body plus main(). It builds the agent from the +// environment, registers the 34-reasoner surface (`audit` plus the 33 router +// reasoners of DESIGN.md §3) and serves it until SIGINT/SIGTERM. +// +// Defaults: NODE_ID "sec-af", PORT 8013. Both env vars override; +// docker-compose.go.yml sets NODE_ID=sec-af-go so the Go node can run beside +// the Python one against a single control plane. +// +// Boot environment: +// +// AGENTFIELD_URL control-plane base URL — checked FIRST in this repo +// AGENTFIELD_SERVER control-plane base URL fallback (default http://localhost:8080) +// AGENTFIELD_API_KEY control-plane bearer token +// AGENT_CALLBACK_URL base URL the CP uses to reach this node (else the SDK +// derives http://localhost:) +// NODE_ID node id (default sec-af) +// PORT listen port (default 8013) +// SEC_AF_PROVIDER harness provider (falls back to HARNESS_PROVIDER, default aforge) +// SEC_AF_MODEL harness model (falls back to HARNESS_MODEL) +// SEC_AF_AI_MODEL model for .ai() calls (falls back to AI_MODEL, then SEC_AF_MODEL) +// SEC_AF_AFORGE_BIN aforge executable (falls back to AFORGE_BIN, default aforge) +// SEC_AF_OPENCODE_BIN opencode executable (default opencode) +// SEC_AF_WORKSPACES_DIR clone destination for remote repo URLs (default /workspaces) +// SEC_AF_REPO_PATH local checkout used when repo_url is neither a directory nor a URL +// OPENROUTER_API_KEY LLM key — required for the .ai() gates; AIConfig is +// attached only when it is set (the SDK rejects an empty key) +package main + +import ( + "context" + "log" + + "github.com/Agent-Field/sec-af/go/internal/node" +) + +func main() { + n, err := node.BuildAgent( + "sec-af", + "8013", + "AI-Native Security Analysis and Red-Teaming Agent", + ) + if err != nil { + log.Fatalf("sec-af: build agent: %v", err) + } + + n.RegisterAll() + + if err := n.Serve(context.Background()); err != nil { + log.Fatalf("sec-af: serve: %v", err) + } +} diff --git a/go/doc.go b/go/doc.go new file mode 100644 index 0000000..597c1da --- /dev/null +++ b/go/doc.go @@ -0,0 +1,4 @@ +// Package gomod is the module root of the sec-af Go port. The node lives under +// cmd/sec-af; reusable packages live under internal/. See docs/DESIGN.md for the +// port contract (parity rules, DAG shape, SDK mapping). +package gomod diff --git a/go/docker-entrypoint.sh b/go/docker-entrypoint.sh new file mode 100755 index 0000000..89895b3 --- /dev/null +++ b/go/docker-entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Generate the opencode config at container start so HARNESS_MODEL is honored. +# +# The Python image bakes opencode.json in with a hardcoded model and a +# single-model provider whitelist, which means HARNESS_MODEL is ignored by the +# opencode harness: even though the model is passed via `-m`, opencode falls +# back to (and restricts itself to) the baked model. Generating the config here +# from HARNESS_MODEL fixes that — the env var wins when set, and we fall back to +# the image default when it isn't. +set -e + +# Precedence MUST match the node's own: config.py:79-84 / +# internal/config/ai.go:58 resolve the harness model as +# SEC_AF_MODEL > HARNESS_MODEL > "minimax/minimax-m2.5", and that value is what +# reaches opencode's `-m` flag. Reading only HARNESS_MODEL here would pin +# opencode.json (and its single-model provider whitelist) to the image default +# while the harness was invoked with SEC_AF_MODEL — precisely the mismatch this +# script exists to prevent. The image sets HARNESS_MODEL as an ENV, so the +# lower-precedence rung is always present and would always win. +MODEL="${SEC_AF_MODEL:-${HARNESS_MODEL:-openrouter/minimax/minimax-m2.5}}" + +# opencode keys models under a provider by the slug *without* the provider +# prefix, e.g. "openrouter/minimax/minimax-m2.5" -> provider "openrouter", +# key "minimax/minimax-m2.5". +MODEL_KEY="${MODEL#openrouter/}" + +CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode" +mkdir -p "$CONFIG_DIR" + +cat > "$CONFIG_DIR/opencode.json" <")` through the control +plane. + +Reference material (read-only): + +- pr-af Go port — under `go/` + (afx.Bind/ToMap, harnessx.Run[T] with embedded pydantic schemas, node/ wiring, + register.go, Dockerfile/Makefile/compose, go/README.md, root README section) +- SWE-AF Go port — under `go/` + (app.Call DAG, envelope.UnwrapCallResult) +- AgentField Go SDK (v0.1.131 is the pinned tag) — + under `sdk/go` + (`agent/agent.go` Call/CallLocal/AI/Note, `agent/harness.go`, `agent/router.go`, + `harness/provider.go` Options, `harness/result.go`, `ai/request.go` WithSchema) +- AgentField Python SDK (to check what Python does) — the same repository under + `sdk/python/agentfield` + +The Python node this port reproduces lives in this repository: `src/sec_af` +(sources) and `tests/` (its test suite). + +## -1. Tooling facts + +- The generators (`go/scripts/gen_schemas.py`, `go/scripts/gen_golden.py`) need a + Python 3.11 interpreter with pydantic v2, `agentfield` and this repo's own + dependencies — the interpreter `af install` provisions for the Python node is + the convenient one. Run them as + `PYTHONPATH=/src go/scripts/gen_schemas.py`. +- Go toolchain: go1.25 or newer on PATH; the module targets the Go 1.21 language + level. `go mod tidy` resolves + `github.com/Agent-Field/agentfield/sdk/go v0.1.131` from the proxy. +- Never register a test node against a control plane you do not own: bring up an + isolated one (see §7). + +## 0. Non-negotiables + +1. **Python is byte-untouched.** Every diff lives under `go/`, plus + `docker-compose.go.yml`, the root `agentfield-package.yaml` redirect, one + root-README section, and `.github/workflows/go.yml`. Never edit `src/`, + `tests/`, `pyproject.toml`, `Dockerfile`, `docker-compose.yml`. +2. **1:1 parity is the goal.** Same reasoner names, same input parameter + names/defaults, same result JSON key sets (snake_case, pydantic + `model_dump()` shape), same prompts (byte-verbatim), same concurrency + shape (gather/semaphore), same notes (message + tags), same error mapping. + When Python does something odd, reproduce it and leave a comment + `// Python parity: ...`. Do not "improve" behavior. If a Python behavior is + non-deterministic (set iteration order) make it deterministic and comment it. +3. **Same DAG.** Every place Python does `router.call(f"{NODE_ID}.x", ...)` / + `app.call(...)`, Go does `app.Call(ctx, nodeID+".x", kwargsMap)` with the + SAME target name and the SAME kwargs keys. Never replace a Python `.call` + with a direct Go function call — that collapses the control-plane DAG. + Conversely never add a `.Call` where Python calls a function in-process. +4. **Gate: `cd go && go build ./... && go vet ./... && go test ./... && test -z "$(gofmt -l .)"`** + must be green for everything you touch. Tests are derived from the Python + tests and from the behaviors in this doc (validation contract), not from + the Go implementation. +5. Go 1.21 language level in `go.mod` (`go 1.21`), matching the SDK. Local + toolchain is go1.25 — fine, but do not use APIs newer than 1.21 (no + `slices`/`maps` std packages? — those ARE 1.21, ok; `min`/`max` builtins + are 1.21, ok; avoid `range over int` (1.22) and `iter` (1.23)). +6. No new third-party dependencies beyond: the SDK, `golang.org/x/sync`, + `github.com/invopop/jsonschema` and `github.com/santhosh-tekuri/jsonschema/v5` + (pulled by the SDK). Ask before adding anything else. + +## 1. Repository layout (`go/` at the repo root) + +``` +go/ +├── agentfield-package.yaml # name: sec-af, language: go +├── Dockerfile # multi-stage, aforge fetch + opencode, non-root user, static binary +├── docker-entrypoint.sh # writes opencode.json from HARNESS_MODEL at container start +├── Makefile # build/vet/test/check/fmt/run/docker-* +├── README.md # build/run/compose/install story (model on pr-af go/README.md) +├── .gitignore # bin/, go.work*, coverage +├── doc.go # package doc for the module root +├── go.mod / go.sum # module github.com/Agent-Field/sec-af/go ; go 1.21 ; sdk/go v0.1.131 +├── cmd/sec-af/main.go # the node binary +├── docs/DESIGN.md # this document +├── scripts/gen_schemas.py # pydantic model_json_schema() → internal/harnessx/testdata/schemas/*.json +├── scripts/gen_golden.py # (where useful) Python prompt-builder goldens → internal/.../testdata/*.txt +├── internal/ +│ ├── afx/ Bind[T], ToMap, Unwrap/AsMap (the _unwrap/_as_dict parity), DropNulls (model_dump(exclude_none=True)) +│ ├── pyfmt/ Round(x, ndigits) banker's rounding (Python round()), Repr(v) Python repr for list/dict/str/bool/None +│ │ (needed wherever a prompt f-string embeds a Python list/dict), FormatFloat (Python str(float)) +│ ├── appx/ App interface {Harness, AI, Note, Call} that *agent.Agent satisfies; fakes for tests +│ ├── config/ DepthProfile, BudgetConfig, AuditConfig, AIIntegrationConfig (env), ProviderEnv() +│ ├── schemas/ every pydantic model → Go struct (json tags = pydantic field names), enums → string types +│ ├── harnessx/ Run[T] + RegisterSchema + embedded pydantic schema fixtures; Extract (extract_harness_result parity) +│ ├── aix/ Structured[T]: Python `.ai(user=, schema=Model)` = app.AI(WithSystem?, WithSchema(strictified pydantic schema)) → parse T +│ ├── prompts/ embedded copies of the Python prompt .txt files + Load(relpath) + drift test vs the Python tree +│ ├── see §3 +│ ├── reasoners/ Name* constants + RegisterAll (router with the Python AgentRouter tags) + handler adapters +│ ├── phases/ the *_phase reasoners (Call-based DAG) +│ ├── orch/ orchestrator (generate_output, checkpoints, budget/cost bookkeeping, progress notes) +│ └── node/ BuildAgent (env → agent.Config), the top-level `audit` reasoner handler, Serve +└── test/functional/ (build tag `functional`) registration parity against a live control plane +``` + +Root additions: `docker-compose.go.yml` (Go node as an add-on to the Python +stack, distinct NODE_ID `sec-af-go` and port), root `agentfield-package.yaml` +gains the `superseded_by: https://github.com/Agent-Field/sec-af//go` block +(copy the comment block from pr-af's root manifest verbatim, adjusting names), +root README gains a "Go implementation" section, `.github/workflows/go.yml` +(build/vet/test/gofmt on push + PR, paths-filtered to `go/**`). + +Node identity / ports: + +| Python code default NODE_ID | Go default NODE_ID | Python port | Go default port | router tags | +|---|---|---|---|---| +| `sec-af` | `sec-af` | 8080 (manifest) / 8003 (compose) | **8013** | `["security","audit","red-team"]` | + +`NODE_ID` and `PORT` env override both (Python parity). `docker-compose.go.yml` +sets `NODE_ID=sec-af-go` so both stacks can share one control plane. + +## 2. SDK mapping (Python → Go) + +| Python (agentfield py SDK) | Go (sdk/go v0.1.131) | +|---|---| +| `Agent(node_id, version, description, agentfield_server, callback_url, api_key, harness_config=HarnessConfig(provider, model, max_turns, env, opencode_bin, aforge_bin, permission_mode="auto"), ai_config=AIConfig(model, api_key, api_base))` | `agent.New(agent.Config{NodeID, Version:"0.1.0", AgentFieldURL, Token, ListenAddress:":"+port, PublicURL: AGENT_CALLBACK_URL, CLIConfig:&agent.CLIConfig{AppDescription}, HarnessConfig:&agent.HarnessConfig{Provider, Model, MaxTurns, PermissionMode:"auto", Env: ProviderEnv(), BinPath: }, AIConfig: &ai.Config{Model: strip "openrouter/" prefix, APIKey, BaseURL:"https://openrouter.ai/api/v1"} ONLY when the key is non-empty})` — copy pr-af `node.BuildAgent` incl. the `aiModelForAPI` prefix-strip rationale. | +| `@app.reasoner()` (top-level) | `app.RegisterReasoner(name, handler, agent.WithInputSchema(raw))` — transcribe the Python signature into the input schema (see pr-af `reviewInputSchema`). | +| `router = AgentRouter(tags=[...])`, `@router.reasoner()`, `app.include_router(router)` | `r := agent.NewRouter(); r.RegisterReasoner(name, h)`; `app.IncludeRouter(r, agent.RouterOptions{Tags: tags})` (no Prefix). | +| `await router.call(f"{NODE_ID}.x", a=1, b=2)` | `app.Call(ctx, nodeID+".x", map[string]any{"a":1,"b":2})` — returns the reasoner's result map already unwrapped on success; error on failure. Keep `afx.Unwrap(raw, name)` that mirrors `_unwrap` (error dict → error; `"output"` / `"result"` keys → inner) and `afx.AsMap` (`_as_dict`) for parity; apply them to the returned map exactly where Python does. The ctx passed MUST be the handler's ctx (carries the execution context so the CP parents the child execution). | +| `await app.harness(prompt=p, schema=Model, cwd=c, project_dir=d)` | `harnessx.Run[Model](ctx, app, p, harness.Options{Cwd:c, ProjectDir:d})` — provider/model/max_turns/env/permission come from the agent default HarnessConfig (the Go SDK merges them). `chain_builder` calls harness with NO schema (`app.harness(prompt, cwd=repo_path)`) → `app.Harness(ctx, prompt, nil, nil, opts)` and read `Result.Result` text. | +| `extract_harness_result(result, Model, name)` | `harnessx.Extract[Model](res, name)`: IsError → print the same diagnostic line and return `fmt.Errorf("%s harness error: %s", name, res.ErrorMessage)`; Parsed → value; else TypeError-equivalent error `"%s did not return a valid %s"`. | +| `await app.ai(user=prompt, schema=Model)` / `router.ai(system=, user=, schema=)` | `aix.Structured[Model](ctx, app, system, user)` → `app.AI(ctx, user, ai.WithSystem(system) if system!="", ai.WithSchema(json.RawMessage(strictified schema)))` then `resp.JSON(&v)`. Strictify exactly like Python's `_strictify_openai_schema` (every object: `additionalProperties:false`, `required` = all property names, recursing into `$defs`/`properties`/`items`/`anyOf`). | +| `app.note(msg, tags=[...])` / `router.note(...)` | `app.Note(ctx, msg, tags...)` — same message string, same tag order. | +| `HTTPException(400, detail={"error": msg})` | `return nil, &agent.ExecuteError{StatusCode: 400, Message: msg}` | +| `HTTPException(500, detail={"error": "audit execution failed: ..."})` | `&agent.ExecuteError{StatusCode: 500, Message: "audit execution failed: "+err.Error()}` (after the same `app.Note("Audit pipeline failed: ...", "audit","error")`) | +| `asyncio.gather(*coros)` | `errgroup` / WaitGroup writing into a pre-indexed slice (order preserved). `return_exceptions=True` → per-index error slots. | +| `asyncio.Semaphore(n)` | `semaphore.NewWeighted(n)` from x/sync (or a buffered chan). | +| `asyncio.Queue` producer/consumer (hunt incremental dedup) | channel + consumer goroutine; preserve the note strings. | +| `model_dump()` | `json.Marshal(struct)` — all fields emitted, no `omitempty` (except where Python has `exclude_none=True`: use `afx.DropNulls` on the marshaled map). | +| `Model.model_validate(d)` / `Model(**d)` | `afx.Bind[Model](d)` (JSON round-trip; UnmarshalJSON seeds defaults). | +| `str(float)` in prompts | `pyfmt.FormatFloat` ; `round(x, n)` → `pyfmt.Round` (half-even, like pr-af's). | +| `datetime.now(UTC)` inside `model_dump()` (serialized by FastAPI `jsonable_encoder` → `datetime.isoformat()`) | VERIFIED: `2026-01-02T03:04:05.123456+00:00` (microseconds omitted when zero: `2026-01-02T03:04:05+00:00`). Implement `schemas.Timestamp` (time.Time wrapper) whose MarshalJSON emits exactly that; UnmarshalJSON accepts RFC3339 with or without fraction and `Z`. | + +Python round-trips every reasoner boundary through JSON (`model_dump()` → +control plane → `model_validate`). Go must tolerate the same inputs: numbers +arrive as float64 in `map[string]any`; `afx.Bind` handles that. + +## 2b. Shared test fake and Python-JSON parity helper + +- `internal/appx.Fake` (already written) is THE test double for every package: + scripted `HarnessFn`/`AIFn`/`CallFn` (helpers `appx.HarnessJSON`, `appx.AIJSON`), + recorded `Harnesses`/`AIs`/`Notes`/`Calls`, and `MaxConcurrentHarness()` / + `MaxConcurrentCalls()` for semaphore assertions. Do not write another fake. +- `pyfmt.Dumps(v any, indent int) string` reproduces Python `json.dumps(x, indent=n)` + applied to a pydantic `model_dump()` dict: walks Go values by reflection + (struct fields in declaration order honoring json tags, pointers, slices, + maps with SORTED keys — documented deviation, Python keeps insertion order — + float64 kinds rendered as Python float repr e.g. `1.0`, ints as ints, + `true/false/null`, strings escaped like Python's `ensure_ascii=True` (non-ASCII + → `\uXXXX`, and NO escaping of `<>&`), values implementing json.Marshaler + (e.g. `schemas.Timestamp`) rendered via their MarshalJSON. `pyfmt.Dumps(v, 0)` + /`DumpsCompact` = `json.dumps(x)` with `", "` and `": "` separators. Use it + wherever Python embeds `json.dumps(...)` output in a prompt, a checkpoint file, + or an output artifact that a test compares textually. + +## 2c. Foundation API facts (as actually landed — read the code, these are pointers) + +- `harnessx.Run[T](ctx, app, prompt, opts) (*T, *harness.Result, error)`, + `harnessx.Extract[T](res *harness.Result, dest *T, agentName string) (T, error)`, + `harnessx.RunExtract[T](ctx, app appx.Harnesser, prompt string, opts harness.Options, agentName string) (T, error)` + (the 3-arg Extract is deliberate: the Go SDK stores the dest pointer in Result.Parsed), + `harnessx.SchemaFor[T]() map[string]any` (fixture by Go type name, invopop fallback). +- `aix.Structured[T](ctx, app appx.AIer, system, user string) (T, error)`, `aix.Strictify`. +- `afx.Bind[T]`, `afx.ToMap`, `afx.Unwrap(raw any, name string) (any, error)`, `afx.AsMap(payload any, name string) (map[string]any, error)`, `afx.DropNulls(any) any`. sec-af has only the lenient `Unwrap` (its two Python copies — app.py's and reasoners/phases.py's — are identical). +- `pyfmt.Round(x, ndigits)`, `pyfmt.FormatFloat`, `pyfmt.Str`, `pyfmt.Repr` (maps sorted; use `pyfmt.Ordered`/`pyfmt.O(...)` for insertion-ordered dict repr), `pyfmt.KV`, `pyfmt.Dumps` (`pyfmt/pyjson.go`). +- `prompts.Load(rel) (string, error)`, `prompts.MustLoad(rel)`, `prompts.Names()`; files under `internal/prompts/files//prompts>`. +- `config`: sec-af `DepthProfile` + `NormalizeDepth`, `BudgetConfig`/`DefaultBudgetConfig`, `AuditConfig` + `FromInput(in any, repoPath) (AuditConfig, error)` (JSON-projects any AuditInput-shaped value; `FromInputFields` typed core), `AIIntegrationConfig` + `AIConfigFromEnv() (AIIntegrationConfig, error)` + `ProviderEnv() (map[string]string, error)` (node must fail boot on error, like Python). +- `schemas`: struct per pydantic class, `New()` constructors (these mint uuid4 ids; `UnmarshalJSON` seeds defaults but never mints ids), `Timestamp`, enums as string types with `Parse*`/`Valid`. The enums have NO strict `UnmarshalJSON`, so `afx.Bind` alone will accept an out-of-vocabulary value — `internal/phases/validate.go` is where pydantic's enum gate is reproduced, and every `.call` boundary must bind through it. + Name collisions: `schemas.DataFlowStep` = prove's (file/line/description/tainted); `schemas.ReconDataFlowStep` = recon's (file_path/line/component/operation); `Location/CvssV4Score/EpssScore/ReproductionStep` declared once (output.go); `HuntStrategy` has the LOGIC_BUGS value alias; `schemas.PolicyEvalResult` exists. +- Stale Python tests discovered (port the CODE behavior, note the stale assertion): `tests/test_scoring.py` has two reachability-default assertions the code contradicts (empty tags → externally_reachable 1.0). +- Live verification uses a mock `opencode` shim that resolves every prompt role and writes canned schema-valid JSON (see §7). Known nondeterminism: `run_verdict_agent` is a real `.ai()` call, so the remediation fan-out varies with the LLM. + +## 3. sec-af port map (Python module → Go package) + +| Python | Go package | Notes | +|---|---|---| +| `app.py` | `internal/node` (+ `cmd/sec-af`) | `audit` reasoner: build AuditInput (same defaults: scan_types `["sast","sca","secrets","config"]`, output_formats `["json"]`, exclude_paths `["tests/","vendor/","node_modules/",".git/"]`), `_resolve_repo` (local dir → abs; http(s)/git@ → clone into `SEC_AF_WORKSPACES_DIR` default `/workspaces`, PermissionError fallback `~/.sec-af/workspaces`, `git pull --ff-only` if exists, `git clone --depth 1`, env GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=echo, timeouts 60/120s; else `SEC_AF_REPO_PATH` or cwd), orchestrator construction, `resume_from_checkpoint` branch, the 4 `app.call`s in order with the SAME kwargs, checkpoint writes, `agent_invocations = total_selected + len(strategies_run) + 3`, `_generate_output`, notes, error mapping. `/health` is served by the SDK already (`{"status":"healthy",...}`) — do not add a custom route unless the SDK's differs materially; document. | +| `reasoners/__init__.py` + `recon.py` + `hunt.py` + `prove.py` + `phases.py` | `internal/reasoners` (registration + thin adapters) and `internal/phases` (the `*_phase` + `run_cwe_expansion` bodies) | Register EXACTLY these 33 router reasoners (tags `security, audit, red-team`) in this order: `run_architecture_mapper, run_dependency_auditor, run_config_scanner, run_data_flow_mapper, run_security_context_profiler, run_injection_hunter, run_dos_hunter, run_ssrf_hunter, run_auth_hunter, run_xss_hunter, run_crypto_hunter, run_business_logic_hunter, run_logic_bugs_hunter, run_data_exposure_hunter, run_supply_chain_hunter, run_config_secrets_hunter, run_api_security_hunter, run_deduplicator, run_dep_reachability, run_verifier, run_tracer, run_sanitization_analyzer, run_exploit_hypothesizer, run_verdict_agent, run_remediation, run_remediation_agent, run_dast_verifier, run_cross_service_analyzer, run_cwe_expansion, recon_phase, hunt_phase, prove_phase, remediation_phase` plus the top-level `audit` (34 total). Each adapter: the "X starting" note with its exact tags, bind inputs, call the agent function, return `model_dump()` map. `_run_hunter`'s TypeError-cascade is Python duck-typing noise — Go calls each hunter with the single real signature `(ctx, app, repoPath, recon, depth, maxFilesWithoutSignal)`. `_recon_model` normalization (seed defaults then overlay) → Bind with default-seeding handles it; keep `security_context` default `{"auth_model":"unknown","auth_details":""}`. `_coerce_verifier_finding` (RawFinding or FindingForVerifier projection) must be ported. | +| `orchestrator.py` | `internal/orch` | Port the whole class: `AuditOrchestrator` fields, `run()` (streaming in-process path — keep for completeness, it is not reached by the API but `run_from_checkpoint` uses `_run_hunt/_run_prove` which are in-process), `run_from_checkpoint`, `_run_recon/_run_fast_recon/_run_deep_recon_async/_merge_recon`, `_run_hunt(_streaming)`, `_run_prove(_streaming)`, `_run_dast_verification`, `_generate_output` (severity threshold filter, CWE floor, exploitability score, `get_compliance_mappings_hybrid` with the AI gate, verdict/severity counts, noise reduction, AttackChain mapping, compliance gaps, SecurityAuditResult, sarif/json/report generation, compliance report files under checkpoint_dir), checkpoints (`checkpoint-.json` with `{"phase","created_at","data"}` and `indent=2`), `_prover_cap`, `_prioritize_findings`, `_assess_reachability_parallel` (semaphore min(5,n), fallback tag `requires_auth`), budget/cost helpers, `_PhaseHarnessProxy` (a wrapper that checks budget, counts invocations and registers cost — it exposes ONLY `harness`, exactly as the Python class does, so dedup's `hasattr(app,"ai")` gate is False and run_prove's verdict call raises AttributeError), `_emit_progress` (AuditProgress JSON note), `_track_drop`, `_verified_finding_fallback`, `merge_recon_findings_into_hunt`. | +| `harness.py` | `internal/harnessx` (+ `AIGateWrapper` in `internal/gates`) | `HarnessWrapper` is unused by the live path — port `AIGateWrapper` (invoke with retry on transient errors, `classify_severity`, `check_duplicate`, `select_strategy`, `assess_reachability`, the exact prompt strings incl. Python list repr of `default_candidates`) since the orchestrator uses it (`assess_reachability`, `get_compliance_mappings_hybrid`). `_is_transient_error` patterns + backoff (`min(initial*2**attempt, max)`) must match. Port `HarnessWrapper` too only if cheap (it is pure; `_with_phase_guidance`/`_schema_guidance` strings are testable) — lower priority. | +| `config.py` | `internal/config` | env names: `SEC_AF_PROVIDER`/`HARNESS_PROVIDER` (default `aforge`), `SEC_AF_MODEL`/`HARNESS_MODEL` (default `minimax/minimax-m2.5`), `SEC_AF_AI_MODEL`/`AI_MODEL`/`SEC_AF_MODEL` (default `minimax/minimax-m2.5`), `SEC_AF_MAX_TURNS` 50, `SEC_AF_AI_MAX_RETRIES` 3, `SEC_AF_AI_INITIAL_BACKOFF_SECONDS` 2.0, `SEC_AF_AI_MAX_BACKOFF_SECONDS` 8.0, `SEC_AF_OPENCODE_BIN` opencode, `SEC_AF_AFORGE_BIN`/`AFORGE_BIN` aforge, `SEC_AF_OPENCODE_SERVER`/`OPENCODE_SERVER`. `provider_env()`: the 6 keys + `AGENTFIELD_AFORGE_COMMAND` (default exec) + `XDG_DATA_HOME` (default `/opencode-shared-data`, mkdir). Malformed ints crash at boot (Python crashes at import) → return error from `FromEnv`. | +| `context.py` | `internal/context` (package name `recontext` to avoid clashing with std `context`) | `prune_recon_for_strategy`, `recon_context_generic`, the per-strategy projections, `get_framework_hints`/`get_language_hints` wiring (hints live in agents/hunt in Python — put them in `internal/agents/hunt/hints.go` and import). Outputs are embedded in prompts → byte-exact; golden test against Python via `scripts/gen_golden.py`. | +| `schemas/*.py` | `internal/schemas` | One Go file per Python module. Enums: `Severity, Confidence, FindingType, HuntStrategy, Verdict, EvidenceLevel, ...` as `type X string` + consts. Pydantic defaults ≠ Go zero → `UnmarshalJSON` default seeding (pr-af `schemas/defaults.go` pattern). Computed properties (`RawFinding.fingerprint` default, `id` default uuid/hash, `for_verifier()` projection) → methods; check how `id`/`fingerprint` defaults are generated in Python and reproduce (if `uuid4` → `uuid.NewString()` — accept nondeterminism; if hash → exact). `SecurityAuditResult.timestamp` → Timestamp type (§2). Field descriptions: keep as Go doc comments (they matter for the pydantic schema fixtures, which come from Python, so no need to reproduce in Go tags). | +| `schemas/gates.py` | `internal/schemas/gates.go` | `CWEExpansion, DuplicateCheck, ReachabilityGate, SeverityClassification, StrategySelection, ...` — used by aix. | +| `scoring.py` | `internal/scoring` | port + port `tests/test_scoring.py` fully. | +| `compliance/mapping.py` | `internal/compliance` | static tables byte-exact; `get_compliance_mappings`, `get_compliance_gaps`, `get_compliance_mappings_hybrid(cwe, frameworks, ai_gate)`; port `tests/test_compliance.py`. | +| `output/sarif.py, json_output.py, report.py, compliance_report.py` | `internal/output` | SARIF JSON must be byte-comparable modulo key order → build with ordered structs; port `tests/test_sarif.py`, `test_json_output.py`, `test_compliance_report.py`. Python `json.dumps(indent=2)` → Go `json.MarshalIndent(v,""," ")` (note Python puts a space after `:` and `,` — MarshalIndent matches; but Python escapes non-ASCII as `\uXXXX` by default (`ensure_ascii=True`) and Go escapes `<>&` as `<` — document; where a test compares strings, compare parsed JSON). | +| `diff_analysis.py`, `monitoring.py`, `policies.py` | `internal/diffanalysis`, `internal/monitoring`, `internal/policies` | pure modules with Python tests → port them + tests (monitoring/policies are not wired into the API; port for 1:1 completeness; `audit.py` stub can be skipped — note it). | +| `agents/_utils.py` | `internal/harnessx` (Extract) | see §2. | +| `agents/recon/*` | `internal/agents/recon` | 5 mappers (each: read prompt template, append the exact CONTEXT block, `tempfile.mkdtemp(prefix=f"secaf-{agent_name}-")` → `os.MkdirTemp("", "secaf-"+name+"-")`, harness with `cwd=tmp, project_dir=repo`, extract, parse `*Raw` → typed via `_parsers.py` port, `defer os.RemoveAll`), `_repo_metrics` (same SKIP_DIRS/CODE_EXTS, count lines of code files, count all files), `extract_recon_findings` (+ `tests/test_recon_findings.py`), `run_recon`, `run_fast_recon`, `run_deep_recon`. | +| `agents/hunt/*` | `internal/agents/hunt` | `_scan_enrich.py` (scan_locations prompt, enrich_finding prompt, `enrich_locations_parallel`, `assemble_finding`), 12 hunters (injection, dos, ssrf, auth, xss, crypto, business_logic, logic, data_exposure, supply_chain, config_secrets, api_security) — each hunter file is a thin wrapper with its strategy name/CWE baseline/prompt path: read them all; `_framework_hints.py`, `_language_hints.py` (static tables, byte-exact), `__init__.py` `run_hunt` + `run_hunt_streaming` + `_default_strategies` + dedup-by-fingerprint + `include_paths` handling (`tests/test_hunt_include_paths.py`, `test_hunt_crypto.py`, `test_strategy_selection.py`). | +| `agents/dedup.py` | `internal/agents/dedup` | `deduplicate_and_correlate` (fingerprint + semantic `.ai(DuplicateCheck)` pass + chain correlation harness call), `tests/test_dedup.py`. | +| `agents/prove/*` | `internal/agents/prove` | `verifier.py` (orchestrates tracer→sanitization→exploit→verdict in-process + `assembler`, `fallback(finding, reason, drop_reason, original_verdict)`), `tracer`, `sanitization`, `exploit`, `verdict` (uses `.ai(schema=VerdictDecision)` — aix), `chain_builder` (no-schema harness), `cross_service`, `dast_verifier`, `dep_reachability`, `sandbox` (subprocess w/ limits → `exec.CommandContext` + timeout), `__init__.py` `run_prove`/`run_prove_streaming`; `tests/test_prove_phase_demotion.py`. | +| `agents/remediation.py` | `internal/agents/remediation` | `generate_remediation` (VerifiedFinding → RemediationSuggestion) and `run_remediation` (RawFinding+verdict+rationale). | +| `prompts/**/*.txt` (in `src/sec_af/prompts`) | `internal/prompts/files/**` (go:embed) | byte-identical copies; `prompts.Load("hunt/injection.txt")`; drift test walks `../../../src/sec_af/prompts` when it exists and asserts byte equality for every file in both directions. | + +### The DAG the control plane must show + +``` +audit +├── recon_phase +│ ├── run_architecture_mapper ┐ +│ ├── run_dependency_auditor ├ gather (3) +│ ├── run_config_scanner ┘ +│ ├── run_data_flow_mapper ┐ gather (2) — skipped when depth == quick +│ └── run_security_context_profiler ┘ +├── hunt_phase +│ ├── run__hunter × N (semaphore max(1,min(max_concurrent_hunters=4, N)); N from _default_strategies) +│ └── run_deduplicator (only when ≥1 fingerprint-unique finding) +├── prove_phase +│ └── run_verifier × K (K = min(len(findings), prover cap: quick 10 / standard 30 / thorough 10000, max_provers); semaphore 3) +└── remediation_phase + └── run_remediation × M (M = confirmed/likely findings without remediation; semaphore 3) +``` + +`run_cwe_expansion` is registered but Python calls `expand_cwes_for_hunt` +in-process from `hunt_phase` (an `.ai()` call, not a `.call`) — reproduce +that: no DAG node for it. + +## 5. Testing contract + +- Port EVERY Python test file to a Go test in the owning package (same + assertions, same fixtures). Name tests after the Python ones so reviewers + can diff coverage (`TestScoring_...` ↔ `test_scoring.py::test_...`). +- Add golden tests for every prompt-building function whose output reaches + the LLM (`scripts/gen_golden.py` runs the Python builders with fixed inputs + and writes `testdata/golden/*.txt`; the Go test renders the same inputs and + compares byte-for-byte). Commit the generator AND the goldens. +- Schema fixtures: `scripts/gen_schemas.py` imports the pydantic models and + writes `model_json_schema()` JSON for every model that is passed to + `app.harness(schema=)` or `app.ai(schema=)`. Commit them under + `internal/harnessx/testdata/schemas/`. Add the pr-af drift test that checks + every embedded schema's `properties` keys ⊆ the Go struct's json tags and + vice-versa (required ones). +- Concurrency tests for each phase: a fake `appx.App` whose `Call` records + (target, kwargs) — assert the exact target names, kwargs keys, call counts, + order where Python orders, and the max observed concurrency ≤ the semaphore + limit. +- Node tests: registration parity (exact ordered name list + tags), `audit` / + `scan` input binding defaults, error mapping. +- Functional test (build tag) against a live CP is optional; the manual live + verification (§7) is mandatory and done by the integrator. + +## 6. Packaging + +Copy pr-af's `go/Dockerfile`, `go/docker-entrypoint.sh`, `go/Makefile`, +`docker-compose.go.yml`, `go/README.md`, root README section, root manifest +redirect, and adapt: binary name, user (`secaf`), ports, env var names +(`HARNESS_PROVIDER`, `HARNESS_MODEL`, `AI_MODEL`, `SEC_AF_*`, +`SEC_AF_WORKSPACES_DIR`), the aforge fetch +stage (take it from the repo's OWN Python Dockerfile — it is already the +checksum-verified download), the Python image's opencode config (the Go +entrypoint generates it from `HARNESS_MODEL`). The Go manifest's +`user_environment` block = the root manifest's block (same keys) — the Go node +reads the same env vars. `go.mod` requires +`github.com/Agent-Field/agentfield/sdk/go v0.1.131` (no replace). +CI: `.github/workflows/go.yml` — `actions/setup-go` with `go-version-file: +go/go.mod`, `working-directory: go`, steps `go build ./...`, `go vet ./...`, +`go test ./...`, `test -z "$(gofmt -l .)"`, `docker build -f go/Dockerfile .`. + +## 7. Live verification (integrator) + +1. Isolated control plane (the `af` binary, or a fresh build) on a free port + with `HOME` + `AGENTFIELD_HOME` pointed at a scratch dir — never a control + plane you do not own. +2. Python node (`pip install -e .` in a venv, or the installed package) and Go + node (`go run ./cmd/sec-af`) both registered (distinct NODE_IDs), same + `OPENROUTER_API_KEY`, same harness provider/model, same + `SEC_AF_WORKSPACES_DIR`. +3. Deterministic DAG comparison: a mock harness CLI (`HARNESS_PROVIDER=opencode` + + `SEC_AF_OPENCODE_BIN` pointing at a shim that recognizes the prompt's role + and writes canned schema-valid JSON to the output file; see pr-af + `go/test/mockcli`) → run `audit` on the same fixture repo through both nodes → pull `/api/v1/executions?...`/workflow + tree for each run and compare the node/edge multiset (parent→child + reasoner names, counts). Must be identical. +4. Real run (depth `quick`) of the Go node on a small public vulnerable repo + with the real key → succeeds, result JSON has the expected keys, DAG shape + matches the Python structure. diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..1fab92c --- /dev/null +++ b/go/go.mod @@ -0,0 +1,20 @@ +module github.com/Agent-Field/sec-af/go + +// Match the AgentField Go SDK's go directive (sdk/go/go.mod: go 1.21) so the +// two modules resolve identically under the dev workspace and in CI/Docker. +go 1.21 + +require ( + github.com/Agent-Field/agentfield/sdk/go v0.1.131 + github.com/invopop/jsonschema v0.13.0 + golang.org/x/sync v0.11.0 +) + +require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..c946edb --- /dev/null +++ b/go/go.sum @@ -0,0 +1,27 @@ +github.com/Agent-Field/agentfield/sdk/go v0.1.131 h1:WikeIiY0tT5WaQg7pAie5TYj8sm11/pcHO6fmiVCGAs= +github.com/Agent-Field/agentfield/sdk/go v0.1.131/go.mod h1:08VZk14uw4GJH6a34psHkuLu+DcRr197Zi0IGmLlfrM= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go/internal/afx/bind.go b/go/internal/afx/bind.go new file mode 100644 index 0000000..aa1b091 --- /dev/null +++ b/go/internal/afx/bind.go @@ -0,0 +1,123 @@ +// Package afx holds the small ergonomics over the AgentField Go SDK that every +// reasoner handler, phase and orchestrator step in the SEC-AF port reuses: +// +// - Bind/ToMap — the map[string]any <-> typed struct boundary that stands in +// for pydantic's Model.model_validate / Model.model_dump; +// - Unwrap/AsMap — byte-exact ports of the private _unwrap/_as_dict helpers +// that SEC-AF applies to every `await router.call(...)` result; +// - DropNulls — the model_dump(exclude_none=True) filter; +// - WireNumbers — the int-vs-float distinction CPython's json.loads makes and +// Go's decoder does not (wire.go). +package afx + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" +) + +// Bind decodes a reasoner's untyped input map into a typed value T. +// +// Handlers registered with the SDK receive input as map[string]any. Bind +// round-trips that map through JSON (marshal then unmarshal into T), which +// mirrors how Python materializes a pydantic model from the request body: +// field-name matching is by the json struct tags (the exact snake_case pydantic +// field names), and any custom UnmarshalJSON on T runs — so a T whose +// UnmarshalJSON seeds non-zero pydantic defaults gets those defaults for keys +// absent from the input (the schemas package's default-seeding pattern). +// +// Plain encoding/json is deliberate: no json.Decoder/UseNumber. Numbers in the +// input map are already Go float64/int (they came from the SDK's own JSON +// decode, or from a Go caller), and re-marshaling then unmarshaling into the +// typed fields of T yields the correct concrete types without number-precision +// gymnastics. +// +// Bind is NOT the whole of `Model.model_validate(dict)`. Three pydantic +// behaviours have no encoding/json equivalent, and internal/phases' checked +// binders (BindRawFinding, BindVerifiedFinding, BindReconResult, …) supply all +// three on top of Bind — use those for anything arriving off a `.call` +// boundary: +// +// - a MISSING required field is a ValidationError in Python; here it keeps +// the Go zero value (or the seeded pydantic default); +// - an explicit NULL on a non-Optional field is a ValidationError in Python; +// here it is a no-op for scalars and ZEROES a slice/map/pointer, which also +// wipes the `[]` the schemas package seeded; +// - pydantic's LAX mode parses a string-encoded number into an int/float +// field (`start_line="10"` -> 10); json.Unmarshal answers +// UnmarshalTypeError. +func Bind[T any](input map[string]any) (T, error) { + var out T + b, err := json.Marshal(input) + if err != nil { + return out, fmt.Errorf("afx.Bind: marshal input: %w", err) + } + if err := json.Unmarshal(b, &out); err != nil { + return out, fmt.Errorf("afx.Bind: unmarshal into %T: %w", out, err) + } + return out, nil +} + +// ToMap is Bind's inverse: it renders a typed struct as the map[string]any +// shape the SDK's reasoner handlers (and Agent.Call) accept. Top-level +// exported fields become map entries keyed by their json tag, and the field +// VALUES stay typed — deliberately NOT a marshal->unmarshal round trip, which +// would decode nested values into plain Go maps and lose whatever their custom +// marshalers encode (ordered objects, the Timestamp wrapper's exact isoformat +// spelling). Keeping values typed lets Bind on the handler side — and the SDK's +// own workflow-event emitter — re-marshal them through the same custom +// marshalers, so ToMap -> Bind is lossless. +// +// The reasoner input structs are flat, fully json-tagged, and carry no +// omitempty (every key is emitted, so Bind-side default seeding never overrides +// a deliberately zero field); ToMap ignores omitempty accordingly. Anonymous +// EXPORTED struct fields without their own json tag are flattened the way +// encoding/json flattens them; an embedded field of UNEXPORTED type is skipped +// (encoding/json would promote its exported fields, but reflect refuses to read +// through an unexported field, and the port has no such struct). +func ToMap(v any) (map[string]any, error) { + rv := reflect.ValueOf(v) + for rv.Kind() == reflect.Pointer { + if rv.IsNil() { + return nil, fmt.Errorf("afx.ToMap: nil %T", v) + } + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + return nil, fmt.Errorf("afx.ToMap: %T is not a struct", v) + } + out := make(map[string]any, rv.NumField()) + fillMap(out, rv) + return out, nil +} + +// fillMap writes rv's fields into out, recursing through untagged anonymous +// struct fields (encoding/json flattening). +func fillMap(out map[string]any, rv reflect.Value) { + rt := rv.Type() + for i := 0; i < rt.NumField(); i++ { + f := rt.Field(i) + if !f.IsExported() { + continue + } + name, _, _ := strings.Cut(f.Tag.Get("json"), ",") + if name == "-" { + continue + } + if name == "" { + if f.Anonymous { + fv := rv.Field(i) + for fv.Kind() == reflect.Pointer && !fv.IsNil() { + fv = fv.Elem() + } + if fv.Kind() == reflect.Struct { + fillMap(out, fv) + continue + } + } + name = f.Name + } + out[name] = rv.Field(i).Interface() + } +} diff --git a/go/internal/afx/bind_test.go b/go/internal/afx/bind_test.go new file mode 100644 index 0000000..2d7befd --- /dev/null +++ b/go/internal/afx/bind_test.go @@ -0,0 +1,184 @@ +package afx + +import ( + "encoding/json" + "reflect" + "testing" +) + +// depthDefaulted models the schemas-package pattern: pydantic defaults that are +// not the Go zero value are seeded in UnmarshalJSON, so Bind on a map missing +// those keys still produces the Python default. +type depthDefaulted struct { + RepoPath string `json:"repo_path"` + Depth string `json:"depth"` + SeverityThreshold string `json:"severity_threshold"` + ScanTypes []string `json:"scan_types"` + MaxProvers *int `json:"max_provers"` +} + +func (d *depthDefaulted) UnmarshalJSON(b []byte) error { + type alias depthDefaulted + v := alias{ + Depth: "standard", + SeverityThreshold: "low", + ScanTypes: []string{"sast", "sca", "secrets", "config"}, + } + if err := json.Unmarshal(b, &v); err != nil { + return err + } + *d = depthDefaulted(v) + return nil +} + +// TestBindSeedsPydanticDefaults: Bind must run T's UnmarshalJSON so absent keys +// pick up the pydantic default rather than the Go zero value — the behaviour +// `AuditInput(**payload)` has in Python. +func TestBindSeedsPydanticDefaults(t *testing.T) { + got, err := Bind[depthDefaulted](map[string]any{"repo_path": "/tmp/repo"}) + if err != nil { + t.Fatalf("Bind: %v", err) + } + want := depthDefaulted{ + RepoPath: "/tmp/repo", + Depth: "standard", + SeverityThreshold: "low", + ScanTypes: []string{"sast", "sca", "secrets", "config"}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("Bind = %#v, want %#v", got, want) + } +} + +// TestBindOverridesDefaults: keys present in the input win over the seed. +func TestBindOverridesDefaults(t *testing.T) { + got, err := Bind[depthDefaulted](map[string]any{ + "repo_path": "/tmp/repo", + "depth": "thorough", + "scan_types": []any{"sast"}, + "max_provers": 4, + }) + if err != nil { + t.Fatalf("Bind: %v", err) + } + if got.Depth != "thorough" { + t.Errorf("Depth = %q, want thorough", got.Depth) + } + if !reflect.DeepEqual(got.ScanTypes, []string{"sast"}) { + t.Errorf("ScanTypes = %#v", got.ScanTypes) + } + if got.MaxProvers == nil || *got.MaxProvers != 4 { + t.Errorf("MaxProvers = %#v, want 4", got.MaxProvers) + } +} + +// TestBindAcceptsJSONNumbers: reasoner inputs arrive over the control plane as +// JSON, so every number is a float64 by the time it reaches a handler. Bind +// must land those in int/float fields alike. +func TestBindAcceptsJSONNumbers(t *testing.T) { + type numeric struct { + Turns int `json:"turns"` + Cost float64 `json:"cost"` + } + got, err := Bind[numeric](map[string]any{"turns": float64(50), "cost": float64(1)}) + if err != nil { + t.Fatalf("Bind: %v", err) + } + if got.Turns != 50 || got.Cost != 1 { + t.Errorf("Bind = %#v, want {50 1}", got) + } +} + +// TestBindTypeMismatchIsAnError mirrors pydantic's ValidationError. +func TestBindTypeMismatchIsAnError(t *testing.T) { + type numeric struct { + Turns int `json:"turns"` + } + if _, err := Bind[numeric](map[string]any{"turns": "fifty"}); err == nil { + t.Fatal("Bind should reject a string in an int field") + } +} + +// TestToMapUsesJSONTagsAndKeepsValuesTyped. +func TestToMapUsesJSONTagsAndKeepsValuesTyped(t *testing.T) { + type inner struct { + N int `json:"n"` + } + type outer struct { + RepoPath string `json:"repo_path"` + Depth string `json:"depth"` + Nested inner `json:"nested"` + Tags []string `json:"tags"` + Skipped string `json:"-"` + unset string //nolint:unused // unexported fields are skipped + } + got, err := ToMap(outer{RepoPath: "/r", Depth: "quick", Nested: inner{N: 3}, Tags: []string{"a"}, Skipped: "x"}) + if err != nil { + t.Fatalf("ToMap: %v", err) + } + if got["repo_path"] != "/r" || got["depth"] != "quick" { + t.Errorf("ToMap = %#v", got) + } + if _, present := got["-"]; present { + t.Error(`ToMap emitted the json:"-" field`) + } + if _, present := got["Skipped"]; present { + t.Error(`ToMap emitted the json:"-" field under its Go name`) + } + if _, present := got["unset"]; present { + t.Error("ToMap emitted an unexported field") + } + // Values stay typed — not flattened into map[string]any. + if _, ok := got["nested"].(inner); !ok { + t.Errorf("ToMap flattened a nested struct: %T", got["nested"]) + } + if _, ok := got["tags"].([]string); !ok { + t.Errorf("ToMap changed a slice's type: %T", got["tags"]) + } +} + +// TestToMapFlattensEmbedded mirrors encoding/json's anonymous-field flattening. +func TestToMapFlattensEmbedded(t *testing.T) { + type Base struct { + A string `json:"a"` + } + type derived struct { + Base + B string `json:"b"` + } + got, err := ToMap(derived{Base: Base{A: "1"}, B: "2"}) + if err != nil { + t.Fatalf("ToMap: %v", err) + } + if got["a"] != "1" || got["b"] != "2" { + t.Errorf("ToMap = %#v, want flattened {a:1 b:2}", got) + } +} + +// TestToMapRejectsNonStructs. +func TestToMapRejectsNonStructs(t *testing.T) { + if _, err := ToMap(map[string]any{"a": 1}); err == nil { + t.Error("ToMap should reject a map") + } + var p *struct{ A int } + if _, err := ToMap(p); err == nil { + t.Error("ToMap should reject a nil pointer") + } +} + +// TestToMapThenBindRoundTrips: the phase code hands ToMap output to app.Call +// and the handler Binds it back. +func TestToMapThenBindRoundTrips(t *testing.T) { + in := depthDefaulted{RepoPath: "/r", Depth: "thorough", SeverityThreshold: "high", ScanTypes: []string{"sast"}} + m, err := ToMap(in) + if err != nil { + t.Fatalf("ToMap: %v", err) + } + back, err := Bind[depthDefaulted](m) + if err != nil { + t.Fatalf("Bind: %v", err) + } + if !reflect.DeepEqual(back, in) { + t.Errorf("round trip = %#v, want %#v", back, in) + } +} diff --git a/go/internal/afx/unwrap.go b/go/internal/afx/unwrap.go new file mode 100644 index 0000000..560519d --- /dev/null +++ b/go/internal/afx/unwrap.go @@ -0,0 +1,258 @@ +package afx + +import ( + "fmt" + "reflect" + + "github.com/Agent-Field/sec-af/go/internal/pyfmt" +) + +// Unwrap ports the private _unwrap helper that SEC-AF defines TWICE, verbatim +// and identically, in src/sec_af/app.py:59 and src/sec_af/reasoners/phases.py:34: +// +// def _unwrap(result: object, name: str) -> object: +// if isinstance(result, dict): +// if "error" in result and isinstance(result["error"], dict): +// message = result["error"].get("message") or result["error"].get("detail") or str(result["error"]) +// raise RuntimeError(f"{name} failed: {message}") +// if "output" in result: +// return result["output"] +// if "result" in result: +// return result["result"] +// return result +// +// It is applied to the return value of every `await router.call(f"{NODE_ID}.x", ...)` +// before _as_dict/model_validate. Both call sites are byte-identical in THIS +// repo, so there is a single Go function; cloudsecurity-af carries a stricter +// variant (it additionally fails on an "error_message" key and on +// status in ("failed", "error")) which deliberately does NOT exist here — do +// not add it. +// +// Parity details worth keeping in mind: +// +// - The error branch fires only when result["error"] is itself a dict. A +// string "error" value falls through to the "output"/"result" lookups. +// - `a or b or c` is PYTHON truthiness, not a nil check: an empty-string +// message falls through to "detail", and an empty detail falls through to +// str(the whole error dict). +// - The "output"/"result" lookups test key PRESENCE, not truthiness, so a +// present-but-null "output" unwraps to nil (and then trips AsMap). +// +// Python parity divergence: the str(error_dict) fallback renders a Python dict +// repr. Python dicts iterate in insertion order; a Go map cannot, so +// pyfmt.Str sorts the keys to stay deterministic. A numeric value inside that +// dict also renders as a float ({'code': 500.0}) rather than an int, because +// encoding/json decodes every JSON number to float64 — see PyTypeName for the +// same caveat. Both only affect the text of an error raised for a malformed +// error envelope that carried neither a "message" nor a "detail". +func Unwrap(raw any, name string) (any, error) { + m, ok := raw.(map[string]any) + if !ok { + return raw, nil + } + + if errVal, present := m["error"]; present { + if errMap, isDict := errVal.(map[string]any); isDict { + message := errMap["message"] + if !pyTruthy(message) { + message = errMap["detail"] + } + if !pyTruthy(message) { + return nil, fmt.Errorf("%s failed: %s", name, pyfmt.Str(errMap)) + } + return nil, fmt.Errorf("%s failed: %s", name, pyfmt.Str(message)) + } + } + if v, present := m["output"]; present { + return v, nil + } + if v, present := m["result"]; present { + return v, nil + } + return raw, nil +} + +// AsMap ports _as_dict (src/sec_af/app.py:71, src/sec_af/reasoners/phases.py:46): +// +// def _as_dict(payload: object, name: str) -> dict[str, Any]: +// if not isinstance(payload, dict): +// raise RuntimeError(f"{name} returned non-dict payload: {type(payload).__name__}") +// return payload +// +// The error text is reproduced exactly, including the Python type NAME of the +// offending payload (see PyTypeName). +// +// A NIL map[string]any is rejected as NoneType, not accepted as `{}`. That case +// is not hypothetical: the Go SDK's `agent.Call` returns `(nil, nil)` for a +// SUCCEEDED execution whose status payload carries `result: null` or no +// "result" key at all (sdk/go/agent/agent.go:2392 guards the unmarshal with +// `len(Result) > 0 && string(Result) != "null"` and then returns the untouched +// nil map). Boxed into an `any` that is a TYPED nil, so `payload.(map[string]any)` +// succeeds with ok=true and m=nil. Python's `Agent.call` hands back `None` for +// the same execution, and `_as_dict(None, name)` raises +// `RuntimeError(" returned non-dict payload: NoneType")` — so without +// this guard a null `.call` result would bind to a default-seeded model +// (BindHuntResult(nil) yields an EMPTY HuntResult with no error) and the audit +// would answer 200 with zero findings where Python answers 500. +// +// An EMPTY BUT NON-NIL map is Python's `{}` and is accepted: encoding/json +// produces a non-nil map for a `{}` body and leaves the map nil for `null`, so +// the two stay distinguishable. +func AsMap(payload any, name string) (map[string]any, error) { + m, ok := payload.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s returned non-dict payload: %s", name, PyTypeName(payload)) + } + if m == nil { + return nil, fmt.Errorf("%s returned non-dict payload: NoneType", name) + } + return m, nil +} + +// PyTypeName renders `type(x).__name__` for a value that came off the wire as +// JSON, which is the only place SEC-AF's error strings expose a type name. +// +// Go Python +// map[string]any dict +// []any / array list +// string str +// float32/64 float +// int kinds int +// bool bool +// nil NoneType +// +// Python parity divergence: Go's encoding/json decodes EVERY JSON number to +// float64, so a bare integral payload reports "float" where CPython's +// json.loads would have produced an int and reported "int". Unreachable in +// practice — the payloads _as_dict guards are reasoner result envelopes — and +// it only changes the text of an error raised on a malformed payload. +func PyTypeName(v any) string { + switch v.(type) { + case nil: + return "NoneType" + case bool: + return "bool" + case string: + return "str" + case float32, float64: + return "float" + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return "int" + } + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Map: + return "dict" + case reflect.Slice, reflect.Array: + return "list" + case reflect.String: + return "str" + case reflect.Bool: + return "bool" + case reflect.Float32, reflect.Float64: + return "float" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return "int" + case reflect.Pointer, reflect.Interface: + if rv.IsNil() { + return "NoneType" + } + return PyTypeName(rv.Elem().Interface()) + } + return reflect.TypeOf(v).Name() +} + +// DropNulls reproduces pydantic's model_dump(exclude_none=True) over a decoded +// JSON value: every mapping entry whose value is None disappears, recursively, +// everywhere in the tree. +// +// Two deliberate scoping rules, both matching pydantic: +// +// - Only MAPPING entries are dropped. A None element inside a list survives, +// because exclude_none is a field-level filter, not a value filter — +// model_dump of `list[str | None]` containing None keeps the None. +// - An empty container is not None and survives ([] and {} are kept). +// +// A nil Go POINTER counts as None alongside the untyped nil interface, so the +// function is correct whether it is handed a json.Unmarshal result (where None +// is always the untyped nil) or a hand-built map. A nil SLICE or MAP does NOT +// count: those stand for empty pydantic collections (`Field(default_factory=list)`), +// which model_dump emits as [] / {} rather than dropping. +// +// The input is never mutated; fresh maps and slices are returned. +func DropNulls(v any) any { + switch x := v.(type) { + case map[string]any: + out := make(map[string]any, len(x)) + for k, val := range x { + if isPyNone(val) { + continue + } + out[k] = DropNulls(val) + } + return out + case []any: + out := make([]any, len(x)) + for i, e := range x { + out[i] = DropNulls(e) + } + return out + default: + return v + } +} + +// isPyNone reports whether val is Python's None: the untyped nil interface or a +// nil pointer. See DropNulls for why nil slices/maps are excluded. +func isPyNone(val any) bool { + if val == nil { + return true + } + rv := reflect.ValueOf(val) + return rv.Kind() == reflect.Pointer && rv.IsNil() +} + +// pyTruthy reproduces Python's `bool(x)` for the value kinds a decoded JSON +// document can hold, which is what the `a or b or c` chain inside _unwrap +// actually tests. Falsy: None, False, 0, 0.0, "", [], {}. Everything else is +// truthy. +func pyTruthy(v any) bool { + switch x := v.(type) { + case nil: + return false + case bool: + return x + case string: + return x != "" + case float64: + return x != 0 + case float32: + return x != 0 + case int: + return x != 0 + case int64: + return x != 0 + } + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.String: + return rv.Len() != 0 + case reflect.Slice, reflect.Array, reflect.Map: + return rv.Len() != 0 + case reflect.Bool: + return rv.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return rv.Int() != 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return rv.Uint() != 0 + case reflect.Float32, reflect.Float64: + return rv.Float() != 0 + case reflect.Pointer, reflect.Interface: + if rv.IsNil() { + return false + } + return pyTruthy(rv.Elem().Interface()) + } + return true +} diff --git a/go/internal/afx/unwrap_test.go b/go/internal/afx/unwrap_test.go new file mode 100644 index 0000000..58356b1 --- /dev/null +++ b/go/internal/afx/unwrap_test.go @@ -0,0 +1,272 @@ +package afx + +import ( + "reflect" + "testing" +) + +// Ground truth for every expectation below was produced by running the exact +// Python bodies of _unwrap / _as_dict (src/sec_af/app.py:59,71 and +// src/sec_af/reasoners/phases.py:34,46) under +// ~/.agentfield/packages/sec-af/venv/bin/python and printing str(exc). + +// TestUnwrapErrorMessages pins the RuntimeError text of the error branch, +// including the `message or detail or str(error)` truthiness cascade. +func TestUnwrapErrorMessages(t *testing.T) { + cases := []struct { + name string + raw map[string]any + target string + wantErr string + }{ + { + // python: _unwrap({"error": {"message": "boom"}}, "recon_phase") + name: "message wins", + raw: map[string]any{"error": map[string]any{"message": "boom"}}, + target: "recon_phase", + wantErr: "recon_phase failed: boom", + }, + { + // python: _unwrap({"error": {"detail": "detail-msg"}}, "hunt_phase") + name: "detail when message absent", + raw: map[string]any{"error": map[string]any{"detail": "detail-msg"}}, + target: "hunt_phase", + wantErr: "hunt_phase failed: detail-msg", + }, + { + // python: _unwrap({"error": {"message": "", "detail": "fallback"}}, "prove_phase") + // The empty message is FALSY, so `or` falls through to detail. + name: "empty message falls through to detail", + raw: map[string]any{"error": map[string]any{"message": "", "detail": "fallback"}}, + target: "prove_phase", + wantErr: "prove_phase failed: fallback", + }, + { + // python: _unwrap({"error": {"code": 500}}, "run_verifier") + name: "neither key -> str(error dict)", + raw: map[string]any{"error": map[string]any{"code": 500}}, + target: "run_verifier", + wantErr: "run_verifier failed: {'code': 500}", + }, + { + // python: _unwrap({"error": {"message": {"a": 1}}}, "run_deduplicator") + // A non-string message still goes through str(). + name: "dict message renders as a python dict repr", + raw: map[string]any{"error": map[string]any{"message": map[string]any{"a": 1}}}, + target: "run_deduplicator", + wantErr: "run_deduplicator failed: {'a': 1}", + }, + { + // python: _unwrap({"error": {"message": None, "detail": None}}, "x") + // -> "x failed: {'message': None, 'detail': None}" + // Go parity divergence: a Go map has no insertion order, so + // pyfmt.Str sorts the keys. Same content, deterministic order. + name: "null message and detail -> sorted dict repr", + raw: map[string]any{"error": map[string]any{"message": nil, "detail": nil}}, + target: "x", + wantErr: "x failed: {'detail': None, 'message': None}", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := Unwrap(c.raw, c.target) + if err == nil { + t.Fatalf("Unwrap returned (%v, nil), want an error", got) + } + if err.Error() != c.wantErr { + t.Errorf("Unwrap error = %q, python = %q", err.Error(), c.wantErr) + } + }) + } +} + +// TestUnwrapSuccessPaths pins the non-error branches, exactly as the Python +// helper resolves them. +func TestUnwrapSuccessPaths(t *testing.T) { + cases := []struct { + name string + raw any + want any + }{ + // python: _unwrap({"output": {"a": 1}}, "x") -> {'a': 1} + {"output key", map[string]any{"output": map[string]any{"a": 1}}, map[string]any{"a": 1}}, + // python: _unwrap({"result": {"b": 2}}, "x") -> {'b': 2} + {"result key", map[string]any{"result": map[string]any{"b": 2}}, map[string]any{"b": 2}}, + // python: _unwrap({"output": None}, "x") -> None + // PRESENCE, not truthiness: a null output unwraps to None (and then + // trips _as_dict), it does not fall through to "result". + {"present-but-null output", map[string]any{"output": nil, "result": map[string]any{"b": 2}}, nil}, + // python: _unwrap({"output": {...}, "result": {...}}, "x") -> the output + {"output beats result", map[string]any{"output": map[string]any{"a": 1}, "result": map[string]any{"b": 2}}, map[string]any{"a": 1}}, + // python: _unwrap({"error": "plain-string", "output": {"a": 1}}, "x") -> {'a': 1} + // The error branch requires error to be a DICT. + {"string error is not the error branch", map[string]any{"error": "plain-string", "output": map[string]any{"a": 1}}, map[string]any{"a": 1}}, + // python: _unwrap({"a": 1}, "x") -> {'a': 1} (returned unchanged) + {"plain map passes through", map[string]any{"a": 1}, map[string]any{"a": 1}}, + // python: _unwrap([1, 2], "x") -> [1, 2] (non-dict returned unchanged) + {"non-map passes through", []any{1, 2}, []any{1, 2}}, + // python: _unwrap("txt", "x") -> 'txt' + {"string passes through", "txt", "txt"}, + {"nil passes through", nil, nil}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := Unwrap(c.raw, "x") + if err != nil { + t.Fatalf("Unwrap: unexpected error %v", err) + } + if !reflect.DeepEqual(got, c.want) { + t.Errorf("Unwrap = %#v, want %#v", got, c.want) + } + }) + } +} + +// TestAsMap pins the _as_dict error text, including the Python type name of the +// offending payload. +func TestAsMap(t *testing.T) { + if got, err := AsMap(map[string]any{"a": 1}, "run_verifier"); err != nil { + t.Fatalf("AsMap on a dict: %v", err) + } else if !reflect.DeepEqual(got, map[string]any{"a": 1}) { + t.Errorf("AsMap returned %#v", got) + } + + cases := []struct { + in any + wantErr string + }{ + // python: _as_dict(None, "run_verifier") + {nil, "run_verifier returned non-dict payload: NoneType"}, + // The shape agent.Call actually produces for a SUCCEEDED execution + // whose result is `null`: a TYPED nil map boxed in an interface, which + // `payload.(map[string]any)` accepts with ok=true. Python's Agent.call + // returns None there, so _as_dict must reject it as NoneType — an + // empty-but-non-nil map (Python's `{}`) is a different value and is + // accepted above. + {map[string]any(nil), "run_verifier returned non-dict payload: NoneType"}, + // python: _as_dict([1, 2], "run_verifier") + {[]any{1, 2}, "run_verifier returned non-dict payload: list"}, + // python: _as_dict("s", "run_verifier") + {"s", "run_verifier returned non-dict payload: str"}, + // python: _as_dict(5, "run_verifier") + {5, "run_verifier returned non-dict payload: int"}, + // python: _as_dict(5.0, "run_verifier") + {5.0, "run_verifier returned non-dict payload: float"}, + // python: _as_dict(True, "run_verifier") + {true, "run_verifier returned non-dict payload: bool"}, + } + for _, c := range cases { + _, err := AsMap(c.in, "run_verifier") + if err == nil { + t.Errorf("AsMap(%#v): want an error", c.in) + continue + } + if err.Error() != c.wantErr { + t.Errorf("AsMap(%#v) error = %q, python = %q", c.in, err.Error(), c.wantErr) + } + } +} + +// TestPyTypeName covers the mapping table directly, including the documented +// float64 divergence for JSON integers. +func TestPyTypeName(t *testing.T) { + cases := []struct { + in any + want string + }{ + {nil, "NoneType"}, + {map[string]any{}, "dict"}, + {map[string]string{}, "dict"}, + {[]any{}, "list"}, + {[]string{}, "list"}, + {"s", "str"}, + {true, "bool"}, + {1, "int"}, + {int64(1), "int"}, + {1.5, "float"}, + // Python parity divergence: encoding/json produces float64 for every + // JSON number, so an integral JSON value reports "float" here where + // CPython's json.loads would have made it an int. + {float64(5), "float"}, + } + for _, c := range cases { + if got := PyTypeName(c.in); got != c.want { + t.Errorf("PyTypeName(%#v) = %q, want %q", c.in, got, c.want) + } + } + + var p *int + if got := PyTypeName(p); got != "NoneType" { + t.Errorf("PyTypeName((*int)(nil)) = %q, want NoneType", got) + } +} + +// TestDropNulls covers model_dump(exclude_none=True) semantics. +func TestDropNulls(t *testing.T) { + in := map[string]any{ + "kept": "v", + "dropped": nil, + "zero": 0, + "empty_str": "", + "empty_list": []any{}, + "empty_map": map[string]any{}, + "nested": map[string]any{ + "a": nil, + "b": 1, + }, + "list_of_maps": []any{ + map[string]any{"x": nil, "y": 2}, + nil, // a None list ELEMENT survives: exclude_none is field-level + }, + } + want := map[string]any{ + "kept": "v", + "zero": 0, + "empty_str": "", + "empty_list": []any{}, + "empty_map": map[string]any{}, + "nested": map[string]any{ + "b": 1, + }, + "list_of_maps": []any{ + map[string]any{"y": 2}, + nil, + }, + } + + got := DropNulls(in) + if !reflect.DeepEqual(got, want) { + t.Errorf("DropNulls =\n %#v\nwant\n %#v", got, want) + } + + // The input must be untouched. + if _, still := in["dropped"]; !still { + t.Error("DropNulls mutated its input") + } + if _, still := in["nested"].(map[string]any)["a"]; !still { + t.Error("DropNulls mutated a nested input map") + } +} + +// TestDropNullsTypedNilPointer: a nil Go pointer is Python's None too. +func TestDropNullsTypedNilPointer(t *testing.T) { + var p *string + got := DropNulls(map[string]any{"p": p, "q": "x"}).(map[string]any) + if _, present := got["p"]; present { + t.Error("DropNulls kept a nil pointer entry") + } + if got["q"] != "x" { + t.Errorf("DropNulls dropped a non-nil entry: %#v", got) + } +} + +// TestDropNullsScalarPassthrough: a non-container input is returned unchanged. +func TestDropNullsScalarPassthrough(t *testing.T) { + for _, v := range []any{"s", 1, 1.5, true, nil} { + if got := DropNulls(v); !reflect.DeepEqual(got, v) { + t.Errorf("DropNulls(%#v) = %#v", v, got) + } + } +} diff --git a/go/internal/afx/wire.go b/go/internal/afx/wire.go new file mode 100644 index 0000000..bc2a620 --- /dev/null +++ b/go/internal/afx/wire.go @@ -0,0 +1,56 @@ +package afx + +import ( + "bytes" + "encoding/json" +) + +// WireNumbers restores the int-vs-float distinction CPython's json.loads makes +// and Go's encoding/json does not. +// +// # Why it is needed +// +// SEC-AF's Python side reads a `.call` result with `json.loads`, which turns an +// integer LITERAL into an `int` and anything carrying '.', 'e' or 'E' into a +// `float`. Go's decoder — the one inside the SDK's Call — turns EVERY JSON +// number into a float64, so the distinction is gone by the time a handler sees +// the payload. +// +// For a value that is immediately bound into a typed struct that costs nothing: +// the target field's type decides the spelling. It costs something for a value +// that is stored UNTYPED and re-serialised later, because the writer then has +// only the float64 to go on and spells `2` as `2.0`. The audit result's +// `metadata["prove_drop_summary"]` is exactly that: app.py:205-208 copies the +// prove_phase payload's `drop_summary` straight into +// `SecurityAuditResult.metadata` (a `dict[str, object]`), and every number in +// it — `demoted_total`, each `by_reason` count — is an int in Python. +// +// # What it does +// +// It re-decodes the value through encoding/json with UseNumber, leaving every +// numeric leaf as a json.Number that carries its literal spelling. The marshal +// step is what recovers the int-ness: encoding/json writes an integral float64 +// as "2", not "2.0", so the re-decode yields json.Number("2"). +// +// DOCUMENTED RESIDUAL, one case. A payload that spells an integral value with +// an explicit fraction or exponent — `{"demoted_total": 2.0}` — is a float in +// CPython and prints "2.0", where this round trip flattens it to "2". The +// distinction is unrecoverable once the SDK has decoded the body, and no +// producer of `drop_summary` emits that spelling: reasoners/phases.py's +// `_track_drop` builds the counts with `+= 1`. +// +// On any marshal/decode failure the input is returned unchanged — a metadata +// entry spelled the Go way beats losing the entry. +func WireNumbers(v any) any { + b, err := json.Marshal(v) + if err != nil { + return v + } + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + var out any + if err := dec.Decode(&out); err != nil { + return v + } + return out +} diff --git a/go/internal/afx/wire_test.go b/go/internal/afx/wire_test.go new file mode 100644 index 0000000..ce1ae89 --- /dev/null +++ b/go/internal/afx/wire_test.go @@ -0,0 +1,73 @@ +package afx + +// Tests for WireNumbers. +// +// Validation contract (behaviour, from CPython's json.loads): an integer +// LITERAL decodes to an `int` and prints without a fraction, a fractional or +// exponent literal decodes to a `float`. Go's decoder makes both float64, which +// a later re-serialisation of an UNTYPED value spells "2.0" — the shape +// SecurityAuditResult.metadata carries (see internal/node/audit.go). + +import ( + "encoding/json" + "testing" +) + +func TestWireNumbersRestoresIntegerSpelling(t *testing.T) { + // The exact shape reasoners/phases.py's _track_drop produces, after the SDK + // has decoded it: every number a float64. + decoded := map[string]any{ + "demoted_total": float64(2), + "by_reason": map[string]any{"verifier_error": float64(2)}, + "findings": []any{map[string]any{"score": float64(0.5), "rank": float64(3)}}, + } + + raw, err := json.Marshal(WireNumbers(decoded)) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `{"by_reason":{"verifier_error":2},"demoted_total":2,` + + `"findings":[{"rank":3,"score":0.5}]}` + if string(raw) != want { + t.Errorf("re-serialised\n got: %s\nwant: %s", raw, want) + } + + for _, value := range WireNumbers(decoded).(map[string]any) { + if _, isFloat := value.(float64); isFloat { + t.Errorf("a float64 survived: %#v", value) + } + } + if _, isNumber := WireNumbers(decoded).(map[string]any)["demoted_total"].(json.Number); !isNumber { + t.Error("demoted_total is not a json.Number") + } +} + +// TestWireNumbersKeepsNonNumbers proves it is a spelling fix and nothing else. +func TestWireNumbersKeepsNonNumbers(t *testing.T) { + for _, value := range []any{nil, "text", true, []any{}, map[string]any{}} { + raw, err := json.Marshal(WireNumbers(value)) + if err != nil { + t.Fatalf("marshal %#v: %v", value, err) + } + want, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal %#v: %v", value, err) + } + if string(raw) != string(want) { + t.Errorf("%#v round-tripped to %s, want %s", value, raw, want) + } + } +} + +// TestWireNumbersReturnsTheInputOnFailure: a value encoding/json cannot marshal +// must not be lost. +func TestWireNumbersReturnsTheInputOnFailure(t *testing.T) { + unmarshalable := map[string]any{"fn": func() {}} + got, ok := WireNumbers(unmarshalable).(map[string]any) + if !ok || len(got) != 1 { + t.Fatalf("WireNumbers = %#v, want the input unchanged", got) + } + if _, present := got["fn"]; !present { + t.Error("the unmarshalable entry was dropped") + } +} diff --git a/go/internal/agents/dedup/dedup.go b/go/internal/agents/dedup/dedup.go new file mode 100644 index 0000000..33a6407 --- /dev/null +++ b/go/internal/agents/dedup/dedup.go @@ -0,0 +1,647 @@ +// Package dedup ports src/sec_af/agents/dedup.py — the HUNT-phase +// deduplicator and chain correlator that runs between the hunters and the +// PROVE phase. +// +// The Python module does three things in sequence: +// +// 1. fingerprint dedup — collapse findings that share +// sha256("::")[:16], merging the loser's extra data into +// the winner (_merge_duplicate); +// 2. a semantic dedup pass — for every same-file/same-CWE pair, ask the LLM +// `.ai(schema=DuplicateCheck)` whether the two are the same root cause, +// all pairs in parallel (_deduplicate_with_ai); +// 3. chain correlation — seed candidate chains from a hardcoded CWE-pair +// table (_fallback_correlate) and hand them, with the finding list, to the +// harness for expansion (deduplicate_and_correlate). +// +// Everything in step 2 and 3 is best-effort: Python swallows every exception +// (a failed AI check is "not a duplicate", a failed harness call means "no +// chains" and falls back to the seeds), and the Go port reproduces that +// exactly rather than propagating errors. +package dedup + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "sort" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/aix" + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/harnessx" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// severityScore ports dedup.py _SEVERITY_SCORE. A severity outside the table +// scores 0, matching Python's `.get(sev, 0)` — and a Go map lookup for a +// missing key already yields 0, so the two agree without a helper. +var severityScore = map[schemas.Severity]int{ + schemas.SeverityCritical: 5, + schemas.SeverityHigh: 4, + schemas.SeverityMedium: 3, + schemas.SeverityLow: 2, + schemas.SeverityInfo: 1, +} + +// confidenceScore ports dedup.py _CONFIDENCE_SCORE. +var confidenceScore = map[schemas.Confidence]int{ + schemas.ConfidenceHigh: 3, + schemas.ConfidenceMedium: 2, + schemas.ConfidenceLow: 1, +} + +// chainPatterns ports dedup.py _CHAIN_PATTERNS: ordered (first, second) CWE +// pairs that seed heuristic attack chains. Order is load-bearing — the seed +// chains are emitted in this order and reach the prompt. +var chainPatterns = [][2]string{ + {"CWE-918", "CWE-798"}, + {"CWE-862", "CWE-285"}, + {"CWE-89", "CWE-200"}, + {"CWE-16", "CWE-798"}, +} + +// aiDuplicateCheckTimeout ports the `timeout_seconds: float = 60.0` default of +// _ai_check_duplicate's asyncio.wait_for. +const aiDuplicateCheckTimeout = 60 * time.Second + +// chainCorrelationTimeout ports the `timeout=600.0` on the chain-correlation +// harness call in deduplicate_and_correlate. +const chainCorrelationTimeout = 600 * time.Second + +// ComputeFingerprint ports src/sec_af/agents/dedup.py compute_fingerprint: +// +// key = f"{finding.file_path}:{finding.start_line}:{finding.cwe_id}" +// return hashlib.sha256(key.encode("utf-8")).hexdigest()[:16] +// +// The 16-character prefix is of the lowercase hex digest, i.e. the first 8 +// bytes of the digest. +func ComputeFingerprint(finding schemas.RawFinding) string { + key := finding.FilePath + ":" + strconv.Itoa(finding.StartLine) + ":" + finding.CweID + sum := sha256.Sum256([]byte(key)) + return hex.EncodeToString(sum[:])[:16] +} + +// confidenceValue ports _confidence_value. +func confidenceValue(finding *schemas.RawFinding) int { + return confidenceScore[finding.Confidence] +} + +// severityConfidenceLess reports whether a sorts BEFORE b under Python's +// +// final.sort(key=_severity_confidence_sort_key, reverse=True) +// +// i.e. the tuple (severity_score, confidence_score) compared lexicographically, +// descending. Callers must use a STABLE sort: Python's list.sort is stable and +// `reverse=True` is documented to preserve the original order of equal +// elements (it reverses the comparison, not the result). +func severityConfidenceLess(a, b *schemas.RawFinding) bool { + as, bs := severityScore[a.EstimatedSeverity], severityScore[b.EstimatedSeverity] + if as != bs { + return as > bs + } + return confidenceScore[a.Confidence] > confidenceScore[b.Confidence] +} + +// mergeDuplicate ports _merge_duplicate. +// +// Python parity — MUTATION IS THE POINT. The Python helper mutates the winning +// pydantic model in place and returns it; the caller's `deduped` list holds the +// very same object, so the merge is visible to every later step even though the +// caller only ever reassigns a dict entry. The Go port therefore threads +// *schemas.RawFinding throughout (and DeduplicateAndCorrelate takes pointers +// into the caller's slice) so the same aliasing holds. +func mergeDuplicate(existing, incoming *schemas.RawFinding) *schemas.RawFinding { + winner := existing + loser := incoming + if confidenceValue(incoming) > confidenceValue(existing) { + winner = incoming + loser = existing + } + + // Python parity: len() on a str counts CODE POINTS, not bytes. + if utf8.RuneCountInString(loser.Description) > utf8.RuneCountInString(winner.Description) { + winner.Description = loser.Description + } + + winner.RelatedFiles = sortedUnion(winner.RelatedFiles, loser.RelatedFiles) + + // Python parity: `data_flow` is `list[DataFlowStep] | None`, so the test is + // None-vs-not-None. A nil Go slice is the None; an EMPTY non-nil slice is + // Python's `[]`, which is not None and therefore does NOT get overwritten. + if winner.DataFlow == nil && loser.DataFlow != nil { + winner.DataFlow = loser.DataFlow + } + + // Python: `winner.fingerprint = winner.fingerprint or compute_fingerprint(winner)`. + if winner.Fingerprint == "" { + winner.Fingerprint = ComputeFingerprint(*winner) + } + return winner +} + +// sortedUnion ports `sorted(set(a) | set(b))`. Go's sort.Strings orders by +// bytes, which for UTF-8 is the same order as Python's code-point comparison. +func sortedUnion(a, b []string) []string { + seen := make(map[string]struct{}, len(a)+len(b)) + out := make([]string, 0, len(a)+len(b)) + for _, s := range a { + if _, dup := seen[s]; !dup { + seen[s] = struct{}{} + out = append(out, s) + } + } + for _, s := range b { + if _, dup := seen[s]; !dup { + seen[s] = struct{}{} + out = append(out, s) + } + } + sort.Strings(out) + return out +} + +// buildDuplicateCheckPrompt is the prompt _ai_check_duplicate hands to +// `app.ai(user=..., schema=DuplicateCheck)`. Extracted as a pure function so a +// golden test can compare it byte-for-byte against the Python f-string. +func buildDuplicateCheckPrompt(candidate, existing *schemas.RawFinding) string { + var b strings.Builder + b.WriteString("Determine if these two security findings are duplicates (same root cause).\n\n") + b.WriteString("Finding A:\n") + b.WriteString("- Title: " + candidate.Title + "\n") + b.WriteString("- CWE: " + candidate.CweID + " (" + candidate.CweName + ")\n") + b.WriteString("- File: " + candidate.FilePath + ":" + strconv.Itoa(candidate.StartLine) + "\n") + b.WriteString("- Description: " + runeSlice(candidate.Description, 200) + "\n\n") + b.WriteString("Finding B:\n") + b.WriteString("- Title: " + existing.Title + "\n") + b.WriteString("- CWE: " + existing.CweID + " (" + existing.CweName + ")\n") + b.WriteString("- File: " + existing.FilePath + ":" + strconv.Itoa(existing.StartLine) + "\n") + b.WriteString("- Description: " + runeSlice(existing.Description, 200)) + return b.String() +} + +// aiCheckDuplicate ports _ai_check_duplicate. +// +// Python parity: the whole call is wrapped in `except Exception: return False`, +// so a transport failure, a timeout, a malformed response — anything at all — +// means "not a duplicate". The Go port swallows the error identically and +// returns a bool, not (bool, error). +func aiCheckDuplicate(ctx context.Context, app appx.AIer, candidate, existing *schemas.RawFinding, timeout time.Duration) bool { + prompt := buildDuplicateCheckPrompt(candidate, existing) + + // Ports `asyncio.wait_for(app.ai(...), timeout=timeout_seconds)`. + callCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + result, err := aix.Structured[schemas.DuplicateCheck](callCtx, app, "", prompt) + if err != nil { + return false + } + return result.IsDuplicate +} + +// deduplicateWithAI ports _deduplicate_with_ai. +// +// findings are pointers into the caller's storage; the fingerprint seeding and +// every merge mutate them in place, exactly as the Python code mutates the +// caller's pydantic models. +// +// app is deliberately `any`: Python types this parameter `object` and probes +// `hasattr(app, "ai") and callable(...)` at runtime, so a harness-only app +// skips the semantic pass entirely. The Go equivalent is an optional-interface +// assertion to appx.AIer. +func deduplicateWithAI(ctx context.Context, findings []*schemas.RawFinding, app any) []*schemas.RawFinding { + // Python: `by_fingerprint: dict[str, RawFinding]`. A Python dict preserves + // insertion order and `deduped = list(by_fingerprint.values())` depends on + // it, so the Go port carries an explicit key order alongside the map. + byFingerprint := make(map[string]*schemas.RawFinding, len(findings)) + fpOrder := make([]string, 0, len(findings)) + for _, finding := range findings { + if finding.Fingerprint == "" { + finding.Fingerprint = ComputeFingerprint(*finding) + } + existing, ok := byFingerprint[finding.Fingerprint] + if !ok { + byFingerprint[finding.Fingerprint] = finding + fpOrder = append(fpOrder, finding.Fingerprint) + continue + } + // Python reassigns the SAME key, which keeps its original position. + byFingerprint[finding.Fingerprint] = mergeDuplicate(existing, finding) + } + + deduped := make([]*schemas.RawFinding, 0, len(fpOrder)) + for _, fp := range fpOrder { + deduped = append(deduped, byFingerprint[fp]) + } + + // Python: `by_file: dict[str, list[RawFinding]] = defaultdict(list)`, again + // iterated in insertion order. + byFile := make(map[string][]*schemas.RawFinding, len(deduped)) + fileOrder := make([]string, 0, len(deduped)) + for _, finding := range deduped { + if _, ok := byFile[finding.FilePath]; !ok { + fileOrder = append(fileOrder, finding.FilePath) + } + byFile[finding.FilePath] = append(byFile[finding.FilePath], finding) + } + + toRemove := make(map[string]struct{}) + + // Python: `has_ai = hasattr(app, "ai") and callable(getattr(app, "ai", None))`. + aiApp, hasAI := app.(appx.AIer) + if hasAI { + // Collect all candidate pairs up front so we can check them in parallel. + type pair struct{ candidate, existing *schemas.RawFinding } + var pairs []pair + for _, file := range fileOrder { + fileFindings := byFile[file] + if len(fileFindings) < 2 { + continue + } + for i, candidate := range fileFindings { + for _, existing := range fileFindings[i+1:] { + if candidate.CweID == existing.CweID { + pairs = append(pairs, pair{candidate, existing}) + } + } + } + } + + if len(pairs) > 0 { + // Ports `await asyncio.gather(*[_ai_check_duplicate(...) for ...])`: + // UNBOUNDED fan-out (no semaphore in Python), results collected + // positionally. _ai_check_duplicate never raises, so there is no + // error channel to model and a plain WaitGroup suffices — the + // gather's return_exceptions default is unreachable here. + results := make([]bool, len(pairs)) + var wg sync.WaitGroup + wg.Add(len(pairs)) + for i, p := range pairs { + go func(i int, p pair) { + defer wg.Done() + results[i] = aiCheckDuplicate(ctx, aiApp, p.candidate, p.existing, aiDuplicateCheckTimeout) + }(i, p) + } + wg.Wait() + + for i, p := range pairs { + if !results[i] { + continue + } + candidate, existing := p.candidate, p.existing + // Skip if either side was already removed by an earlier pair result. + if _, gone := toRemove[candidate.Fingerprint]; gone { + continue + } + if _, gone := toRemove[existing.Fingerprint]; gone { + continue + } + if confidenceValue(candidate) >= confidenceValue(existing) { + toRemove[existing.Fingerprint] = struct{}{} + // Python parity: the by_fingerprint reassignment cannot + // change what survives — `final` is filtered from `deduped`, + // which was snapshotted before this loop — but the merge it + // wraps mutates the shared finding, and that DOES survive. + byFingerprint[candidate.Fingerprint] = mergeDuplicate(candidate, existing) + } else { + toRemove[candidate.Fingerprint] = struct{}{} + byFingerprint[existing.Fingerprint] = mergeDuplicate(existing, candidate) + } + } + } + } + + final := make([]*schemas.RawFinding, 0, len(deduped)) + for _, f := range deduped { + if _, gone := toRemove[f.Fingerprint]; gone { + continue + } + final = append(final, f) + } + sort.SliceStable(final, func(i, j int) bool { return severityConfidenceLess(final[i], final[j]) }) + return final +} + +// fallbackCorrelate ports _fallback_correlate: the hardcoded CWE-pair seed +// chains. +func fallbackCorrelate(findings []*schemas.RawFinding) []schemas.PotentialChain { + // Python: `defaultdict(list)` keyed by the UPPERCASED cwe_id; only lookups + // happen afterwards, so insertion order is irrelevant here. + byCWE := make(map[string][]*schemas.RawFinding, len(findings)) + for _, finding := range findings { + up := strings.ToUpper(finding.CweID) + byCWE[up] = append(byCWE[up], finding) + } + + chains := make([]schemas.PotentialChain, 0, len(chainPatterns)) + for _, pattern := range chainPatterns { + firstCWE, secondCWE := pattern[0], pattern[1] + firstCandidates := byCWE[firstCWE] + secondCandidates := byCWE[secondCWE] + if len(firstCandidates) == 0 || len(secondCandidates) == 0 { + continue + } + first := firstCandidates[0] + second := secondCandidates[0] + + // Python: `max(a, b, key=lambda s: _SEVERITY_SCORE.get(s, 0))` returns + // the FIRST argument on a tie. + severity := first.EstimatedSeverity + if severityScore[second.EstimatedSeverity] > severityScore[first.EstimatedSeverity] { + severity = second.EstimatedSeverity + } + + chain := schemas.NewPotentialChain() // mints chain_id, like PotentialChain(...) + chain.Title = "Potential attack chain: " + firstCWE + " -> " + secondCWE + chain.FindingIDs = []string{first.ID, second.ID} + chain.CombinedImpact = "Combined exploitation path discovered by correlation heuristics; verify chain during PROVE phase." + chain.EstimatedSeverity = severity + chains = append(chains, chain) + } + return chains +} + +// seedChainContext ports _seed_chain_context: the block of prompt text that +// tells the harness which heuristic chains to validate and expand. +func seedChainContext(seedChains []schemas.PotentialChain, findings []*schemas.RawFinding) string { + // Python: `{finding.id: finding for finding in findings}` — a later finding + // with the same id wins. + byID := make(map[string]*schemas.RawFinding, len(findings)) + for _, finding := range findings { + byID[finding.ID] = finding + } + + lines := []string{"Seed chain candidates (validate and expand these):"} + if len(seedChains) == 0 { + lines = append(lines, "- No heuristic seed chains were detected from hardcoded CWE pairs.") + } else { + for _, chain := range seedChains { + orderedLabels := make([]string, 0, len(chain.FindingIDs)) + for _, findingID := range chain.FindingIDs { + finding, ok := byID[findingID] + if !ok { + continue + } + orderedLabels = append(orderedLabels, finding.CweName) + } + label := chain.Title + if len(orderedLabels) > 0 { + label = strings.Join(orderedLabels, " -> ") + } + lines = append(lines, "- Potential chain: "+label+" (findings "+strings.Join(chain.FindingIDs, ", ")+")") + } + } + lines = append(lines, "Look for additional multi-step attack chains beyond these seeds.") + return strings.Join(lines, "\n") +} + +// buildChainCorrelationPrompt is the prompt deduplicate_and_correlate hands to +// `app.harness(..., schema=ChainCorrelationResult, ...)`. Extracted as a pure +// function so a golden test can compare it byte-for-byte against Python. +func buildChainCorrelationPrompt(findings []*schemas.RawFinding, seedContext string) string { + summaryLines := make([]string, 0, len(findings)) + for _, f := range findings { + summaryLines = append(summaryLines, + "- id="+f.ID+" cwe="+f.CweID+" file="+f.FilePath+":"+strconv.Itoa(f.StartLine)+ + " title="+f.Title+" severity="+string(f.EstimatedSeverity)) + } + findingsSummary := strings.Join(summaryLines, "\n") + + return "You are SEC-AF's chain correlator.\n" + + "Identify multi-step attack chains across the findings below.\n" + + "A chain means one vulnerability enables exploitation of another.\n" + + "Also flag any remaining duplicate IDs that should be dropped.\n\n" + + "Findings:\n" + findingsSummary + "\n\n" + + seedContext +} + +// splitPipe ports _split_pipe: split on "|" with at most expected-1 splits, +// strip each part, then right-pad with "" to exactly expected entries. +func splitPipe(s string, expected int) []string { + parts := strings.SplitN(s, "|", expected) + out := make([]string, 0, expected) + for _, p := range parts { + // Python's str.strip() removes whitespace as defined by str.isspace(); + // strings.TrimSpace uses unicode.IsSpace. The two agree on every + // character that appears in LLM output. + out = append(out, strings.TrimSpace(p)) + } + for len(out) < expected { + out = append(out, "") + } + return out +} + +// parseChainFromStr ports _parse_chain_from_str. The bool reports Python's +// "not None" — a chain with fewer than two RESOLVABLE finding ids is dropped. +func parseChainFromStr(entry string, availableIDs map[string]struct{}) (schemas.PotentialChain, bool) { + parts := splitPipe(entry, 4) + title := parts[0] + + validIDs := []string{} + for _, fid := range strings.Split(parts[1], ",") { + fid = strings.TrimSpace(fid) + if fid == "" { + continue + } + if _, ok := availableIDs[fid]; ok { + validIDs = append(validIDs, fid) + } + } + if len(validIDs) < 2 { + return schemas.PotentialChain{}, false + } + + impact := parts[2] + if impact == "" { + impact = "Combined exploitation path" + } + + severityStr := strings.TrimSpace(strings.ToLower(parts[3])) + severity := schemas.SeverityHigh // Python: severity_map.get(severity_str, Severity.HIGH) + switch severityStr { + case "critical": + severity = schemas.SeverityCritical + case "high": + severity = schemas.SeverityHigh + case "medium": + severity = schemas.SeverityMedium + case "low": + severity = schemas.SeverityLow + } + + chain := schemas.NewPotentialChain() // mints chain_id + chain.Title = title + chain.FindingIDs = validIDs + chain.CombinedImpact = impact + chain.EstimatedSeverity = severity + return chain, true +} + +// extractChainCorrelation ports _extract_chain_correlation. +// +// Python inspects the HarnessResult: an already-typed ChainCorrelationResult +// passes straight through, a `.parsed` of the right type is returned, a +// `.parsed` dict is model_validate'd, and anything else is None. The Go SDK +// sets Result.Parsed to the very pointer harnessx.Run allocated when — and only +// when — the output validated, so a non-nil Parsed is exactly Python's +// `isinstance(parsed, ChainCorrelationResult)`; the dict branch is unreachable +// (see harnessx.Extract's note) and is deliberately not ported. +func extractChainCorrelation(res *harness.Result, dest *schemas.ChainCorrelationResult) *schemas.ChainCorrelationResult { + if res == nil || res.Parsed == nil || dest == nil { + return nil + } + return dest +} + +// DeduplicateAndCorrelate ports src/sec_af/agents/dedup.py deduplicate_and_correlate. +// +// async def deduplicate_and_correlate(findings, recon, app, repo_path) -> HuntResult +// +// Python parity notes: +// +// - `recon` is accepted and never read. It is kept in the signature because +// every call site passes it and the reasoner adapter mirrors the Python +// argument list. +// - The findings are MUTATED in place (fingerprint seeding, merges). Python +// mutates the caller's pydantic objects; the Go port takes pointers into +// the caller's slice so the same thing happens. +// - The chain-correlation harness call is wrapped in a bare `except +// Exception: chains = []`, so ANY failure — transport error, timeout, +// is_error result, unparseable output — silently yields the seed chains. +// The only error this function can return is a tempdir-creation failure, +// which in Python happens OUTSIDE the try and therefore propagates. +func DeduplicateAndCorrelate( + ctx context.Context, + findings []schemas.RawFinding, + recon schemas.ReconResult, + app appx.Harnesser, + repoPath string, +) (schemas.HuntResult, error) { + _ = recon // Python parity: unused. + + ptrs := make([]*schemas.RawFinding, len(findings)) + for i := range findings { + ptrs[i] = &findings[i] + } + + deduplicated := deduplicateWithAI(ctx, ptrs, app) + + var chains []schemas.PotentialChain + seedChains := fallbackCorrelate(deduplicated) + seedContext := seedChainContext(seedChains, deduplicated) + + if len(deduplicated) > 0 { + prompt := buildChainCorrelationPrompt(deduplicated, seedContext) + + harnessCwd, err := os.MkdirTemp("", "secaf-dedup-") + if err != nil { + return schemas.HuntResult{}, err + } + func() { + // Ports Python's `finally: shutil.rmtree(harness_cwd, ignore_errors=True)`. + defer os.RemoveAll(harnessCwd) + + callCtx, cancel := context.WithTimeout(ctx, chainCorrelationTimeout) + defer cancel() + + dest, res, runErr := harnessx.Run[schemas.ChainCorrelationResult]( + callCtx, app, prompt, + harness.Options{Cwd: harnessCwd, ProjectDir: repoPath}, + ) + if runErr != nil { + // Python: the `await` raises, the except clause resets chains. + chains = nil + return + } + parsed := extractChainCorrelation(res, dest) + if parsed == nil { + return + } + availableIDs := make(map[string]struct{}, len(deduplicated)) + for _, f := range deduplicated { + availableIDs[f.ID] = struct{}{} + } + for _, chainStr := range parsed.Chains { + if chain, ok := parseChainFromStr(chainStr, availableIDs); ok { + chains = append(chains, chain) + } + } + if len(parsed.DuplicateIDs) > 0 { + dropSet := make(map[string]struct{}, len(parsed.DuplicateIDs)) + for _, id := range parsed.DuplicateIDs { + dropSet[id] = struct{}{} + } + kept := make([]*schemas.RawFinding, 0, len(deduplicated)) + for _, f := range deduplicated { + if _, dropped := dropSet[f.ID]; dropped { + continue + } + kept = append(kept, f) + } + deduplicated = kept + } + }() + } + + if len(chains) == 0 { + chains = seedChains + } + + sort.SliceStable(deduplicated, func(i, j int) bool { + return severityConfidenceLess(deduplicated[i], deduplicated[j]) + }) + + result := schemas.NewHuntResult() + result.Findings = make([]schemas.RawFinding, 0, len(deduplicated)) + for _, f := range deduplicated { + result.Findings = append(result.Findings, *f) + } + if chains == nil { + chains = []schemas.PotentialChain{} + } + result.Chains = chains + result.TotalRaw = len(findings) + result.DeduplicatedCount = len(deduplicated) + result.ChainCount = len(chains) + return result, nil +} + +// Deduplicator ports the src/sec_af/agents/dedup.py Deduplicator class — a thin object binding an +// app and a repo path so callers can invoke the free function without repeating +// them. Nothing in the live path uses it; it is part of the module's __all__ and +// is ported for completeness. +type Deduplicator struct { + app appx.Harnesser + repoPath string +} + +// NewDeduplicator ports Deduplicator.__init__. +func NewDeduplicator(app appx.Harnesser, repoPath string) *Deduplicator { + return &Deduplicator{app: app, repoPath: repoPath} +} + +// Run ports Deduplicator.run. +func (d *Deduplicator) Run(ctx context.Context, findings []schemas.RawFinding, recon schemas.ReconResult) (schemas.HuntResult, error) { + return DeduplicateAndCorrelate(ctx, findings, recon, d.app, d.repoPath) +} + +// runeSlice reproduces Python's s[:n], which counts code points, not bytes. +func runeSlice(s string, n int) string { + if n < 0 { + n = 0 + } + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) +} diff --git a/go/internal/agents/dedup/dedup_test.go b/go/internal/agents/dedup/dedup_test.go new file mode 100644 index 0000000..28035d8 --- /dev/null +++ b/go/internal/agents/dedup/dedup_test.go @@ -0,0 +1,776 @@ +package dedup + +import ( + "context" + "encoding/json" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// --------------------------------------------------------------------------- +// test doubles +// --------------------------------------------------------------------------- + +// harnessOnly exposes ONLY the Harness seam of the wrapped Fake. +// +// It is the Go equivalent of tests/test_dedup.py's `_HarnessApp`, which defines +// `async def harness(...)` and nothing else: dedup._deduplicate_with_ai probes +// `hasattr(app, "ai")` and skips the whole semantic pass when it is absent. +// *appx.Fake implements appx.AIer, so handing it over directly would take the +// OTHER branch — this wrapper is what makes the Python test's shape reachable. +type harnessOnly struct{ f *appx.Fake } + +func (h harnessOnly) Harness(ctx context.Context, prompt string, schema map[string]any, dest any, opts harness.Options) (*harness.Result, error) { + return h.f.Harness(ctx, prompt, schema, dest, opts) +} + +// chainFake returns a Fake whose harness answers every call with resp, plus the +// harness-only wrapper around it. +func chainFake(t *testing.T, resp schemas.ChainCorrelationResult) (*appx.Fake, harnessOnly) { + t.Helper() + raw, err := json.Marshal(resp) + if err != nil { + t.Fatalf("marshal ChainCorrelationResult: %v", err) + } + f := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return raw, nil + })} + return f, harnessOnly{f} +} + +// finding ports tests/test_dedup.py::_finding. +func finding(id, cweID, cweName string, severity schemas.Severity) schemas.RawFinding { + f := schemas.NewRawFinding() + f.ID = id + f.HunterStrategy = "injection" + f.Title = "Finding " + id + f.Description = "Description for " + id + f.FindingType = schemas.FindingTypeSast + f.CweID = cweID + f.CweName = cweName + f.FilePath = "src/" + strings.ToLower(id) + ".py" + f.StartLine = 10 + f.EndLine = 10 + f.CodeSnippet = "dangerous_call(user_input)" + f.EstimatedSeverity = severity + f.Confidence = schemas.ConfidenceHigh + f.RelatedFiles = []string{} + f.Fingerprint = "fp-" + id + return f +} + +func emptyChains() schemas.ChainCorrelationResult { return schemas.NewChainCorrelationResult() } + +// --------------------------------------------------------------------------- +// ports of tests/test_dedup.py +// --------------------------------------------------------------------------- + +// Ports test_dedup_prompt_includes_seed_chain_candidates. +func TestDedup_PromptIncludesSeedChainCandidates(t *testing.T) { + findings := []schemas.RawFinding{ + finding("F1", "CWE-918", "Server-Side Request Forgery (SSRF)", schemas.SeverityHigh), + finding("F3", "CWE-798", "Use of Hard-coded Credentials", schemas.SeverityHigh), + } + fake, app := chainFake(t, emptyChains()) + + if _, err := DeduplicateAndCorrelate(context.Background(), findings, schemas.ReconResult{}, app, "."); err != nil { + t.Fatalf("DeduplicateAndCorrelate: %v", err) + } + + if len(fake.Harnesses) != 1 { + t.Fatalf("want exactly 1 harness call, got %d", len(fake.Harnesses)) + } + prompt := fake.Harnesses[0].Prompt + for _, want := range []string{ + "Seed chain candidates (validate and expand these):", + "Potential chain: Server-Side Request Forgery (SSRF) -> Use of Hard-coded Credentials (findings F1, F3)", + "Look for additional multi-step attack chains beyond these seeds.", + } { + if !strings.Contains(prompt, want) { + t.Errorf("prompt missing %q\n--- prompt ---\n%s", want, prompt) + } + } +} + +// Ports test_seed_chains_are_used_when_ai_returns_no_chains. +func TestDedup_SeedChainsUsedWhenAIReturnsNoChains(t *testing.T) { + findings := []schemas.RawFinding{ + finding("F1", "CWE-89", "SQL Injection", schemas.SeverityCritical), + finding("F2", "CWE-200", "Exposure of Sensitive Information", schemas.SeverityHigh), + } + _, app := chainFake(t, emptyChains()) + + result, err := DeduplicateAndCorrelate(context.Background(), findings, schemas.ReconResult{}, app, ".") + if err != nil { + t.Fatalf("DeduplicateAndCorrelate: %v", err) + } + if len(result.Chains) != 1 { + t.Fatalf("want 1 chain, got %d", len(result.Chains)) + } + if got := result.Chains[0].FindingIDs; len(got) != 2 || got[0] != "F1" || got[1] != "F2" { + t.Errorf("finding_ids = %v, want [F1 F2]", got) + } + if got, want := result.Chains[0].Title, "Potential attack chain: CWE-89 -> CWE-200"; got != want { + t.Errorf("title = %q, want %q", got, want) + } + if got, want := result.ChainCount, 1; got != want { + t.Errorf("chain_count = %d, want %d", got, want) + } +} + +// Ports test_ai_discovered_chains_take_priority_over_seed_chains. +func TestDedup_AIDiscoveredChainsTakePriorityOverSeedChains(t *testing.T) { + findings := []schemas.RawFinding{ + finding("F1", "CWE-918", "Server-Side Request Forgery (SSRF)", schemas.SeverityHigh), + finding("F2", "CWE-200", "Exposure of Sensitive Information", schemas.SeverityMedium), + finding("F3", "CWE-798", "Use of Hard-coded Credentials", schemas.SeverityHigh), + } + resp := schemas.NewChainCorrelationResult() + resp.Chains = []string{ + "AI-discovered chain: SSRF to data exfiltration|F1,F2,F3|SSRF reaches metadata; stolen secret enables privileged API access and data exfiltration.|critical", + } + _, app := chainFake(t, resp) + + result, err := DeduplicateAndCorrelate(context.Background(), findings, schemas.ReconResult{}, app, ".") + if err != nil { + t.Fatalf("DeduplicateAndCorrelate: %v", err) + } + if len(result.Chains) != 1 { + t.Fatalf("want 1 chain, got %d", len(result.Chains)) + } + if got, want := result.Chains[0].Title, "AI-discovered chain: SSRF to data exfiltration"; got != want { + t.Errorf("title = %q, want %q", got, want) + } + if got := result.Chains[0].FindingIDs; len(got) != 3 || got[0] != "F1" || got[1] != "F2" || got[2] != "F3" { + t.Errorf("finding_ids = %v, want [F1 F2 F3]", got) + } + if got, want := result.Chains[0].EstimatedSeverity, schemas.SeverityCritical; got != want { + t.Errorf("severity = %q, want %q", got, want) + } +} + +// --------------------------------------------------------------------------- +// compute_fingerprint +// --------------------------------------------------------------------------- + +func TestComputeFingerprint(t *testing.T) { + f := schemas.RawFinding{FilePath: "src/f1.py", StartLine: 10, CweID: "CWE-89"} + // Ground truth from the venv interpreter: + // hashlib.sha256(b"src/f1.py:10:CWE-89").hexdigest()[:16] + if got, want := ComputeFingerprint(f), "afd9dad9b5320992"; got != want { + t.Errorf("ComputeFingerprint = %q, want %q", got, want) + } + if got := len(ComputeFingerprint(f)); got != 16 { + t.Errorf("fingerprint length = %d, want 16", got) + } +} + +// A finding that arrives with an empty fingerprint gets one computed, and the +// CALLER's slice sees it (Python mutates the pydantic models in place). +func TestDedup_SeedsFingerprintsInPlace(t *testing.T) { + findings := []schemas.RawFinding{finding("F1", "CWE-89", "SQL Injection", schemas.SeverityHigh)} + findings[0].Fingerprint = "" + _, app := chainFake(t, emptyChains()) + + if _, err := DeduplicateAndCorrelate(context.Background(), findings, schemas.ReconResult{}, app, "."); err != nil { + t.Fatalf("DeduplicateAndCorrelate: %v", err) + } + want := ComputeFingerprint(findings[0]) + if findings[0].Fingerprint != want { + t.Errorf("caller's finding fingerprint = %q, want %q", findings[0].Fingerprint, want) + } +} + +// --------------------------------------------------------------------------- +// fingerprint collapse + _merge_duplicate +// --------------------------------------------------------------------------- + +func TestDedup_CollapsesSharedFingerprintAndMerges(t *testing.T) { + a := finding("A", "CWE-89", "SQL Injection", schemas.SeverityHigh) + a.Fingerprint = "same" + a.Description = "short" + a.RelatedFiles = []string{"z.py", "b.py"} + a.Confidence = schemas.ConfidenceMedium + + b := finding("B", "CWE-89", "SQL Injection", schemas.SeverityHigh) + b.Fingerprint = "same" + b.Description = "a much much longer description" + b.RelatedFiles = []string{"a.py", "b.py"} + b.Confidence = schemas.ConfidenceLow // lower, so A stays the winner + b.DataFlow = []schemas.ReconDataFlowStep{{FilePath: "src/x.py", Line: 3, Component: "c", Operation: "op"}} + + findings := []schemas.RawFinding{a, b} + _, app := chainFake(t, emptyChains()) + + result, err := DeduplicateAndCorrelate(context.Background(), findings, schemas.ReconResult{}, app, ".") + if err != nil { + t.Fatalf("DeduplicateAndCorrelate: %v", err) + } + if len(result.Findings) != 1 { + t.Fatalf("want 1 finding after fingerprint collapse, got %d", len(result.Findings)) + } + got := result.Findings[0] + if got.ID != "A" { + t.Errorf("winner id = %q, want A (existing wins unless incoming has HIGHER confidence)", got.ID) + } + if got.Description != "a much much longer description" { + t.Errorf("description = %q, want the loser's longer text", got.Description) + } + if want := []string{"a.py", "b.py", "z.py"}; !equalStrings(got.RelatedFiles, want) { + t.Errorf("related_files = %v, want %v (sorted set union)", got.RelatedFiles, want) + } + if len(got.DataFlow) != 1 || got.DataFlow[0].FilePath != "src/x.py" { + t.Errorf("data_flow = %v, want the loser's flow adopted when the winner had none", got.DataFlow) + } + if result.TotalRaw != 2 { + t.Errorf("total_raw = %d, want 2 (pre-dedup count)", result.TotalRaw) + } + if result.DeduplicatedCount != 1 { + t.Errorf("deduplicated_count = %d, want 1", result.DeduplicatedCount) + } +} + +func TestDedup_HigherConfidenceIncomingWins(t *testing.T) { + a := finding("A", "CWE-89", "SQL Injection", schemas.SeverityHigh) + a.Fingerprint = "same" + a.Confidence = schemas.ConfidenceLow + b := finding("B", "CWE-89", "SQL Injection", schemas.SeverityHigh) + b.Fingerprint = "same" + b.Confidence = schemas.ConfidenceHigh + + _, app := chainFake(t, emptyChains()) + result, err := DeduplicateAndCorrelate(context.Background(), []schemas.RawFinding{a, b}, schemas.ReconResult{}, app, ".") + if err != nil { + t.Fatalf("DeduplicateAndCorrelate: %v", err) + } + if len(result.Findings) != 1 || result.Findings[0].ID != "B" { + t.Fatalf("want the higher-confidence incoming finding to win, got %+v", result.Findings) + } +} + +// A winner whose data_flow is an EMPTY (non-nil) list is not None in Python, so +// the loser's flow must NOT overwrite it. +func TestDedup_EmptyDataFlowIsNotNone(t *testing.T) { + winner := schemas.NewRawFinding() + winner.DataFlow = []schemas.ReconDataFlowStep{} + winner.Confidence = schemas.ConfidenceHigh + loser := schemas.NewRawFinding() + loser.DataFlow = []schemas.ReconDataFlowStep{{FilePath: "a", Line: 1}} + loser.Confidence = schemas.ConfidenceLow + + got := mergeDuplicate(&winner, &loser) + if len(got.DataFlow) != 0 { + t.Errorf("data_flow = %v, want the winner's empty list preserved", got.DataFlow) + } +} + +// --------------------------------------------------------------------------- +// the .ai(DuplicateCheck) semantic pass +// --------------------------------------------------------------------------- + +func dupCheckFake(isDuplicate bool, hook func()) *appx.Fake { + return &appx.Fake{AIFn: func(_ context.Context, _ string, _ ...ai.Option) (*ai.Response, error) { + if hook != nil { + hook() + } + body := `{"is_duplicate":false,"duplicate_of":null,"reason":"different"}` + if isDuplicate { + body = `{"is_duplicate":true,"duplicate_of":"x","reason":"same root cause"}` + } + return &ai.Response{Choices: []ai.Choice{{Message: ai.Message{ + Role: "assistant", + Content: []ai.ContentPart{{Type: "text", Text: body}}, + }}}}, nil + }} +} + +// sameFile builds n findings in one file with one CWE and distinct fingerprints, +// so every pair is an AI-check candidate. +func sameFile(n int) []schemas.RawFinding { + out := make([]schemas.RawFinding, 0, n) + for i := 0; i < n; i++ { + f := finding(string(rune('A'+i)), "CWE-89", "SQL Injection", schemas.SeverityHigh) + f.FilePath = "src/shared.py" + f.StartLine = 10 + i + f.Fingerprint = "fp-" + string(rune('A'+i)) + out = append(out, f) + } + return out +} + +func TestDedup_AIPassRemovesSemanticDuplicates(t *testing.T) { + fake := dupCheckFake(true, nil) + fake.HarnessFn = appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return json.RawMessage(`{"chains":[],"duplicate_ids":[]}`), nil + }) + + result, err := DeduplicateAndCorrelate(context.Background(), sameFile(3), schemas.ReconResult{}, fake, ".") + if err != nil { + t.Fatalf("DeduplicateAndCorrelate: %v", err) + } + if len(fake.AIs) != 3 { + t.Errorf("want 3 pair checks (C(3,2)), got %d", len(fake.AIs)) + } + if len(result.Findings) != 1 || result.Findings[0].ID != "A" { + t.Fatalf("want only the first finding to survive, got %+v", result.Findings) + } +} + +// Python fans every pair out with asyncio.gather and NO semaphore. The barrier +// below deadlocks (and the test fails on the timeout) if the Go port checks the +// pairs one at a time. +func TestDedup_AIPairChecksRunConcurrently(t *testing.T) { + const pairs = 3 + var mu sync.Mutex + arrived := 0 + release := make(chan struct{}) + timedOut := make(chan struct{}) + + fake := dupCheckFake(false, func() { + mu.Lock() + arrived++ + full := arrived == pairs + mu.Unlock() + if full { + close(release) + } + select { + case <-release: + case <-timedOut: + } + }) + fake.HarnessFn = appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return json.RawMessage(`{"chains":[],"duplicate_ids":[]}`), nil + }) + + done := make(chan struct{}) + go func() { + defer close(done) + if _, err := DeduplicateAndCorrelate(context.Background(), sameFile(3), schemas.ReconResult{}, fake, "."); err != nil { + t.Errorf("DeduplicateAndCorrelate: %v", err) + } + }() + + select { + case <-done: + case <-time.After(10 * time.Second): + close(timedOut) + <-done + t.Fatal("pair checks did not run concurrently: only " + itoa(arrived) + " of 3 were in flight at once") + } +} + +// The `except Exception: return False` in _ai_check_duplicate means a failing +// gate keeps BOTH findings. +func TestDedup_AIErrorMeansNotDuplicate(t *testing.T) { + fake := &appx.Fake{ + AIFn: func(context.Context, string, ...ai.Option) (*ai.Response, error) { + return nil, context.DeadlineExceeded + }, + HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return json.RawMessage(`{"chains":[],"duplicate_ids":[]}`), nil + }), + } + result, err := DeduplicateAndCorrelate(context.Background(), sameFile(2), schemas.ReconResult{}, fake, ".") + if err != nil { + t.Fatalf("DeduplicateAndCorrelate: %v", err) + } + if len(result.Findings) != 2 { + t.Errorf("want both findings kept when the gate fails, got %d", len(result.Findings)) + } +} + +// The hasattr(app, "ai") probe: a harness-only app skips the semantic pass. +func TestDedup_HarnessOnlyAppSkipsAIPass(t *testing.T) { + fake, app := chainFake(t, emptyChains()) + result, err := DeduplicateAndCorrelate(context.Background(), sameFile(3), schemas.ReconResult{}, app, ".") + if err != nil { + t.Fatalf("DeduplicateAndCorrelate: %v", err) + } + if len(fake.AIs) != 0 { + t.Errorf("want no .ai calls for a harness-only app, got %d", len(fake.AIs)) + } + if len(result.Findings) != 3 { + t.Errorf("want all 3 findings kept, got %d", len(result.Findings)) + } +} + +func TestDedup_DuplicateCheckPromptShape(t *testing.T) { + a := finding("A", "CWE-89", "SQL Injection", schemas.SeverityHigh) + b := finding("B", "CWE-79", "XSS", schemas.SeverityLow) + a.Description = strings.Repeat("x", 250) + + prompt := buildDuplicateCheckPrompt(&a, &b) + if !strings.HasPrefix(prompt, "Determine if these two security findings are duplicates (same root cause).\n\nFinding A:\n") { + t.Errorf("unexpected prompt head:\n%s", prompt) + } + if !strings.Contains(prompt, "- CWE: CWE-89 (SQL Injection)\n") { + t.Errorf("missing candidate CWE line:\n%s", prompt) + } + if !strings.Contains(prompt, "- File: src/a.py:10\n") { + t.Errorf("missing candidate file line:\n%s", prompt) + } + if strings.Contains(prompt, strings.Repeat("x", 201)) { + t.Error("description was not truncated to 200 characters") + } + if !strings.Contains(prompt, strings.Repeat("x", 200)) { + t.Error("description was truncated to fewer than 200 characters") + } + if strings.HasSuffix(prompt, "\n") { + t.Error("prompt must not end with a newline (Python's f-string does not)") + } +} + +// --------------------------------------------------------------------------- +// ordering +// --------------------------------------------------------------------------- + +func TestDedup_SortsBySeverityThenConfidenceDescendingStable(t *testing.T) { + mk := func(id string, sev schemas.Severity, conf schemas.Confidence) schemas.RawFinding { + f := finding(id, "CWE-1", "one", sev) + f.Confidence = conf + return f + } + findings := []schemas.RawFinding{ + mk("L", schemas.SeverityLow, schemas.ConfidenceHigh), + mk("C1", schemas.SeverityCritical, schemas.ConfidenceLow), + mk("H", schemas.SeverityHigh, schemas.ConfidenceHigh), + mk("C2", schemas.SeverityCritical, schemas.ConfidenceLow), // ties C1 -> stable + mk("CH", schemas.SeverityCritical, schemas.ConfidenceHigh), + } + _, app := chainFake(t, emptyChains()) + result, err := DeduplicateAndCorrelate(context.Background(), findings, schemas.ReconResult{}, app, ".") + if err != nil { + t.Fatalf("DeduplicateAndCorrelate: %v", err) + } + got := make([]string, 0, len(result.Findings)) + for _, f := range result.Findings { + got = append(got, f.ID) + } + want := []string{"CH", "C1", "C2", "H", "L"} + if !equalStrings(got, want) { + t.Errorf("order = %v, want %v", got, want) + } +} + +// --------------------------------------------------------------------------- +// harness interaction +// --------------------------------------------------------------------------- + +func TestDedup_HarnessOptionsAndTempDirLifecycle(t *testing.T) { + var seenCwd string + var existedDuringCall bool + fake := &appx.Fake{HarnessFn: func(_ context.Context, _ string, _ map[string]any, dest any, opts harness.Options) (*harness.Result, error) { + seenCwd = opts.Cwd + if st, err := os.Stat(opts.Cwd); err == nil && st.IsDir() { + existedDuringCall = true + } + if err := json.Unmarshal([]byte(`{"chains":[],"duplicate_ids":[]}`), dest); err != nil { + return nil, err + } + return &harness.Result{Parsed: dest}, nil + }} + + findings := []schemas.RawFinding{finding("F1", "CWE-89", "SQL Injection", schemas.SeverityHigh)} + if _, err := DeduplicateAndCorrelate(context.Background(), findings, schemas.ReconResult{}, harnessOnly{fake}, "/repo/root"); err != nil { + t.Fatalf("DeduplicateAndCorrelate: %v", err) + } + if !existedDuringCall { + t.Error("harness cwd did not exist during the call") + } + if base := lastPathSegment(seenCwd); !strings.HasPrefix(base, "secaf-dedup-") { + t.Errorf("harness cwd %q does not use the secaf-dedup- prefix", seenCwd) + } + if _, err := os.Stat(seenCwd); !os.IsNotExist(err) { + t.Errorf("harness cwd %q was not removed after the call (err=%v)", seenCwd, err) + } + if got, want := fake.Harnesses[0].Opts.ProjectDir, "/repo/root"; got != want { + t.Errorf("project_dir = %q, want %q", got, want) + } +} + +// A harness that fails (is_error) leaves chains empty, so the seed chains win — +// Python's bare `except Exception: chains = []` plus `if not chains:`. +func TestDedup_HarnessFailureFallsBackToSeedChains(t *testing.T) { + fake := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return nil, errString("provider exploded") + })} + findings := []schemas.RawFinding{ + finding("F1", "CWE-89", "SQL Injection", schemas.SeverityCritical), + finding("F2", "CWE-200", "Exposure of Sensitive Information", schemas.SeverityHigh), + } + result, err := DeduplicateAndCorrelate(context.Background(), findings, schemas.ReconResult{}, harnessOnly{fake}, ".") + if err != nil { + t.Fatalf("DeduplicateAndCorrelate must swallow harness failures, got %v", err) + } + if len(result.Chains) != 1 || result.Chains[0].Title != "Potential attack chain: CWE-89 -> CWE-200" { + t.Errorf("want the seed chain, got %+v", result.Chains) + } +} + +// A transport error out of app.Harness is the `await` raising in Python. +func TestDedup_HarnessTransportErrorFallsBackToSeedChains(t *testing.T) { + fake := &appx.Fake{HarnessFn: func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return nil, errString("connection refused") + }} + findings := []schemas.RawFinding{ + finding("F1", "CWE-89", "SQL Injection", schemas.SeverityCritical), + finding("F2", "CWE-200", "Exposure of Sensitive Information", schemas.SeverityHigh), + } + result, err := DeduplicateAndCorrelate(context.Background(), findings, schemas.ReconResult{}, harnessOnly{fake}, ".") + if err != nil { + t.Fatalf("want the error swallowed, got %v", err) + } + if len(result.Chains) != 1 { + t.Errorf("want the seed chain, got %+v", result.Chains) + } +} + +func TestDedup_DuplicateIDsFromHarnessDropFindings(t *testing.T) { + resp := schemas.NewChainCorrelationResult() + resp.DuplicateIDs = []string{"F2"} + _, app := chainFake(t, resp) + + findings := []schemas.RawFinding{ + finding("F1", "CWE-89", "SQL Injection", schemas.SeverityCritical), + finding("F2", "CWE-200", "Exposure of Sensitive Information", schemas.SeverityHigh), + } + result, err := DeduplicateAndCorrelate(context.Background(), findings, schemas.ReconResult{}, app, ".") + if err != nil { + t.Fatalf("DeduplicateAndCorrelate: %v", err) + } + if len(result.Findings) != 1 || result.Findings[0].ID != "F1" { + t.Fatalf("want F2 dropped, got %+v", result.Findings) + } + if result.DeduplicatedCount != 1 { + t.Errorf("deduplicated_count = %d, want 1", result.DeduplicatedCount) + } + if result.TotalRaw != 2 { + t.Errorf("total_raw = %d, want 2", result.TotalRaw) + } + // The seed chain referenced F2, and Python does NOT re-derive chains after + // the drop, so the (now dangling) seed chain survives. + if len(result.Chains) != 1 { + t.Errorf("chains = %+v, want the seed chain retained", result.Chains) + } +} + +// No findings means no harness call at all (Python guards with `if deduplicated:`). +func TestDedup_NoFindingsSkipsHarness(t *testing.T) { + fake, app := chainFake(t, emptyChains()) + result, err := DeduplicateAndCorrelate(context.Background(), nil, schemas.ReconResult{}, app, ".") + if err != nil { + t.Fatalf("DeduplicateAndCorrelate: %v", err) + } + if len(fake.Harnesses) != 0 { + t.Errorf("want no harness call for an empty finding list, got %d", len(fake.Harnesses)) + } + if result.Findings == nil || len(result.Findings) != 0 { + t.Errorf("findings = %v, want an empty (non-nil) list", result.Findings) + } + if result.Chains == nil || len(result.Chains) != 0 { + t.Errorf("chains = %v, want an empty (non-nil) list", result.Chains) + } + if result.StrategiesRun == nil { + t.Error("strategies_run must be [] (pydantic default_factory=list), not null") + } +} + +// --------------------------------------------------------------------------- +// _parse_chain_from_str / _split_pipe / _seed_chain_context +// --------------------------------------------------------------------------- + +func TestSplitPipe(t *testing.T) { + cases := []struct { + in string + expected int + want []string + }{ + {"a|b|c|d|e", 4, []string{"a", "b", "c", "d|e"}}, + {"only-title", 4, []string{"only-title", "", "", ""}}, + {" a | b ", 4, []string{"a", "b", "", ""}}, + {"", 2, []string{"", ""}}, + } + for _, c := range cases { + got := splitPipe(c.in, c.expected) + if !equalStrings(got, c.want) { + t.Errorf("splitPipe(%q, %d) = %v, want %v", c.in, c.expected, got, c.want) + } + } +} + +func TestParseChainFromStr(t *testing.T) { + available := map[string]struct{}{"F1": {}, "F2": {}, "F3": {}} + + if _, ok := parseChainFromStr("t|F1|impact|high", available); ok { + t.Error("want a chain with a single valid id to be dropped") + } + if _, ok := parseChainFromStr("t|F1,NOPE|impact|high", available); ok { + t.Error("want unknown ids filtered out before the <2 check") + } + + chain, ok := parseChainFromStr("Title|F1, F2 ,,F3||bogus", available) + if !ok { + t.Fatal("want the chain parsed") + } + if chain.Title != "Title" { + t.Errorf("title = %q", chain.Title) + } + if !equalStrings(chain.FindingIDs, []string{"F1", "F2", "F3"}) { + t.Errorf("finding_ids = %v", chain.FindingIDs) + } + if chain.CombinedImpact != "Combined exploitation path" { + t.Errorf("impact = %q, want the empty-string default", chain.CombinedImpact) + } + if chain.EstimatedSeverity != schemas.SeverityHigh { + t.Errorf("severity = %q, want the HIGH default for an unknown label", chain.EstimatedSeverity) + } + if chain.ChainID == "" { + t.Error("chain_id must be minted (pydantic default_factory=uuid4)") + } + + for label, want := range map[string]schemas.Severity{ + "critical": schemas.SeverityCritical, + " HIGH ": schemas.SeverityHigh, + "Medium": schemas.SeverityMedium, + "low": schemas.SeverityLow, + "info": schemas.SeverityHigh, // not in Python's severity_map -> default + } { + c, ok := parseChainFromStr("t|F1,F2|i|"+label, available) + if !ok { + t.Fatalf("chain %q did not parse", label) + } + if c.EstimatedSeverity != want { + t.Errorf("severity for %q = %q, want %q", label, c.EstimatedSeverity, want) + } + } +} + +func TestSeedChainContext_UnresolvableIDsFallBackToTitle(t *testing.T) { + f := finding("F1", "CWE-89", "SQL Injection", schemas.SeverityHigh) + chain := schemas.NewPotentialChain() + chain.Title = "Fallback title" + chain.FindingIDs = []string{"NOPE", "ALSO-NOPE"} + + got := seedChainContext([]schemas.PotentialChain{chain}, []*schemas.RawFinding{&f}) + want := "Seed chain candidates (validate and expand these):\n" + + "- Potential chain: Fallback title (findings NOPE, ALSO-NOPE)\n" + + "Look for additional multi-step attack chains beyond these seeds." + if got != want { + t.Errorf("seedChainContext =\n%q\nwant\n%q", got, want) + } +} + +// _fallback_correlate upper-cases the CWE id before matching, and `max(...)` +// keeps the FIRST argument on a severity tie. +func TestFallbackCorrelate_UpperCasesAndTieKeepsFirst(t *testing.T) { + a := finding("A", "cwe-918", "SSRF", schemas.SeverityHigh) + b := finding("B", "CWE-798", "Creds", schemas.SeverityHigh) + chains := fallbackCorrelate([]*schemas.RawFinding{&a, &b}) + if len(chains) != 1 { + t.Fatalf("want 1 chain, got %d", len(chains)) + } + if chains[0].Title != "Potential attack chain: CWE-918 -> CWE-798" { + t.Errorf("title = %q", chains[0].Title) + } + if chains[0].EstimatedSeverity != schemas.SeverityHigh { + t.Errorf("severity = %q", chains[0].EstimatedSeverity) + } + if chains[0].CombinedImpact != "Combined exploitation path discovered by correlation heuristics; verify chain during PROVE phase." { + t.Errorf("impact = %q", chains[0].CombinedImpact) + } +} + +// Every pattern in _CHAIN_PATTERNS fires, in table order. +func TestFallbackCorrelate_AllPatternsInOrder(t *testing.T) { + cwes := []string{"CWE-918", "CWE-798", "CWE-862", "CWE-285", "CWE-89", "CWE-200", "CWE-16"} + ptrs := make([]*schemas.RawFinding, 0, len(cwes)) + for i, cwe := range cwes { + f := finding(string(rune('A'+i)), cwe, cwe, schemas.SeverityMedium) + ptrs = append(ptrs, &f) + } + chains := fallbackCorrelate(ptrs) + want := []string{ + "Potential attack chain: CWE-918 -> CWE-798", + "Potential attack chain: CWE-862 -> CWE-285", + "Potential attack chain: CWE-89 -> CWE-200", + "Potential attack chain: CWE-16 -> CWE-798", + } + got := make([]string, 0, len(chains)) + for _, c := range chains { + got = append(got, c.Title) + } + if !equalStrings(got, want) { + t.Errorf("chains = %v, want %v", got, want) + } +} + +// --------------------------------------------------------------------------- +// Deduplicator +// --------------------------------------------------------------------------- + +func TestDeduplicator_Run(t *testing.T) { + fake, app := chainFake(t, emptyChains()) + d := NewDeduplicator(app, "/repo") + findings := []schemas.RawFinding{finding("F1", "CWE-89", "SQL Injection", schemas.SeverityHigh)} + result, err := d.Run(context.Background(), findings, schemas.ReconResult{}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if len(result.Findings) != 1 { + t.Fatalf("findings = %+v", result.Findings) + } + if got, want := fake.Harnesses[0].Opts.ProjectDir, "/repo"; got != want { + t.Errorf("project_dir = %q, want %q", got, want) + } +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +type errString string + +func (e errString) Error() string { return string(e) } + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func lastPathSegment(p string) string { + if i := strings.LastIndexByte(p, '/'); i >= 0 { + return p[i+1:] + } + return p +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} diff --git a/go/internal/agents/dedup/golden_test.go b/go/internal/agents/dedup/golden_test.go new file mode 100644 index 0000000..84c4ef2 --- /dev/null +++ b/go/internal/agents/dedup/golden_test.go @@ -0,0 +1,195 @@ +package dedup + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// The goldens in testdata/golden are written by go/scripts/gen_golden.py +// (section "S6"), which captures what the REAL Python functions in +// src/sec_af/agents/dedup.py hand to `app.ai(user=...)` and +// `app.harness(prompt=...)` for the fixtures rebuilt below. Regenerate with: +// +// PYTHONPATH=src ~/.agentfield/packages/sec-af/venv/bin/python go/scripts/gen_golden.py +// +// NOTE (integration): the "S6" section named above is NO LONGER PRESENT in +// go/scripts/gen_golden.py — it was lost when several agents rewrote that file +// concurrently during the port. Running the script does NOT refresh these +// files. The committed goldens ARE the ones that section produced from the real +// Python functions, and this test still guards them; but if the Python prompt +// builder changes, re-derive them by hand from the fixtures below (or restore +// the section) rather than trusting the script. See the COVERAGE GAP comment in +// gen_golden.py. +// +// A diff here means the Go prompt builder and the Python one have drifted, which +// is a real behavioural change: the prompt is the LLM's entire instruction. + +const goldenFixtureRepo = "/fixtures/demo-repo" + +func golden(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "golden", name+".txt")) + if err != nil { + t.Fatalf("read golden %s: %v", name, err) + } + return string(b) +} + +func assertGolden(t *testing.T, name, got string) { + t.Helper() + want := golden(t, name) + if got == want { + return + } + t.Errorf("prompt does not match golden %s.txt\n--- got (%d bytes) ---\n%s\n--- want (%d bytes) ---\n%s", + name, len(got), got, len(want), want) +} + +// goldenFinding mirrors gen_golden.py's _s6_raw_finding defaults. +func goldenFinding(mut func(f *schemas.RawFinding)) schemas.RawFinding { + f := schemas.NewRawFinding() + f.ID = "F1" + f.HunterStrategy = "injection" + f.Title = "SQL injection in user lookup" + f.Description = "User-controlled `user_id` flows into a raw SQL string." + f.FindingType = schemas.FindingTypeSast + f.CweID = "CWE-89" + f.CweName = "SQL Injection" + f.FilePath = "src/db/users.py" + f.StartLine = 42 + f.EndLine = 44 + f.CodeSnippet = `cur.execute("SELECT * FROM users WHERE id = " + user_id)` + f.EstimatedSeverity = schemas.SeverityCritical + f.Confidence = schemas.ConfidenceHigh + f.RelatedFiles = []string{} + f.Fingerprint = "fp-F1" + if mut != nil { + mut(&f) + } + return f +} + +func TestGolden_DuplicateCheckPrompt(t *testing.T) { + candidate := goldenFinding(func(f *schemas.RawFinding) { + f.ID = "F1" + f.Title = "SQL injection in user lookup" + f.Description = strings.Repeat("Tainted `user_id` reaches cur.execute without parameterization. ", 5) + f.Fingerprint = "fp-F1" + }) + existing := goldenFinding(func(f *schemas.RawFinding) { + f.ID = "F2" + f.Title = "Unsanitized SQL string concatenation" + f.Description = "Same sink, different wording — café ☕." + f.FilePath = "src/db/users.py" + f.StartLine = 43 + f.Fingerprint = "fp-F2" + }) + + assertGolden(t, "duplicate_check_prompt", buildDuplicateCheckPrompt(&candidate, &existing)) +} + +// capturePrompt runs DeduplicateAndCorrelate through a harness-only recorder — +// the same shape gen_golden.py's _S6HarnessApp has (no `.ai` attribute, so the +// semantic pass is skipped) — and returns the prompt it was handed. +func capturePrompt(t *testing.T, findings []schemas.RawFinding) string { + t.Helper() + fake := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return nil, errString("captured") + })} + if _, err := DeduplicateAndCorrelate(context.Background(), findings, schemas.ReconResult{}, harnessOnly{fake}, goldenFixtureRepo); err != nil { + t.Fatalf("DeduplicateAndCorrelate: %v", err) + } + if len(fake.Harnesses) != 1 { + t.Fatalf("want 1 harness call, got %d", len(fake.Harnesses)) + } + return fake.Harnesses[0].Prompt +} + +func TestGolden_ChainCorrelationPromptSeeded(t *testing.T) { + findings := []schemas.RawFinding{ + goldenFinding(func(f *schemas.RawFinding) { + f.ID = "F1" + f.CweID = "CWE-918" + f.CweName = "Server-Side Request Forgery (SSRF)" + f.Title = "SSRF in webhook fetcher" + f.FilePath = "src/net/webhook.py" + f.StartLine = 17 + f.EstimatedSeverity = schemas.SeverityHigh + f.Confidence = schemas.ConfidenceHigh + f.Fingerprint = "fp-F1" + }), + goldenFinding(func(f *schemas.RawFinding) { + f.ID = "F2" + f.CweID = "CWE-89" + f.CweName = "SQL Injection" + f.Title = "SQL injection in user lookup" + f.FilePath = "src/db/users.py" + f.StartLine = 42 + f.EstimatedSeverity = schemas.SeverityCritical + f.Confidence = schemas.ConfidenceMedium + f.Fingerprint = "fp-F2" + }), + goldenFinding(func(f *schemas.RawFinding) { + f.ID = "F3" + // Lower-case on purpose: _fallback_correlate upper-cases before + // matching, but the findings summary echoes the raw value. + f.CweID = "cwe-798" + f.CweName = "Use of Hard-coded Credentials" + f.Title = "Hard-coded AWS key" + f.FilePath = "src/config/settings.py" + f.StartLine = 8 + f.EstimatedSeverity = schemas.SeverityMedium + f.Confidence = schemas.ConfidenceLow + f.Fingerprint = "fp-F3" + }), + goldenFinding(func(f *schemas.RawFinding) { + f.ID = "F4" + f.CweID = "CWE-200" + f.CweName = "Exposure of Sensitive Information" + f.Title = "Stack trace leaked to client" + f.FilePath = "src/api/errors.py" + f.StartLine = 55 + f.EstimatedSeverity = schemas.SeverityLow + f.Confidence = schemas.ConfidenceHigh + f.Fingerprint = "fp-F4" + }), + } + assertGolden(t, "chain_correlation_prompt_seeded", capturePrompt(t, findings)) +} + +func TestGolden_ChainCorrelationPromptUnseeded(t *testing.T) { + findings := []schemas.RawFinding{ + goldenFinding(func(f *schemas.RawFinding) { + f.ID = "G1" + f.CweID = "CWE-79" + f.CweName = "Cross-site Scripting" + f.Title = "Reflected XSS in search" + f.FilePath = "src/web/search.py" + f.StartLine = 12 + f.EstimatedSeverity = schemas.SeverityMedium + f.Confidence = schemas.ConfidenceMedium + f.Fingerprint = "fp-G1" + }), + goldenFinding(func(f *schemas.RawFinding) { + f.ID = "G2" + f.CweID = "CWE-327" + f.CweName = "Use of a Broken or Risky Cryptographic Algorithm" + f.Title = "MD5 used for password hashing" + f.FilePath = "src/auth/hash.py" + f.StartLine = 9 + f.EstimatedSeverity = schemas.SeverityHigh + f.Confidence = schemas.ConfidenceLow + f.Fingerprint = "fp-G2" + }), + } + assertGolden(t, "chain_correlation_prompt_unseeded", capturePrompt(t, findings)) +} diff --git a/go/internal/agents/dedup/testdata/golden/chain_correlation_prompt_seeded.txt b/go/internal/agents/dedup/testdata/golden/chain_correlation_prompt_seeded.txt new file mode 100644 index 0000000..193b360 --- /dev/null +++ b/go/internal/agents/dedup/testdata/golden/chain_correlation_prompt_seeded.txt @@ -0,0 +1,15 @@ +You are SEC-AF's chain correlator. +Identify multi-step attack chains across the findings below. +A chain means one vulnerability enables exploitation of another. +Also flag any remaining duplicate IDs that should be dropped. + +Findings: +- id=F2 cwe=CWE-89 file=src/db/users.py:42 title=SQL injection in user lookup severity=critical +- id=F1 cwe=CWE-918 file=src/net/webhook.py:17 title=SSRF in webhook fetcher severity=high +- id=F3 cwe=cwe-798 file=src/config/settings.py:8 title=Hard-coded AWS key severity=medium +- id=F4 cwe=CWE-200 file=src/api/errors.py:55 title=Stack trace leaked to client severity=low + +Seed chain candidates (validate and expand these): +- Potential chain: Server-Side Request Forgery (SSRF) -> Use of Hard-coded Credentials (findings F1, F3) +- Potential chain: SQL Injection -> Exposure of Sensitive Information (findings F2, F4) +Look for additional multi-step attack chains beyond these seeds. \ No newline at end of file diff --git a/go/internal/agents/dedup/testdata/golden/chain_correlation_prompt_unseeded.txt b/go/internal/agents/dedup/testdata/golden/chain_correlation_prompt_unseeded.txt new file mode 100644 index 0000000..d1e46b6 --- /dev/null +++ b/go/internal/agents/dedup/testdata/golden/chain_correlation_prompt_unseeded.txt @@ -0,0 +1,12 @@ +You are SEC-AF's chain correlator. +Identify multi-step attack chains across the findings below. +A chain means one vulnerability enables exploitation of another. +Also flag any remaining duplicate IDs that should be dropped. + +Findings: +- id=G2 cwe=CWE-327 file=src/auth/hash.py:9 title=MD5 used for password hashing severity=high +- id=G1 cwe=CWE-79 file=src/web/search.py:12 title=Reflected XSS in search severity=medium + +Seed chain candidates (validate and expand these): +- No heuristic seed chains were detected from hardcoded CWE pairs. +Look for additional multi-step attack chains beyond these seeds. \ No newline at end of file diff --git a/go/internal/agents/dedup/testdata/golden/duplicate_check_prompt.txt b/go/internal/agents/dedup/testdata/golden/duplicate_check_prompt.txt new file mode 100644 index 0000000..ae7ad6b --- /dev/null +++ b/go/internal/agents/dedup/testdata/golden/duplicate_check_prompt.txt @@ -0,0 +1,13 @@ +Determine if these two security findings are duplicates (same root cause). + +Finding A: +- Title: SQL injection in user lookup +- CWE: CWE-89 (SQL Injection) +- File: src/db/users.py:42 +- Description: Tainted `user_id` reaches cur.execute without parameterization. Tainted `user_id` reaches cur.execute without parameterization. Tainted `user_id` reaches cur.execute without parameterization. Tainted + +Finding B: +- Title: Unsanitized SQL string concatenation +- CWE: CWE-89 (SQL Injection) +- File: src/db/users.py:43 +- Description: Same sink, different wording — café ☕. \ No newline at end of file diff --git a/go/internal/agents/hunt/api_security.go b/go/internal/agents/hunt/api_security.go new file mode 100644 index 0000000..362bddb --- /dev/null +++ b/go/internal/agents/hunt/api_security.go @@ -0,0 +1,75 @@ +package hunt + +// Ports src/sec_af/agents/hunt/api_security.py. + +import ( + "context" + "strconv" + "strings" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/recontext" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +const apiSecurityPromptPath = "hunt/api_security.txt" + +// apiSecurityScanPrompt builds the exact prompt run_api_security_hunter sends. +// +// Python parity: the only hunter whose block closes with an explicit +// "write the JSON output file using your Write tool" instruction (the recon +// mappers use the same wording). earlyStop is where the argument cascade lands +// the depth string for this hunter (package doc). +func apiSecurityScanPrompt(repoPath string, recon schemas.ReconResult, earlyStop string) (scanPrompt, reconContext string) { + reconContext = recontext.ReconContextForAPISecurity(recon) + template := prompts.MustLoad(apiSecurityPromptPath) + template = strings.ReplaceAll(template, "{{RECON_CONTEXT}}", reconContext) + template = strings.ReplaceAll(template, "{{LANGUAGE_HINTS}}", recontext.LanguageHintsForContext(recon)) + template = strings.ReplaceAll(template, "{{FRAMEWORK_HINTS}}", recontext.FrameworkHintsForContext(recon)) + + scanPrompt = template + + "\n\nCONTEXT:\n" + + "- Repository path: " + repoPath + "\n" + + "- Focus only on API-relevant code paths and endpoint handlers surfaced by RECON.\n" + + "- Early stop rule: if you inspect " + + earlyStop + " files without credible API issues, " + + "stop and return empty findings.\n" + + "- Read the handler files first, then generate findings.\n" + + "- After gathering evidence, write the JSON output file using your Write tool." + return scanPrompt, reconContext +} + +func runAPISecurityHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, earlyStop string, +) (schemas.HuntResult, error) { + // Python parity: `if not recon.architecture.api_surface`. Unlike crypto's + // and supply_chain's gates, THIS early exit names the strategy — it is + // identical to the "scanner found nothing" return. + if len(recon.Architecture.APISurface) == 0 { + empty := schemas.NewHuntResult() + empty.StrategiesRun = []string{string(schemas.HuntStrategyAPISecurity)} + return empty, nil + } + scanPrompt, reconContext := apiSecurityScanPrompt(repoPath, recon, earlyStop) + return runHunterBody(ctx, app, repoPath, hunterSpec{ + ScanPrompt: scanPrompt, + ReconContext: reconContext, + // Python parity: the only hunter tagging its findings "api". + FindingType: "api", + Strategy: string(schemas.HuntStrategyAPISecurity), + EmptyStrategiesRun: []string{string(schemas.HuntStrategyAPISecurity)}, + }) +} + +// RunAPISecurityHunter ports api_security.py run_api_security_hunter: +// +// async def run_api_security_hunter(app, repo_path, recon, +// max_files_without_signal: int = 30) -> HuntResult +func RunAPISecurityHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, maxFilesWithoutSignal int, +) (schemas.HuntResult, error) { + return runAPISecurityHunter(ctx, app, repoPath, recon, strconv.Itoa(maxFilesWithoutSignal)) +} diff --git a/go/internal/agents/hunt/auth.go b/go/internal/agents/hunt/auth.go new file mode 100644 index 0000000..c7716d9 --- /dev/null +++ b/go/internal/agents/hunt/auth.go @@ -0,0 +1,94 @@ +package hunt + +// Ports src/sec_af/agents/hunt/auth.py. + +import ( + "context" + "strconv" + "strings" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/recontext" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +const authPromptPath = "hunt/auth.txt" + +// authTargetCWEs ports auth.py `_TARGET_CWES`. Order is load-bearing: the list +// is joined with ", " into the prompt's {{TARGET_CWES}} slot. +var authTargetCWEs = []string{"CWE-287", "CWE-306", "CWE-862", "CWE-863", "CWE-352"} + +// authDepthLabel ports auth.py _depth_label: +// +// normalized = depth.lower().strip() +// return normalized if normalized in {"quick", "standard", "thorough"} else "standard" +// +// Python parity: lower() runs BEFORE strip(), which is immaterial (neither +// changes the other's result), but the STRIP is what distinguishes this from +// config.NormalizeDepth — " THOROUGH " normalizes to "thorough" here and to +// "standard" there. auth.py is the only module that trims. +func authDepthLabel(depth string) string { + normalized := strings.TrimSpace(strings.ToLower(depth)) + switch normalized { + case "quick", "standard", "thorough": + return normalized + default: + return "standard" + } +} + +// authBuildPrompt ports auth.py _build_prompt — the six template substitutions. +// It returns the recon context alongside the prompt because run_auth_hunter +// recomputes the same string for the enrichment step. +func authBuildPrompt(template, repoPath string, recon schemas.ReconResult, depth string) (string, string) { + reconContext := recontext.ReconContextForAuth(recon) + out := strings.ReplaceAll(template, "{{REPO_PATH}}", repoPath) + out = strings.ReplaceAll(out, "{{DEPTH}}", authDepthLabel(depth)) + out = strings.ReplaceAll(out, "{{TARGET_CWES}}", strings.Join(authTargetCWEs, ", ")) + out = strings.ReplaceAll(out, "{{RECON_CONTEXT}}", reconContext) + out = strings.ReplaceAll(out, "{{LANGUAGE_HINTS}}", recontext.LanguageHintsForContext(recon)) + out = strings.ReplaceAll(out, "{{FRAMEWORK_HINTS}}", recontext.FrameworkHintsForContext(recon)) + return out, reconContext +} + +// authScanPrompt builds the exact prompt run_auth_hunter sends. +// +// Python parity: auth is the ONLY hunter whose appended block is headed +// "EXECUTION CONSTRAINTS:" rather than "CONTEXT:", it is the only one that ends +// with a trailing newline, and it carries no repository-path or depth line — +// both already reached the prompt through the template's {{REPO_PATH}} and +// {{DEPTH}} markers. run_auth_hunter also passes the ALREADY-normalized label +// into _build_prompt, which normalizes again; authDepthLabel is idempotent, so +// the port folds the double normalization into one call. +func authScanPrompt(repoPath string, recon schemas.ReconResult, depth, earlyStop string) (scanPrompt, reconContext string) { + built, reconContext := authBuildPrompt(prompts.MustLoad(authPromptPath), repoPath, recon, authDepthLabel(depth)) + scanPrompt = built + + "\n\nEXECUTION CONSTRAINTS:\n" + + "- Early stop rule: if you inspect " + + earlyStop + " files without credible auth issues, " + + "stop and return empty findings.\n" + return scanPrompt, reconContext +} + +func runAuthHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, depth, earlyStop string, +) (schemas.HuntResult, error) { + scanPrompt, reconContext := authScanPrompt(repoPath, recon, depth, earlyStop) + return runHunterBody(ctx, app, repoPath, hunterSpec{ + ScanPrompt: scanPrompt, + ReconContext: reconContext, + FindingType: "sast", + Strategy: "auth", + EmptyStrategiesRun: nil, // Python: bare HuntResult() + }) +} + +// RunAuthHunter ports auth.py run_auth_hunter. +func RunAuthHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, depth string, maxFilesWithoutSignal int, +) (schemas.HuntResult, error) { + return runAuthHunter(ctx, app, repoPath, recon, depth, strconv.Itoa(maxFilesWithoutSignal)) +} diff --git a/go/internal/agents/hunt/business_logic.go b/go/internal/agents/hunt/business_logic.go new file mode 100644 index 0000000..36449f1 --- /dev/null +++ b/go/internal/agents/hunt/business_logic.go @@ -0,0 +1,105 @@ +package hunt + +// Ports src/sec_af/agents/hunt/business_logic.py. + +import ( + "context" + "strconv" + "strings" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/config" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/recontext" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +const businessLogicPromptPath = "hunt/business_logic.txt" + +// IsBusinessLogicHunterEnabled ports business_logic.py +// is_business_logic_hunter_enabled: +// +// profile = _normalize_depth(depth) +// return profile in {DepthProfile.STANDARD, DepthProfile.THOROUGH} +// +// Python parity: the normalization is the LENIENT one, so an unrecognised depth +// becomes STANDARD and the hunter runs. Only "quick" (in any case) disables it. +// business_logic.py declares its own `_normalize_depth` accepting +// `str | DepthProfile`; config.NormalizeDepth covers both arms because Go's +// DepthProfile IS a string type. +func IsBusinessLogicHunterEnabled(depth string) bool { + profile := config.NormalizeDepth(depth) + return profile == config.DepthStandard || profile == config.DepthThorough +} + +// businessLogicScanPrompt builds the exact prompt run_business_logic_hunter +// sends. +// +// Python parity: unlike injection/dos/ssrf/xss, the depth line interpolates the +// NORMALIZED profile (`_normalize_depth(depth).value`), not the raw argument — +// so a direct caller passing "Thorough" gets "thorough" here and "Thorough" +// there. The optional depth_prompt tail is appended AFTER the block, on its own +// line, and only when non-empty. +func businessLogicScanPrompt( + repoPath string, recon schemas.ReconResult, depth, earlyStop, depthPrompt string, +) (scanPrompt, reconContext string) { + reconContext = businessLogicContextBlock(recon) + template := prompts.MustLoad(businessLogicPromptPath) + template = strings.ReplaceAll(template, "{{RECON_CONTEXT_JSON}}", reconContext) + template = strings.ReplaceAll(template, "{{LANGUAGE_HINTS}}", recontext.LanguageHintsForContext(recon)) + template = strings.ReplaceAll(template, "{{FRAMEWORK_HINTS}}", recontext.FrameworkHintsForContext(recon)) + + scanPrompt = template + + "\n\nCONTEXT:\n" + + "- Repository path: " + repoPath + "\n" + + "- Depth profile: " + string(config.NormalizeDepth(depth)) + "\n" + + "- Early stop rule: if you inspect " + earlyStop + + " files without credible business-logic signal, stop and return empty findings.\n" + + "- Strategy: business_logic\n" + + "- Focus CWEs: CWE-840, CWE-841, CWE-362, CWE-367, CWE-639.\n" + + "- Reason about intended business behavior versus exploitable implementation behavior.\n" + + "- Take multiple turns, trace complete workflows, and return final JSON only when complete." + if depthPrompt != "" { + scanPrompt += "\n- Additional depth guidance: " + depthPrompt + } + return scanPrompt, reconContext +} + +func runBusinessLogicHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, depth, earlyStop, depthPrompt string, +) (schemas.HuntResult, error) { + // Python parity: `return HuntResult(findings=[], strategies_run=[])`, which + // is the bare default shape. + if !IsBusinessLogicHunterEnabled(depth) { + return schemas.NewHuntResult(), nil + } + scanPrompt, reconContext := businessLogicScanPrompt(repoPath, recon, depth, earlyStop, depthPrompt) + return runHunterBody(ctx, app, repoPath, hunterSpec{ + ScanPrompt: scanPrompt, + ReconContext: reconContext, + // Python parity: business_logic is one of only three hunters whose + // finding_type is not "sast" — it tags its findings "logic". + FindingType: "logic", + Strategy: string(schemas.HuntStrategyBusinessLogic), + EmptyStrategiesRun: []string{string(schemas.HuntStrategyBusinessLogic)}, + }) +} + +// RunBusinessLogicHunter ports business_logic.py run_business_logic_hunter: +// +// async def run_business_logic_hunter(app, repo_path, recon_result, depth, +// max_files_without_signal: int = 30, +// depth_prompt: str = "") -> HuntResult +// +// depthPrompt is the Go spelling of the `depth_prompt: str = ""` default; pass +// "" for it. __init__.py computes a depth_prompt but never manages to deliver +// it (package doc), so the live pipeline always passes "". +func RunBusinessLogicHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, depth string, maxFilesWithoutSignal int, depthPrompt string, +) (schemas.HuntResult, error) { + return runBusinessLogicHunter( + ctx, app, repoPath, recon, depth, strconv.Itoa(maxFilesWithoutSignal), depthPrompt, + ) +} diff --git a/go/internal/agents/hunt/config_secrets.go b/go/internal/agents/hunt/config_secrets.go new file mode 100644 index 0000000..04b62df --- /dev/null +++ b/go/internal/agents/hunt/config_secrets.go @@ -0,0 +1,69 @@ +package hunt + +// Ports src/sec_af/agents/hunt/config_secrets.py. + +import ( + "context" + "strconv" + "strings" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/recontext" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +const configSecretsPromptPath = "hunt/config_secrets.txt" + +// configSecretsScanPrompt builds the exact prompt run_config_secrets_hunter +// sends. +// +// Python parity: the block ends with a trailing newline and — unlike every +// other hunter — has NO closing "write final JSON" line. earlyStop is where the +// argument cascade lands the depth string for this hunter (package doc). +func configSecretsScanPrompt(repoPath string, recon schemas.ReconResult, earlyStop string) (scanPrompt, reconContext string) { + reconContext = recontext.ReconContextForConfigSecrets(recon) + template := prompts.MustLoad(configSecretsPromptPath) + template = strings.ReplaceAll(template, "{{RECON_CONTEXT}}", reconContext) + template = strings.ReplaceAll(template, "{{LANGUAGE_HINTS}}", recontext.LanguageHintsForContext(recon)) + template = strings.ReplaceAll(template, "{{FRAMEWORK_HINTS}}", recontext.FrameworkHintsForContext(recon)) + + scanPrompt = template + + "\n\nCONTEXT:\n" + + "- Repository path: " + repoPath + "\n" + + "- Hunt strategy: config_secrets (CWE-798, CWE-259, CWE-321, CWE-16).\n" + + "- Early stop rule: if you inspect " + + earlyStop + " files without credible secrets/config issues, " + + "stop and return empty findings.\n" + + "- Use RECON ConfigReport and SecurityContext to prioritize likely real findings.\n" + + "- Take multiple turns: inspect files, validate exploitability signal, then build findings.\n" + return scanPrompt, reconContext +} + +func runConfigSecretsHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, earlyStop string, +) (schemas.HuntResult, error) { + scanPrompt, reconContext := configSecretsScanPrompt(repoPath, recon, earlyStop) + return runHunterBody(ctx, app, repoPath, hunterSpec{ + ScanPrompt: scanPrompt, + ReconContext: reconContext, + // Python parity: the only hunter tagging its findings "config". + FindingType: "config", + Strategy: "config_secrets", + // Python parity: bare HuntResult() here, even though the sibling + // secrets-adjacent hunters name their strategy. + EmptyStrategiesRun: nil, + }) +} + +// RunConfigSecretsHunter ports config_secrets.py run_config_secrets_hunter: +// +// async def run_config_secrets_hunter(app, repo_path, recon, +// max_files_without_signal: int = 30) -> HuntResult +func RunConfigSecretsHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, maxFilesWithoutSignal int, +) (schemas.HuntResult, error) { + return runConfigSecretsHunter(ctx, app, repoPath, recon, strconv.Itoa(maxFilesWithoutSignal)) +} diff --git a/go/internal/agents/hunt/context_blocks.go b/go/internal/agents/hunt/context_blocks.go new file mode 100644 index 0000000..72e7aea --- /dev/null +++ b/go/internal/agents/hunt/context_blocks.go @@ -0,0 +1,94 @@ +package hunt + +// The inline JSON recon-context blocks four hunters build for themselves +// instead of calling into src/sec_af/context.py. + +import ( + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// headEntryPoints ports Python's `architecture.entry_points[:n]`. +// +// The result is always NON-NIL, because pyfmt.Dumps renders a nil Go slice as +// `null` while a Python list slice of an empty list is `[]`. Every other +// element of the block is a struct, whose rendering needs no such care. +func headEntryPoints(items []schemas.EntryPoint, n int) []schemas.EntryPoint { + if n > len(items) { + n = len(items) + } + out := make([]schemas.EntryPoint, 0, n) + return append(out, items[:n]...) +} + +// headAPIEndpoints ports `architecture.api_surface[:n]`. See headEntryPoints. +func headAPIEndpoints(items []schemas.APIEndpoint, n int) []schemas.APIEndpoint { + if n > len(items) { + n = len(items) + } + out := make([]schemas.APIEndpoint, 0, n) + return append(out, items[:n]...) +} + +// headDataFlows ports `data_flows.flows[:n]`. See headEntryPoints. +func headDataFlows(items []schemas.DataFlow, n int) []schemas.DataFlow { + if n > len(items) { + n = len(items) + } + out := make([]schemas.DataFlow, 0, n) + return append(out, items[:n]...) +} + +// entryFlowContextBlock ports the `_recon_context_block` helper that +// agents/hunt/dos.py, ssrf.py and xss.py each declare — three byte-identical +// copies of: +// +// entry_points = [entry.model_dump() for entry in recon_result.architecture.entry_points[:10]] +// data_flows = [flow.model_dump() for flow in recon_result.data_flows.flows[:10]] +// context = { +// "app_type": recon_result.architecture.app_type, +// "auth_model": recon_result.security_context.auth_model, +// "frameworks": recon_result.frameworks, +// "languages": recon_result.languages, +// "entry_points": entry_points, +// "data_flows": data_flows, +// } +// return json.dumps(context, indent=2) +// +// The dict's key order is INSERTION order, which is not alphabetical, so the +// port builds a pyfmt.Ordered rather than a Go map (see DESIGN.md §2b) — and +// pyfmt.Dumps, not encoding/json, because the text reaches the LLM verbatim and +// CPython does not escape `<`, `>` or `&` while Go's encoder does. +// +// `app_type` is `str | None`: a nil *string renders as `null`, exactly as +// model_dump() -> json.dumps does for None. +func entryFlowContextBlock(recon schemas.ReconResult) string { + return pyfmt.Dumps(pyfmt.O( + "app_type", recon.Architecture.AppType, + "auth_model", recon.SecurityContext.AuthModel, + "frameworks", recon.Frameworks, + "languages", recon.Languages, + "entry_points", headEntryPoints(recon.Architecture.EntryPoints, 10), + "data_flows", headDataFlows(recon.DataFlows.Flows, 10), + ), 2) +} + +// businessLogicContextBlock ports agents/hunt/business_logic.py +// `_recon_context_block` — a WIDER projection than entryFlowContextBlock, with +// different limits (15 entry points, 20 endpoints, 20 flows), the api_surface +// added, and a different key order: +// +// {"app_type", "frameworks", "languages", "auth_model", "auth_details", +// "entry_points", "api_surface", "data_flows"} +func businessLogicContextBlock(recon schemas.ReconResult) string { + return pyfmt.Dumps(pyfmt.O( + "app_type", recon.Architecture.AppType, + "frameworks", recon.Frameworks, + "languages", recon.Languages, + "auth_model", recon.SecurityContext.AuthModel, + "auth_details", recon.SecurityContext.AuthDetails, + "entry_points", headEntryPoints(recon.Architecture.EntryPoints, 15), + "api_surface", headAPIEndpoints(recon.Architecture.APISurface, 20), + "data_flows", headDataFlows(recon.DataFlows.Flows, 20), + ), 2) +} diff --git a/go/internal/agents/hunt/crypto.go b/go/internal/agents/hunt/crypto.go new file mode 100644 index 0000000..b44ea64 --- /dev/null +++ b/go/internal/agents/hunt/crypto.go @@ -0,0 +1,155 @@ +package hunt + +// Ports src/sec_af/agents/hunt/crypto.py. + +import ( + "context" + "strconv" + "strings" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/recontext" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +const cryptoPromptPath = "hunt/crypto.txt" + +// cryptoSecurityCriticalTerms ports crypto.py `_SECURITY_CRITICAL_TERMS`. +// Order is load-bearing only in that membership is tested with `any(...)`; the +// matched CONTEXTS are emitted in recon order, not term order. +var cryptoSecurityCriticalTerms = []string{ + "password", "passwd", "credential", "auth", "token", "session", + "encrypt", "decrypt", "signature", "sign", "verify", "jwt", "tls", "ssl", "key", +} + +// cryptoNonSecurityTerms ports crypto.py `_NON_SECURITY_TERMS`. +var cryptoNonSecurityTerms = []string{ + "checksum", "etag", "cache", "fingerprint", "dedup", "integrity", +} + +// cryptoUsageContexts ports crypto.py _usage_contexts: +// +// return [usage.usage_context for usage in recon.security_context.crypto_usage if usage.usage_context] +// +// Python parity: the guard is a TRUTHINESS test on `str | None`, so both None +// AND the empty string are dropped — a nil *string and a "" both skip here. +func cryptoUsageContexts(recon schemas.ReconResult) []string { + out := make([]string, 0, len(recon.SecurityContext.CryptoUsage)) + for _, usage := range recon.SecurityContext.CryptoUsage { + if usage.UsageContext == nil || *usage.UsageContext == "" { + continue + } + out = append(out, *usage.UsageContext) + } + return out +} + +// filterContextsByTerms ports crypto.py _filter_contexts_by_terms — keep every +// context whose LOWERCASED form contains any of the terms, in input order. +// +// Python parity: the terms are already lowercase and are matched as plain +// substrings, so "auth token cache" matches BOTH tables ("auth" and "cache") +// and appears in both candidate lists. +func filterContextsByTerms(contexts, terms []string) []string { + filtered := make([]string, 0, len(contexts)) + for _, context := range contexts { + lowered := strings.ToLower(context) + for _, term := range terms { + if strings.Contains(lowered, term) { + filtered = append(filtered, context) + break + } + } + } + return filtered +} + +// ShouldRunCryptoHunter ports crypto.py should_run_crypto_hunter: +// +// return bool(recon.security_context.crypto_usage) +// +// Note this checks the LIST, not the usage contexts: a CryptoUsage with a nil +// usage_context still opens the gate (and then contributes to neither candidate +// list). +func ShouldRunCryptoHunter(recon schemas.ReconResult) bool { + return len(recon.SecurityContext.CryptoUsage) > 0 +} + +// cryptoCandidateList ports the two +// `", ".join(candidates) if candidates else "none"` expressions. +func cryptoCandidateList(candidates []string) string { + if len(candidates) == 0 { + return "none" + } + return strings.Join(candidates, ", ") +} + +// cryptoScanPrompt builds the exact prompt run_crypto_hunter sends. +// +// Python parity: crypto's CONTEXT block has NO depth line — it carries +// "- Hunt strategy: crypto" instead — and its early-stop sentence ends with +// "without credible crypto misuse". earlyStop is where the argument cascade +// lands the depth string for this hunter (see the package doc). +func cryptoScanPrompt(repoPath string, recon schemas.ReconResult, earlyStop string) (scanPrompt, reconContext string) { + reconContext = recontext.ReconContextForCrypto(recon) + usageContexts := cryptoUsageContexts(recon) + securityCritical := filterContextsByTerms(usageContexts, cryptoSecurityCriticalTerms) + nonSecurity := filterContextsByTerms(usageContexts, cryptoNonSecurityTerms) + + template := prompts.MustLoad(cryptoPromptPath) + template = strings.ReplaceAll(template, "{{RECON_CONTEXT}}", reconContext) + template = strings.ReplaceAll(template, "{{LANGUAGE_HINTS}}", recontext.LanguageHintsForContext(recon)) + template = strings.ReplaceAll(template, "{{FRAMEWORK_HINTS}}", recontext.FrameworkHintsForContext(recon)) + + scanPrompt = template + + "\n\nCONTEXT:\n" + + "- Repository path: " + repoPath + "\n" + + "- Hunt strategy: crypto\n" + + "- Early stop rule: if you inspect " + earlyStop + + " files without credible crypto misuse, stop and return empty findings.\n" + + "- Focus CWEs: CWE-326, CWE-327, CWE-328, CWE-330, CWE-916, CWE-259, CWE-321, CWE-798\n" + + "- Security-critical usage candidates: " + cryptoCandidateList(securityCritical) + "\n" + + "- Non-security usage candidates: " + cryptoCandidateList(nonSecurity) + "\n" + + "- Prioritize weak crypto findings only when used in security-sensitive contexts; avoid checksum/cache-only noise.\n" + + "- Take multiple turns to explore relevant files before finalizing findings.\n" + + "- Write final JSON only when analysis is complete." + return scanPrompt, reconContext +} + +func runCryptoHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, earlyStop string, +) (schemas.HuntResult, error) { + // Python parity: the gate returns a BARE HuntResult() — strategies_run + // stays [] — while the "scanner found nothing" return below names the + // strategy. The two early exits are deliberately different shapes. + if !ShouldRunCryptoHunter(recon) { + return schemas.NewHuntResult(), nil + } + scanPrompt, reconContext := cryptoScanPrompt(repoPath, recon, earlyStop) + return runHunterBody(ctx, app, repoPath, hunterSpec{ + ScanPrompt: scanPrompt, + ReconContext: reconContext, + FindingType: "sast", + Strategy: string(schemas.HuntStrategyCrypto), + EmptyStrategiesRun: []string{string(schemas.HuntStrategyCrypto)}, + }) +} + +// RunCryptoHunter ports crypto.py run_crypto_hunter: +// +// async def run_crypto_hunter(app, repo_path, recon, +// max_files_without_signal: int = 30) -> HuntResult +// +// Note the third parameter is named `recon`, not `recon_result`, and there is +// no `depth` at all — which is exactly why __init__.py's argument cascade falls +// through to its POSITIONAL shape for this hunter and lands the depth string in +// max_files_without_signal. This exported entry point is the honest one, used +// by src/sec_af/reasoners/hunt.py. +func RunCryptoHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, maxFilesWithoutSignal int, +) (schemas.HuntResult, error) { + return runCryptoHunter(ctx, app, repoPath, recon, strconv.Itoa(maxFilesWithoutSignal)) +} diff --git a/go/internal/agents/hunt/data_exposure.go b/go/internal/agents/hunt/data_exposure.go new file mode 100644 index 0000000..bc77de5 --- /dev/null +++ b/go/internal/agents/hunt/data_exposure.go @@ -0,0 +1,66 @@ +package hunt + +// Ports src/sec_af/agents/hunt/data_exposure.py. + +import ( + "context" + "strconv" + "strings" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/recontext" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +const dataExposurePromptPath = "hunt/data_exposure.txt" + +// dataExposureScanPrompt builds the exact prompt run_data_exposure_hunter +// sends. +// +// Python parity: no depth line ("- Strategy: data_exposure" instead), and the +// early-stop sentence ends "without credible exposure risk". earlyStop is where +// the argument cascade lands the depth string for this hunter (package doc). +func dataExposureScanPrompt(repoPath string, recon schemas.ReconResult, earlyStop string) (scanPrompt, reconContext string) { + reconContext = recontext.ReconContextForDataExposure(recon) + template := prompts.MustLoad(dataExposurePromptPath) + template = strings.ReplaceAll(template, "{{RECON_CONTEXT}}", reconContext) + template = strings.ReplaceAll(template, "{{LANGUAGE_HINTS}}", recontext.LanguageHintsForContext(recon)) + template = strings.ReplaceAll(template, "{{FRAMEWORK_HINTS}}", recontext.FrameworkHintsForContext(recon)) + + scanPrompt = template + + "\n\nCONTEXT:\n" + + "- Repository path: " + repoPath + "\n" + + "- Strategy: data_exposure\n" + + "- Early stop rule: if you inspect " + + earlyStop + " files without credible exposure risk, " + + "stop and return empty findings.\n" + + "- Use multiple turns: inspect files first, then produce findings.\n" + + "- Return final JSON only when analysis is complete." + return scanPrompt, reconContext +} + +func runDataExposureHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, earlyStop string, +) (schemas.HuntResult, error) { + scanPrompt, reconContext := dataExposureScanPrompt(repoPath, recon, earlyStop) + return runHunterBody(ctx, app, repoPath, hunterSpec{ + ScanPrompt: scanPrompt, + ReconContext: reconContext, + FindingType: "sast", + Strategy: "data_exposure", + EmptyStrategiesRun: []string{"data_exposure"}, + }) +} + +// RunDataExposureHunter ports data_exposure.py run_data_exposure_hunter: +// +// async def run_data_exposure_hunter(app, repo_path, recon, +// max_files_without_signal: int = 30) -> HuntResult +func RunDataExposureHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, maxFilesWithoutSignal int, +) (schemas.HuntResult, error) { + return runDataExposureHunter(ctx, app, repoPath, recon, strconv.Itoa(maxFilesWithoutSignal)) +} diff --git a/go/internal/agents/hunt/doc.go b/go/internal/agents/hunt/doc.go new file mode 100644 index 0000000..5c2f845 --- /dev/null +++ b/go/internal/agents/hunt/doc.go @@ -0,0 +1,72 @@ +// Package hunt ports src/sec_af/agents/hunt — the HUNT phase: twelve hunter +// modules, the two-step scan/enrich harness pipeline they share, and the +// run_hunt / run_hunt_streaming orchestration in that package's __init__.py. +// +// # Shape of a hunter +// +// Every hunter is the same four steps (agents/hunt/_scan_enrich.py): +// +// 1. build a scan prompt: load its own prompt template, substitute a recon +// context (and, for most, the language/framework hint blocks), then append +// a CONTEXT block whose exact wording differs per hunter; +// 2. ScanLocations — one harness call with the ScanLocationsResult schema, +// which returns bare VulnLocations. An empty list short-circuits; +// 3. EnrichLocationsParallel — one harness call per location, at most five at +// a time, each producing an EnrichedFinding; +// 4. AssembleFinding — zip the two lists and coerce them into RawFindings. +// +// # Two Python quirks reproduced here +// +// ARGUMENT CASCADE. `_run_single_hunter` does not know the hunters' signatures. +// It tries five different call shapes in order, moving on whenever Python +// raises TypeError, and the FIRST shape that binds wins: +// +// shape 1 runner(app=, repo_path=, recon_result=, depth=, depth_prompt=, +// max_files_without_signal=, include_paths=) +// shape 2 runner(app=, repo_path=, recon_result=, depth=) +// shape 3 runner(app=, repo_path=, recon_result=, max_files_without_signal=) +// shape 4 runner(app, repo_path, recon_result, depth.value) [positional] +// shape 5 runner(app, repo_path, recon_result) [positional] +// +// No hunter accepts `include_paths` or all of shape 1's keywords, so shape 1 +// never binds. Shape 3 never binds either — the hunters that omit `depth` name +// their third parameter `recon`, not `recon_result`. What is left: +// +// - injection, xss, dos, ssrf, auth, business_logic declare +// `(app, repo_path, recon_result, depth, max_files_without_signal=30, ...)` +// and bind at shape 2, so max_files_without_signal keeps its own default of +// 30 and business_logic's depth_prompt keeps its default of ""; +// - crypto, data_exposure, supply_chain, config_secrets, api_security declare +// `(app, repo_path, recon, max_files_without_signal=30)` and bind at shape +// 4 — POSITIONALLY. The fourth positional argument is `depth.value`, so +// these five hunters receive the DEPTH STRING in their +// max_files_without_signal slot. Python never type-checks it, and the value +// is only ever interpolated into the prompt, so their early-stop rule reads +// "if you inspect standard files without credible crypto misuse". +// +// That is not a bug this port fixes; it is what reaches the LLM today, and the +// goldens under testdata/golden pin it (see prompt_crypto_standard.txt). It +// also makes run_hunt's early_stop_file_threshold parameter DEAD: it is only +// ever passed in shapes 1 and 3, neither of which binds. The Go table in +// hunt.go encodes the settled result of the cascade directly, one closure per +// strategy, rather than re-deriving it at run time. +// +// MISSING-HUNTER STUBS. `_load_hunter` swallows ImportError and substitutes an +// async no-op that returns []. That is not dead code: each hunter module +// imports sec_af.context, and sec_af.context imports back into +// sec_af.agents.hunt._framework_hints, so whichever of the two packages is +// imported FIRST decides the outcome. Import sec_af.agents.hunt first (which is +// what the live node does, app.py -> orchestrator.py -> `.agents.hunt`) and all +// eleven hunters load; import sec_af.context first and all eleven silently +// become no-ops. The Go port has no such hazard — the table is a package-level +// slice of direct references — so the stubs are not ported, only recorded here. +// go/scripts/gen_golden.py forces the correct order and asserts it. +// +// # Package boundaries +// +// The language/framework hint tables live in internal/recontext, not here, +// even though Python declares them in agents/hunt/_framework_hints.py and +// _language_hints.py: internal/recontext is what every hunter needs for its +// recon context, so hosting the tables there is what breaks the import cycle +// Go would otherwise reject. See internal/recontext/hints.go. +package hunt diff --git a/go/internal/agents/hunt/dos.go b/go/internal/agents/hunt/dos.go new file mode 100644 index 0000000..e4482c9 --- /dev/null +++ b/go/internal/agents/hunt/dos.go @@ -0,0 +1,62 @@ +package hunt + +// Ports src/sec_af/agents/hunt/dos.py. + +import ( + "context" + "strconv" + "strings" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/recontext" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +const dosPromptPath = "hunt/dos.txt" + +// dosScanPrompt builds the exact prompt run_dos_hunter sends, and returns the +// recon context it embedded (reused verbatim for enrichment). +// +// Python parity: dos.py substitutes `{{RECON_CONTEXT_JSON}}` — the inline JSON +// block, not one of context.py's prose builders. +func dosScanPrompt(repoPath string, recon schemas.ReconResult, depth, earlyStop string) (scanPrompt, reconContext string) { + reconContext = entryFlowContextBlock(recon) + template := prompts.MustLoad(dosPromptPath) + template = strings.ReplaceAll(template, "{{RECON_CONTEXT_JSON}}", reconContext) + template = strings.ReplaceAll(template, "{{LANGUAGE_HINTS}}", recontext.LanguageHintsForContext(recon)) + template = strings.ReplaceAll(template, "{{FRAMEWORK_HINTS}}", recontext.FrameworkHintsForContext(recon)) + + scanPrompt = template + + "\n\nCONTEXT:\n" + + "- Repository path: " + repoPath + "\n" + + "- Depth profile: " + depth + "\n" + + "- Early stop rule: if you inspect " + earlyStop + + " files without credible signal, stop and return empty findings.\n" + + "- Focus on RECON entry points and data flows where unbounded work can be attacker-controlled.\n" + + "- Explore code paths that can trigger excessive CPU, memory, I/O, or external-service consumption.\n" + + "- Take multiple turns to build findings incrementally and write final JSON only when complete." + return scanPrompt, reconContext +} + +func runDosHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, depth, earlyStop string, +) (schemas.HuntResult, error) { + scanPrompt, reconContext := dosScanPrompt(repoPath, recon, depth, earlyStop) + return runHunterBody(ctx, app, repoPath, hunterSpec{ + ScanPrompt: scanPrompt, + ReconContext: reconContext, + FindingType: "sast", + Strategy: "dos", + EmptyStrategiesRun: nil, // Python: bare HuntResult() + }) +} + +// RunDosHunter ports dos.py run_dos_hunter. +func RunDosHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, depth string, maxFilesWithoutSignal int, +) (schemas.HuntResult, error) { + return runDosHunter(ctx, app, repoPath, recon, depth, strconv.Itoa(maxFilesWithoutSignal)) +} diff --git a/go/internal/agents/hunt/golden_test.go b/go/internal/agents/hunt/golden_test.go new file mode 100644 index 0000000..27d070e --- /dev/null +++ b/go/internal/agents/hunt/golden_test.go @@ -0,0 +1,319 @@ +package hunt + +// Shared helpers for the golden-fixture tests in this package. +// +// Every fixture under testdata/golden is produced by go/scripts/gen_golden.py +// running the REAL Python code from src/sec_af/agents/hunt. Regenerate with: +// +// PYTHONPATH=src ~/.agentfield/packages/sec-af/venv/bin/python go/scripts/gen_golden.py +// +// A test failing here means the Go port and the Python source disagree about +// bytes that reach the LLM (prompts) or the wire (assembled findings) — not +// that a fixture needs refreshing. Refresh only after a deliberate Python +// change. + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +const ( + goldenDir = "testdata/golden" + testdataDir = "testdata" +) + +// fixtureRepo is gen_golden.py's _S4_FIXTURE_REPO — a path that deliberately +// does not exist on disk, so nothing in these tests touches the filesystem +// beyond the harness scratch dirs. +const fixtureRepo = "/fixtures/demo-repo" + +// goldenText reads a *.txt fixture verbatim. +func goldenText(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(goldenDir, name+".txt")) + if err != nil { + t.Fatalf("read golden %s.txt: %v", name, err) + } + return string(b) +} + +// goldenJSON decodes a *.json fixture into dest. +func goldenJSON(t *testing.T, name string, dest any) { + t.Helper() + b, err := os.ReadFile(filepath.Join(goldenDir, name+".json")) + if err != nil { + t.Fatalf("read golden %s.json: %v", name, err) + } + if err := json.Unmarshal(b, dest); err != nil { + t.Fatalf("decode golden %s.json: %v", name, err) + } +} + +// loadRecon binds one of the two committed ReconResult fixtures. +// +// recon_fixture.json — the rich shared fixture (a copy of internal/recontext's) +// recon_small.json — the small-but-complete one gen_golden.py emits +func loadRecon(t *testing.T, name string) schemas.ReconResult { + t.Helper() + b, err := os.ReadFile(filepath.Join(testdataDir, name+".json")) + if err != nil { + t.Fatalf("read fixture %s.json: %v", name, err) + } + var recon schemas.ReconResult + if err := json.Unmarshal(b, &recon); err != nil { + t.Fatalf("decode fixture %s.json: %v", name, err) + } + return recon +} + +// emptyRecon reproduces gen_golden.py's _s4_recon_empty: every pydantic default +// plus the one non-default SecurityContext the Python fixture sets. +func emptyRecon() schemas.ReconResult { + recon := schemas.NewReconResult() + recon.SecurityContext.AuthModel = "session" + recon.SecurityContext.AuthDetails = "cookie" + return recon +} + +// assertTextEqual compares a rendered string against a golden and reports the +// first differing line, which is far more useful than a 20 KB diff. +func assertTextEqual(t *testing.T, name, got, want string) { + t.Helper() + if got == want { + return + } + gotLines := strings.Split(got, "\n") + wantLines := strings.Split(want, "\n") + for i := 0; i < len(gotLines) || i < len(wantLines); i++ { + var g, w string + if i < len(gotLines) { + g = gotLines[i] + } + if i < len(wantLines) { + w = wantLines[i] + } + if g != w { + t.Fatalf("%s: first difference at line %d\n go: %q\n python: %q\n(got %d lines / %d bytes, want %d lines / %d bytes)", + name, i+1, g, w, len(gotLines), len(got), len(wantLines), len(want)) + } + } + t.Fatalf("%s: strings differ but no differing line found (got %d bytes, want %d bytes)", name, len(got), len(want)) +} + +// sha256Hex is the digest gen_golden.py pins prompts by when the full text +// would be redundant. +func sha256Hex(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +// jsonTree marshals v and decodes the result into the untyped tree shape the +// golden fixtures decode to, so the two compare without either side's Go types +// leaking in. +func jsonTree(t *testing.T, v any) any { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal %T: %v", v, err) + } + var tree any + if err := json.Unmarshal(b, &tree); err != nil { + t.Fatalf("unmarshal %T: %v", v, err) + } + return tree +} + +// scrubIDs replaces every string under an "id" or "fingerprint" key with the +// placeholder gen_golden.py writes. RawFinding mints both as fresh uuid4s +// (pydantic default_factory / schemas.NewRawFinding), so they are +// nondeterministic by construction. +func scrubIDs(v any) any { + switch t := v.(type) { + case map[string]any: + out := make(map[string]any, len(t)) + for k, val := range t { + if k == "id" || k == "fingerprint" { + if _, isStr := val.(string); isStr { + out[k] = "" + continue + } + } + out[k] = scrubIDs(val) + } + return out + case []any: + out := make([]any, len(t)) + for i, val := range t { + out[i] = scrubIDs(val) + } + return out + default: + return v + } +} + +// diffJSON renders got/want for a readable failure message. +func diffJSON(got, want any) string { + g, _ := json.MarshalIndent(got, "", " ") + w, _ := json.MarshalIndent(want, "", " ") + return fmt.Sprintf("\n--- go ---\n%s\n--- python ---\n%s", g, w) +} + +// --------------------------------------------------------------------------- +// harness fakes +// --------------------------------------------------------------------------- + +// schemaTitle is how the fakes tell a scan call from an enrich call — the same +// discriminator gen_golden.py's _S4App uses (`schema.__name__`), since the +// embedded pydantic fixtures carry their class name as "title". +func schemaTitle(schema map[string]any) string { + if schema == nil { + return "" + } + title, _ := schema["title"].(string) + return title +} + +// cannedLocations reproduces gen_golden.py's _s4_locations: a multi-line +// snippet (two lines, so end_line == start_line + 1) and a single-line one. +func cannedLocations() []schemas.VulnLocation { + return []schemas.VulnLocation{ + { + FilePath: "app/api/users.py", + StartLine: 42, + CodeSnippet: "query = f\"SELECT * FROM users WHERE id = {user_id}\"\ncursor.execute(query)", + PatternType: "sql_injection", + }, + { + FilePath: "app/utils/hash.py", + StartLine: 7, + CodeSnippet: "digest = hashlib.md5(password).hexdigest()", + PatternType: "weak_hash", + }, + } +} + +// cannedEnriched reproduces gen_golden.py's _s4_enriched: a well-formed +// enrichment and a coercion torture case (unknown severity, unknown confidence, +// whitespace-only data-flow summary). +func cannedEnriched() []schemas.EnrichedFinding { + return []schemas.EnrichedFinding{ + { + Title: "SQL injection in user lookup", + Description: "user_id flows unescaped into an f-string query.", + CweID: "CWE-89", + Severity: "HIGH", + Confidence: "high", + DataFlowSummary: " request.args['id'] -> query -> cursor.execute ", + }, + { + Title: "Weak hash for password storage", + Description: "MD5 used to derive a credential digest.", + CweID: "CWE-327", + Severity: "catastrophic", + Confidence: "certain", + DataFlowSummary: " ", + }, + } +} + +// huntFake is the Go twin of gen_golden.py's _S4App: it answers app.harness by +// the schema requested, hands back canned values and records every prompt. +// +// One deliberate difference from the Python fake. _S4App pairs the i-th ENRICH +// CALL with enriched[i], which is well defined there because asyncio runs the +// gathered coroutines to completion in creation order when nothing really +// suspends. Go runs the enrichment fan-out on real goroutines, so call order is +// nondeterministic; the fake therefore pairs by LOCATION, parsing the location +// block out of the prompt. That reproduces exactly the pairing Python observes +// (locations[i] <-> enriched[i]) without depending on scheduling. +type huntFake struct { + *appx.Fake + locations []schemas.VulnLocation + enriched []schemas.EnrichedFinding +} + +// newHuntFake builds a fake that returns locations from step 1 and, for step 2, +// the enrichment belonging to the location the prompt names. +func newHuntFake(locations []schemas.VulnLocation, enriched []schemas.EnrichedFinding) *huntFake { + f := &huntFake{Fake: &appx.Fake{}, locations: locations, enriched: enriched} + f.HarnessFn = func(_ context.Context, prompt string, schema map[string]any, dest any, _ harness.Options) (*harness.Result, error) { + switch schemaTitle(schema) { + case "ScanLocationsResult": + result := dest.(*schemas.ScanLocationsResult) + *result = schemas.NewScanLocationsResult() + result.Locations = append(result.Locations, f.locations...) + return &harness.Result{Parsed: dest}, nil + case "EnrichedFinding": + index, ok := f.locationIndex(prompt) + if !ok { + return nil, fmt.Errorf("huntFake: enrich prompt names no known location") + } + result := dest.(*schemas.EnrichedFinding) + *result = f.enriched[index%len(f.enriched)] + return &harness.Result{Parsed: dest}, nil + default: + return nil, fmt.Errorf("huntFake: unexpected harness schema %q", schemaTitle(schema)) + } + } + return f +} + +// locationBlock renders the three prompt lines enrich_finding.txt fills from a +// VulnLocation. The triple is unique per location in every fixture, so it is a +// sound key. +func locationBlock(location schemas.VulnLocation) string { + return "- File path: " + location.FilePath + "\n" + + "- Start line: " + strconv.Itoa(location.StartLine) + "\n" + + "- Pattern type: " + location.PatternType + "\n" +} + +func (f *huntFake) locationIndex(prompt string) (int, bool) { + for i, location := range f.locations { + if strings.Contains(prompt, locationBlock(location)) { + return i, true + } + } + return 0, false +} + +// scanPrompts returns the recorded step-1 prompts, in call order. +func (f *huntFake) scanPrompts() []string { return f.promptsFor("ScanLocationsResult") } + +// enrichPrompts returns the recorded step-2 prompts. Their ORDER is the +// goroutine completion order and is not meaningful; tests that care about a +// specific location's prompt build it with EnrichPrompt instead. +func (f *huntFake) enrichPrompts() []string { return f.promptsFor("EnrichedFinding") } + +func (f *huntFake) promptsFor(title string) []string { + var out []string + for _, call := range f.Harnesses { + if schemaTitle(call.Schema) == title { + out = append(out, call.Prompt) + } + } + return out +} + +// onlyScanPrompt fails unless exactly one step-1 prompt was recorded. +func (f *huntFake) onlyScanPrompt(t *testing.T) string { + t.Helper() + prompts := f.scanPrompts() + if len(prompts) != 1 { + t.Fatalf("want exactly 1 scan prompt, got %d", len(prompts)) + } + return prompts[0] +} diff --git a/go/internal/agents/hunt/hunt.go b/go/internal/agents/hunt/hunt.go new file mode 100644 index 0000000..0a49e89 --- /dev/null +++ b/go/internal/agents/hunt/hunt.go @@ -0,0 +1,484 @@ +package hunt + +// Ports the orchestration half of src/sec_af/agents/hunt/__init__.py: +// _STRATEGY_RUNNERS, _QUICK_STRATEGIES, _select_strategies, _extract_findings, +// _run_single_hunter, run_hunt and run_hunt_streaming. + +import ( + "context" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/sync/semaphore" + + "github.com/Agent-Field/sec-af/go/internal/agents/dedup" + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/config" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// The two keyword defaults run_hunt / run_hunt_streaming declare. Go has no +// default arguments, so callers name them explicitly. +const ( + // DefaultMaxConcurrentHunters ports `max_concurrent_hunters: int = 4`. + DefaultMaxConcurrentHunters = 4 + // DefaultEarlyStopFileThreshold ports `early_stop_file_threshold: int = 30`. + // + // It has NO observable effect: _run_single_hunter only ever passes it in + // the two call shapes that never bind (package doc). It is kept so the Go + // signature matches Python's and so a future fix to the cascade has an + // obvious home. + DefaultEarlyStopFileThreshold = 30 +) + +// deduplicateAndCorrelate is the seam both run_hunt variants use to reach +// src/sec_af/agents/dedup.py deduplicate_and_correlate. It is a variable rather +// than a direct call for one reason: tests/test_hunt_include_paths.py +// monkeypatches exactly that module attribute +// (`monkeypatch.setattr(hunt_module, "deduplicate_and_correlate", ...)`), and +// the ported test needs the same substitution point. Production code never +// reassigns it. +var deduplicateAndCorrelate = dedup.DeduplicateAndCorrelate + +// hunterInvocation is the SETTLED form of _run_single_hunter's argument +// cascade for one strategy — the call Python actually ends up making once its +// five-shape TypeError probe finishes. Encoding the outcome in the table (one +// closure per strategy) is what the package doc describes; re-deriving it at +// run time would mean reproducing Python's argument binding, which Go cannot do +// and which would be far less legible. +type hunterInvocation func( + ctx context.Context, + app appx.Harnesser, + repoPath string, + recon schemas.ReconResult, + depth config.DepthProfile, +) (schemas.HuntResult, error) + +// strategyEntry is one row of the ordered strategy table. +type strategyEntry struct { + Strategy schemas.HuntStrategy + Run hunterInvocation +} + +// strategyRunners ports _STRATEGY_RUNNERS. +// +// Python's dict preserves insertion order and `_select_strategies` returns +// `list(_STRATEGY_RUNNERS)` — the KEYS in that order — so the order below is +// observable: it is the order hunters are launched in, and the order +// `HuntResult.strategies_run` reports. +// +// Each closure spells out the effective argument list for its hunter: +// +// - the six hunters that declare `depth` bind at the cascade's keyword shape, +// so they get the depth and their OWN max_files_without_signal default (30); +// - the five that do not declare `depth` bind POSITIONALLY, so `depth.value` +// lands in their max_files_without_signal slot and reaches the prompt as +// text. `string(depth)` below is that argument, and it is deliberate. +// +// Python parity: _load_hunter's ImportError fallback (a stub returning []) is +// not represented. See the package doc for why it can fire in Python and why it +// cannot here. +var strategyRunners = []strategyEntry{ + {schemas.HuntStrategyInjection, func(ctx context.Context, app appx.Harnesser, repoPath string, recon schemas.ReconResult, depth config.DepthProfile) (schemas.HuntResult, error) { + return runInjectionHunter(ctx, app, repoPath, recon, string(depth), "30") + }}, + {schemas.HuntStrategyXSS, func(ctx context.Context, app appx.Harnesser, repoPath string, recon schemas.ReconResult, depth config.DepthProfile) (schemas.HuntResult, error) { + return runXSSHunter(ctx, app, repoPath, recon, string(depth), "30") + }}, + {schemas.HuntStrategyDos, func(ctx context.Context, app appx.Harnesser, repoPath string, recon schemas.ReconResult, depth config.DepthProfile) (schemas.HuntResult, error) { + return runDosHunter(ctx, app, repoPath, recon, string(depth), "30") + }}, + {schemas.HuntStrategySSRF, func(ctx context.Context, app appx.Harnesser, repoPath string, recon schemas.ReconResult, depth config.DepthProfile) (schemas.HuntResult, error) { + return runSSRFHunter(ctx, app, repoPath, recon, string(depth), "30") + }}, + {schemas.HuntStrategyAuth, func(ctx context.Context, app appx.Harnesser, repoPath string, recon schemas.ReconResult, depth config.DepthProfile) (schemas.HuntResult, error) { + return runAuthHunter(ctx, app, repoPath, recon, string(depth), "30") + }}, + {schemas.HuntStrategyCrypto, func(ctx context.Context, app appx.Harnesser, repoPath string, recon schemas.ReconResult, depth config.DepthProfile) (schemas.HuntResult, error) { + // Positional bind: depth.value lands in max_files_without_signal. + return runCryptoHunter(ctx, app, repoPath, recon, string(depth)) + }}, + {schemas.HuntStrategyBusinessLogic, func(ctx context.Context, app appx.Harnesser, repoPath string, recon schemas.ReconResult, depth config.DepthProfile) (schemas.HuntResult, error) { + // depth_prompt stays "" — the cascade never delivers the one + // _run_single_hunter computes. + return runBusinessLogicHunter(ctx, app, repoPath, recon, string(depth), "30", "") + }}, + {schemas.HuntStrategyDataExposure, func(ctx context.Context, app appx.Harnesser, repoPath string, recon schemas.ReconResult, depth config.DepthProfile) (schemas.HuntResult, error) { + return runDataExposureHunter(ctx, app, repoPath, recon, string(depth)) + }}, + {schemas.HuntStrategySupplyChain, func(ctx context.Context, app appx.Harnesser, repoPath string, recon schemas.ReconResult, depth config.DepthProfile) (schemas.HuntResult, error) { + return runSupplyChainHunter(ctx, app, repoPath, recon, string(depth)) + }}, + {schemas.HuntStrategyConfigSecrets, func(ctx context.Context, app appx.Harnesser, repoPath string, recon schemas.ReconResult, depth config.DepthProfile) (schemas.HuntResult, error) { + return runConfigSecretsHunter(ctx, app, repoPath, recon, string(depth)) + }}, + {schemas.HuntStrategyAPISecurity, func(ctx context.Context, app appx.Harnesser, repoPath string, recon schemas.ReconResult, depth config.DepthProfile) (schemas.HuntResult, error) { + return runAPISecurityHunter(ctx, app, repoPath, recon, string(depth)) + }}, +} + +// strategyRunnerIndex is the `_STRATEGY_RUNNERS[strategy]` lookup. +var strategyRunnerIndex = func() map[schemas.HuntStrategy]hunterInvocation { + out := make(map[schemas.HuntStrategy]hunterInvocation, len(strategyRunners)) + for _, entry := range strategyRunners { + out[entry.Strategy] = entry.Run + } + return out +}() + +// quickStrategies ports _QUICK_STRATEGIES — the five-strategy tuple the QUICK +// profile runs, in its own order (note it is NOT a prefix of the table: xss and +// crypto are skipped, data_exposure is promoted). +var quickStrategies = []schemas.HuntStrategy{ + schemas.HuntStrategyInjection, + schemas.HuntStrategyDos, + schemas.HuntStrategySSRF, + schemas.HuntStrategyAuth, + schemas.HuntStrategyDataExposure, +} + +// QuickStrategies returns a copy of _QUICK_STRATEGIES. +func QuickStrategies() []schemas.HuntStrategy { + return append([]schemas.HuntStrategy(nil), quickStrategies...) +} + +// AllStrategies returns the table's keys in order — `list(_STRATEGY_RUNNERS)`. +func AllStrategies() []schemas.HuntStrategy { + out := make([]schemas.HuntStrategy, 0, len(strategyRunners)) + for _, entry := range strategyRunners { + out = append(out, entry.Strategy) + } + return out +} + +// SelectStrategies ports _select_strategies: +// +// if depth == DepthProfile.QUICK: return list(_QUICK_STRATEGIES) +// return list(_STRATEGY_RUNNERS) +// +// Anything that is not QUICK — including a depth the lenient normalizer already +// turned into STANDARD — runs the full eleven. +func SelectStrategies(depth config.DepthProfile) []schemas.HuntStrategy { + if depth == config.DepthQuick { + return QuickStrategies() + } + return AllStrategies() +} + +// extractFindings ports _extract_findings. +// +// Python probes four shapes in turn (a HuntResult, a bare list of RawFindings, +// a `.parsed` attribute holding either, and a `.findings` attribute) because +// the runner is typed `Callable[..., Awaitable[object]]` and could be the +// _missing_hunter stub, which returns a list. Go's hunterInvocation returns a +// concrete HuntResult, so only the first branch is reachable — the rest are +// Python duck-typing and are deliberately not ported. +func extractFindings(result schemas.HuntResult) []schemas.RawFinding { + return result.Findings +} + +// runSingleHunter ports _run_single_hunter, minus the argument cascade the +// table already resolved (package doc). +// +// Python parity: the `depth_prompt` this function computes for THOROUGH — +// "Use deep, multi-turn analysis. Trace cross-file flows and hunt secondary +// pivots." — only appears in the cascade's first call shape, which never binds, +// so it never reaches a hunter. It is not recreated here; the string lives on +// in ThoroughDepthPrompt for the callers that pass it deliberately. +func runSingleHunter( + ctx context.Context, + run hunterInvocation, + app appx.Harnesser, + repoPath string, + recon schemas.ReconResult, + depth config.DepthProfile, +) ([]schemas.RawFinding, error) { + result, err := run(ctx, app, repoPath, recon, depth) + if err != nil { + return nil, err + } + return extractFindings(result), nil +} + +// ThoroughDepthPrompt is the extra guidance _run_single_hunter builds for the +// THOROUGH profile. It is dead in the in-process path (see runSingleHunter) but +// is the exact string a deliberate caller — RunBusinessLogicHunter's +// depthPrompt argument, or src/sec_af/reasoners/hunt.py — should pass. +const ThoroughDepthPrompt = "Use deep, multi-turn analysis. Trace cross-file flows and hunt secondary pivots." + +// concurrencyLimit ports `max(1, min(max_concurrent_hunters, len(strategies)))`. +func concurrencyLimit(maxConcurrentHunters, strategyCount int) int { + limit := maxConcurrentHunters + if strategyCount < limit { + limit = strategyCount + } + if limit < 1 { + limit = 1 + } + return limit +} + +// normalizeIncludePaths ports +// `{path.strip() for path in include_paths if path and path.strip()}` — a SET, +// so only membership matters and the order is irrelevant. +func normalizeIncludePaths(includePaths []string) map[string]struct{} { + out := make(map[string]struct{}, len(includePaths)) + for _, path := range includePaths { + trimmed := strings.TrimSpace(path) + if path == "" || trimmed == "" { + continue + } + out[trimmed] = struct{}{} + } + return out +} + +// applyIncludePaths ports the post-dedup filter both run_hunt variants apply: +// +// if include_paths: +// normalized = {...} +// deduplicated.findings = [f for f in deduplicated.findings if f.file_path in normalized] +// +// Python parity: this is an EXACT string match on file_path, not a prefix or +// glob match, and it runs AFTER dedup and chain correlation — so a chain may +// reference a finding that the filter just dropped, and `total_raw` still counts +// the filtered-out findings. +func applyIncludePaths(findings []schemas.RawFinding, includePaths []string) []schemas.RawFinding { + if len(includePaths) == 0 { + return findings + } + normalized := normalizeIncludePaths(includePaths) + kept := make([]schemas.RawFinding, 0, len(findings)) + for _, finding := range findings { + if _, ok := normalized[finding.FilePath]; ok { + kept = append(kept, finding) + } + } + return kept +} + +// strategyValues ports `[strategy.value for strategy in strategies]`. +func strategyValues(strategies []schemas.HuntStrategy) []string { + out := make([]string, 0, len(strategies)) + for _, strategy := range strategies { + out = append(out, string(strategy)) + } + return out +} + +// RunHunt ports src/sec_af/agents/hunt/__init__.py run_hunt: +// +// async def run_hunt(app, repo_path, recon_result, depth, +// max_concurrent_hunters=4, early_stop_file_threshold=30, +// include_paths=None) -> HuntResult +// +// Sequence: normalize the depth leniently, select the strategies, run them all +// under a semaphore, flatten the per-hunter findings in STRATEGY order, hand +// everything to dedup, apply the include-paths filter, then overwrite the four +// counters and the duration. +// +// Concurrency parity: +// +// - `asyncio.gather(..., return_exceptions=True)` means a hunter that raises +// is SILENTLY DROPPED — its findings are simply absent and nothing is +// logged. The Go port reproduces that: a per-index error slot that nothing +// reads. The only error RunHunt can return is dedup's. +// - Results are flattened in strategy order regardless of completion order, +// so the port writes into a pre-sized slice rather than appending from the +// goroutines. +// - The semaphore is held for the WHOLE hunter, which includes that hunter's +// own five-way enrichment fan-out — the two limits multiply. +// +// Python parity: earlyStopFileThreshold is accepted and unused. See the package +// doc; DefaultEarlyStopFileThreshold is the value every caller passes. +func RunHunt( + ctx context.Context, + app appx.Harnesser, + repoPath string, + recon schemas.ReconResult, + depth string, + maxConcurrentHunters int, + earlyStopFileThreshold int, + includePaths []string, +) (schemas.HuntResult, error) { + _ = earlyStopFileThreshold // Python parity: never reaches a hunter. + + started := time.Now() + profile := config.NormalizeDepth(depth) + strategies := SelectStrategies(profile) + + sem := semaphore.NewWeighted(int64(concurrencyLimit(maxConcurrentHunters, len(strategies)))) + + perStrategy := make([][]schemas.RawFinding, len(strategies)) + var wg sync.WaitGroup + for i, strategy := range strategies { + i, strategy := i, strategy + wg.Add(1) + go func() { + defer wg.Done() + if err := sem.Acquire(ctx, 1); err != nil { + // Python: a cancelled task is an exception gather swallows. + return + } + defer sem.Release(1) + findings, err := runSingleHunter( + ctx, strategyRunnerIndex[strategy], app, repoPath, recon, profile, + ) + if err != nil { + // Python: `return_exceptions=True` then `if isinstance(result, + // Exception): continue`. + return + } + perStrategy[i] = findings + }() + } + wg.Wait() + + allFindings := []schemas.RawFinding{} + for _, findings := range perStrategy { + allFindings = append(allFindings, findings...) + } + + deduplicated, err := deduplicateAndCorrelate(ctx, allFindings, recon, app, repoPath) + if err != nil { + return schemas.HuntResult{}, err + } + deduplicated.Findings = applyIncludePaths(deduplicated.Findings, includePaths) + + deduplicated.TotalRaw = len(allFindings) + deduplicated.DeduplicatedCount = len(deduplicated.Findings) + deduplicated.ChainCount = len(deduplicated.Chains) + deduplicated.StrategiesRun = strategyValues(strategies) + deduplicated.HuntDurationSeconds = time.Since(started).Seconds() + return deduplicated, nil +} + +// RunHuntStreaming ports run_hunt_streaming — run_hunt plus an incremental +// fingerprint dedup that publishes each hunter's NEW findings as soon as it +// finishes, so the PROVE phase can start before HUNT is done. +// +// async def run_hunt_streaming(app, repo_path, recon_result, findings_queue, depth, +// max_concurrent_hunters=4, early_stop_file_threshold=30, +// include_paths=None) -> HuntResult +// +// Channel semantics: Python's `asyncio.Queue[list[RawFinding] | None]` carries +// batches and is terminated by a None sentinel. The Go port sends the same +// batches on findings and CLOSES it in place of the sentinel — a closed channel +// is Go's end-of-stream, and it cannot be mistaken for a real (empty) batch the +// way a nil slice could. The channel is always closed, including when a hunter +// fails. +// +// Two deliberate differences from Python, both forced by channels: +// +// - Python's default Queue is UNBOUNDED, so `put` never blocks. A Go channel +// is not: pass a buffer of at least len(SelectStrategies(depth)) if the +// consumer is not draining concurrently, or the producers will stall. +// - The send is guarded by ctx.Done() so a consumer that goes away cannot +// wedge the hunt. Python has no equivalent because it cannot block here. +// +// The fingerprint pass differs from run_hunt's in a way that matters: it seeds +// `finding.fingerprint` with `f"{file_path}:{start_line}:{cwe_id}"` when the +// hunter left it empty, which is NOT the sha256 form dedup.ComputeFingerprint +// produces. Findings therefore reach DeduplicateAndCorrelate already +// fingerprinted, and that function's own seeding never fires for them. +func RunHuntStreaming( + ctx context.Context, + app appx.Harnesser, + repoPath string, + recon schemas.ReconResult, + findings chan<- []schemas.RawFinding, + depth string, + maxConcurrentHunters int, + earlyStopFileThreshold int, + includePaths []string, +) (schemas.HuntResult, error) { + _ = earlyStopFileThreshold // Python parity: never reaches a hunter. + + started := time.Now() + profile := config.NormalizeDepth(depth) + strategies := SelectStrategies(profile) + + sem := semaphore.NewWeighted(int64(concurrencyLimit(maxConcurrentHunters, len(strategies)))) + + var ( + mu sync.Mutex + // allRawFindings ports `all_raw_findings`; only its length is read. + allRawCount int + // fingerprintDeduped ports the dict, plus the key order Python's dict + // preserves and `list(...values())` depends on. + fingerprintDeduped = map[string]*schemas.RawFinding{} + fingerprintOrder []string + ) + + var wg sync.WaitGroup + for _, strategy := range strategies { + strategy := strategy + wg.Add(1) + go func() { + defer wg.Done() + + if err := sem.Acquire(ctx, 1); err != nil { + return + } + hunterFindings, err := runSingleHunter( + ctx, strategyRunnerIndex[strategy], app, repoPath, recon, profile, + ) + sem.Release(1) // Python releases before taking the dedup lock. + if err != nil { + // Python: gather(return_exceptions=True) swallows it, and the + // task never reaches its queue put. + return + } + + var newFindings []schemas.RawFinding + mu.Lock() + allRawCount += len(hunterFindings) + for i := range hunterFindings { + finding := &hunterFindings[i] + if finding.Fingerprint == "" { + // Python: `finding.fingerprint or f"{file_path}:{start_line}:{cwe_id}"`. + finding.Fingerprint = finding.FilePath + ":" + + strconv.Itoa(finding.StartLine) + ":" + finding.CweID + } + if _, seen := fingerprintDeduped[finding.Fingerprint]; seen { + continue + } + fingerprintDeduped[finding.Fingerprint] = finding + fingerprintOrder = append(fingerprintOrder, finding.Fingerprint) + newFindings = append(newFindings, *finding) + } + mu.Unlock() + + if len(newFindings) > 0 { + select { + case findings <- newFindings: + case <-ctx.Done(): + } + } + }() + } + wg.Wait() + close(findings) // Python: `await findings_queue.put(None)`. + + unique := make([]schemas.RawFinding, 0, len(fingerprintOrder)) + for _, fingerprint := range fingerprintOrder { + unique = append(unique, *fingerprintDeduped[fingerprint]) + } + + deduplicated, err := deduplicateAndCorrelate(ctx, unique, recon, app, repoPath) + if err != nil { + return schemas.HuntResult{}, err + } + deduplicated.Findings = applyIncludePaths(deduplicated.Findings, includePaths) + + // Python parity: total_raw counts EVERY finding the hunters produced, not + // the fingerprint-unique list that was handed to dedup. + deduplicated.TotalRaw = allRawCount + deduplicated.DeduplicatedCount = len(deduplicated.Findings) + deduplicated.ChainCount = len(deduplicated.Chains) + deduplicated.StrategiesRun = strategyValues(strategies) + deduplicated.HuntDurationSeconds = time.Since(started).Seconds() + return deduplicated, nil +} diff --git a/go/internal/agents/hunt/hunt_test.go b/go/internal/agents/hunt/hunt_test.go new file mode 100644 index 0000000..16374ae --- /dev/null +++ b/go/internal/agents/hunt/hunt_test.go @@ -0,0 +1,684 @@ +package hunt + +// Tests for the orchestration half of src/sec_af/agents/hunt/__init__.py. +// +// Validation contract (behaviour, not implementation): +// +// - QUICK selects exactly the five _QUICK_STRATEGIES, in their own order; +// every other depth — including an unrecognised one — selects all eleven in +// table order; +// - each selected hunter receives the arguments Python's TypeError cascade +// settles on, which is observable in its scan prompt's early-stop line; +// - hunters run at most max(1, min(max_concurrent_hunters, len(strategies))) +// at a time, and a hunter that fails is silently dropped rather than +// failing the phase; +// - findings are flattened in STRATEGY order, handed to dedup, then filtered +// by include_paths on an exact file_path match, and the four counters plus +// strategies_run are overwritten afterwards; +// - early_stop_file_threshold changes nothing; +// - the streaming variant publishes each hunter's fingerprint-NEW findings as +// a batch, seeds a missing fingerprint as "::", terminates +// the stream once, and reports total_raw over ALL findings rather than the +// unique ones. + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "strconv" + "strings" + "sync/atomic" + "testing" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/config" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// --------------------------------------------------------------------------- +// strategy selection +// --------------------------------------------------------------------------- + +// TestSelectStrategiesMatchesPython pins _normalize_depth + _select_strategies +// over the same depth spellings gen_golden.py ran. +func TestSelectStrategiesMatchesPython(t *testing.T) { + var selected map[string][]string + goldenJSON(t, "select_strategies", &selected) + if len(selected) == 0 { + t.Fatal("select_strategies golden is empty") + } + for depth, want := range selected { + got := strategyValues(SelectStrategies(config.NormalizeDepth(depth))) + if !reflect.DeepEqual(got, want) { + t.Errorf("SelectStrategies(NormalizeDepth(%q)) = %v, want %v", depth, got, want) + } + } + + var normalized map[string]string + goldenJSON(t, "normalize_depth", &normalized) + for depth, want := range normalized { + if got := string(config.NormalizeDepth(depth)); got != want { + t.Errorf("NormalizeDepth(%q) = %q, want %q", depth, got, want) + } + } +} + +// TestStrategyTablesMatchPython pins the two ordered tables themselves. +func TestStrategyTablesMatchPython(t *testing.T) { + var quick []string + goldenJSON(t, "quick_strategies", &quick) + if got := strategyValues(QuickStrategies()); !reflect.DeepEqual(got, quick) { + t.Errorf("_QUICK_STRATEGIES = %v, want %v", got, quick) + } + + var all []string + goldenJSON(t, "strategy_runner_order", &all) + if got := strategyValues(AllStrategies()); !reflect.DeepEqual(got, all) { + t.Errorf("_STRATEGY_RUNNERS order = %v, want %v", got, all) + } + if len(strategyRunnerIndex) != len(all) { + t.Errorf("the lookup map has %d entries, the table has %d", len(strategyRunnerIndex), len(all)) + } + for _, strategy := range AllStrategies() { + if strategyRunnerIndex[strategy] == nil { + t.Errorf("no runner registered for %q", strategy) + } + } +} + +// TestQuickStrategiesIsACopy guards the accessors against a caller mutating the +// package tables. +func TestQuickStrategiesIsACopy(t *testing.T) { + first := QuickStrategies() + first[0] = "clobbered" + if QuickStrategies()[0] != schemas.HuntStrategyInjection { + t.Fatal("QuickStrategies handed out the package slice") + } + all := AllStrategies() + all[0] = "clobbered" + if AllStrategies()[0] != schemas.HuntStrategyInjection { + t.Fatal("AllStrategies handed out the package slice") + } +} + +// --------------------------------------------------------------------------- +// the argument cascade, as seen in the prompts +// --------------------------------------------------------------------------- + +// TestCascadePromptsMatchPython runs every strategy the way run_hunt does, at +// every depth, and compares the resulting scan prompt with the one Python +// produced through the real _run_single_hunter cascade. +// +// This is the test that pins the quirk: for crypto, data_exposure, +// supply_chain, config_secrets and api_security the early-stop line reads +// "if you inspect standard files", because the cascade binds POSITIONALLY and +// depth.value lands in max_files_without_signal. +func TestCascadePromptsMatchPython(t *testing.T) { + var cascade map[string]struct { + Fixture string `json:"fixture"` + Hunters map[string]map[string]any `json:"hunters"` + } + goldenJSON(t, "cascade_binding", &cascade) + if len(cascade) != 3 { + t.Fatalf("cascade_binding golden covers %d depths, want 3", len(cascade)) + } + + fixtures := map[string]schemas.ReconResult{ + "rich": loadRecon(t, "recon_fixture"), + "small": loadRecon(t, "recon_small"), + } + + for depth, entry := range cascade { + depth, entry := depth, entry + t.Run(depth, func(t *testing.T) { + recon, ok := fixtures[entry.Fixture] + if !ok { + t.Fatalf("unknown fixture %q", entry.Fixture) + } + profile := config.NormalizeDepth(depth) + selected := strategyValues(SelectStrategies(profile)) + if len(selected) != len(entry.Hunters) { + t.Fatalf("depth %s selects %d strategies, the golden recorded %d", depth, len(selected), len(entry.Hunters)) + } + + for _, strategy := range SelectStrategies(profile) { + strategy := strategy + t.Run(string(strategy), func(t *testing.T) { + bound, ok := entry.Hunters[string(strategy)] + if !ok { + t.Fatalf("no cascade_binding entry for %s/%s", depth, strategy) + } + fake := newHuntFake(nil, cannedEnriched()) + if _, err := strategyRunnerIndex[strategy]( + context.Background(), fake, fixtureRepo, recon, profile, + ); err != nil { + t.Fatalf("%s: %v", strategy, err) + } + got := fake.onlyScanPrompt(t) + assertTextEqual(t, "prompt_"+string(strategy)+"_"+depth, got, + goldenText(t, "prompt_"+string(strategy)+"_"+depth)) + + // And state the quirk directly, so a reader of the test does + // not have to diff two 15 KB prompts to see it. + want := renderBound(t, bound["max_files_without_signal"]) + if !strings.Contains(got, "if you inspect "+want+" ") { + t.Fatalf("%s/%s: early-stop line does not interpolate %q", depth, strategy, want) + } + }) + } + }) + } +} + +// renderBound formats the max_files_without_signal value the golden recorded — +// a JSON number for the six hunters that take a depth, a JSON string (the depth +// itself) for the five that do not. +func renderBound(t *testing.T, v any) string { + t.Helper() + switch value := v.(type) { + case string: + return value + case float64: + return strconv.Itoa(int(value)) + default: + t.Fatalf("unexpected max_files_without_signal %T (%v)", v, v) + return "" + } +} + +// TestCascadeNeverPassesTheEarlyStopThreshold states, in one place, what the +// binding table means: every hunter is reached with either its own 30 default +// or the depth string, and never with run_hunt's early_stop_file_threshold. +func TestCascadeNeverPassesTheEarlyStopThreshold(t *testing.T) { + var cascade map[string]struct { + Hunters map[string]map[string]any `json:"hunters"` + } + goldenJSON(t, "cascade_binding", &cascade) + for depth, entry := range cascade { + for strategy, bound := range entry.Hunters { + got := renderBound(t, bound["max_files_without_signal"]) + if got != "30" && got != depth { + t.Errorf("%s/%s bound max_files_without_signal to %q, want \"30\" or %q", depth, strategy, got, depth) + } + if _, ok := bound["include_paths"]; ok { + t.Errorf("%s/%s received include_paths, which no hunter accepts", depth, strategy) + } + if depthPrompt, ok := bound["depth_prompt"]; ok && depthPrompt != "" { + t.Errorf("%s/%s received depth_prompt %q, want the empty default", depth, strategy, depthPrompt) + } + } + } +} + +// --------------------------------------------------------------------------- +// run_hunt +// --------------------------------------------------------------------------- + +// withStrategyTable temporarily replaces the strategy table, which is how the +// Python tests reach run_hunt: +// +// monkeypatch.setattr(hunt_module, "_select_strategies", lambda _d: [INJECTION]) +// monkeypatch.setitem(hunt_module._STRATEGY_RUNNERS, HuntStrategy.INJECTION, fake_runner) +func withStrategyTable(t *testing.T, entries ...strategyEntry) { + t.Helper() + savedRunners := strategyRunners + savedIndex := strategyRunnerIndex + savedQuick := quickStrategies + + strategyRunners = entries + strategyRunnerIndex = make(map[schemas.HuntStrategy]hunterInvocation, len(entries)) + quickStrategies = nil + for _, entry := range entries { + strategyRunnerIndex[entry.Strategy] = entry.Run + quickStrategies = append(quickStrategies, entry.Strategy) + } + t.Cleanup(func() { + strategyRunners = savedRunners + strategyRunnerIndex = savedIndex + quickStrategies = savedQuick + }) +} + +// withDedup temporarily replaces the dedup seam, the way +// tests/test_hunt_include_paths.py monkeypatches deduplicate_and_correlate. +func withDedup(t *testing.T, fn func(ctx context.Context, findings []schemas.RawFinding, recon schemas.ReconResult, app appx.Harnesser, repoPath string) (schemas.HuntResult, error)) { + t.Helper() + saved := deduplicateAndCorrelate + deduplicateAndCorrelate = fn + t.Cleanup(func() { deduplicateAndCorrelate = saved }) +} + +// passthroughDedup is the Python test's +// `HuntResult(findings=list(all_findings))` stub. +func passthroughDedup(_ context.Context, findings []schemas.RawFinding, _ schemas.ReconResult, _ appx.Harnesser, _ string) (schemas.HuntResult, error) { + result := schemas.NewHuntResult() + result.Findings = append(result.Findings, findings...) + return result, nil +} + +// testFinding ports the `_finding(file_path, suffix)` helper in +// tests/test_hunt_include_paths.py. +func testFinding(filePath, suffix string) schemas.RawFinding { + finding := schemas.NewRawFinding() + finding.ID = "id-" + suffix + finding.HunterStrategy = "injection" + finding.Title = "title-" + suffix + finding.Description = "desc" + finding.FindingType = schemas.FindingTypeSast + finding.CweID = "CWE-89" + finding.CweName = "SQL Injection" + finding.FilePath = filePath + finding.StartLine = 1 + finding.EndLine = 1 + finding.CodeSnippet = "query" + finding.EstimatedSeverity = schemas.SeverityHigh + finding.Confidence = schemas.ConfidenceMedium + finding.Fingerprint = "fp-" + suffix + return finding +} + +// includePathsRecon ports the `_recon_result()` helper in the same file. +func includePathsRecon() schemas.ReconResult { + return emptyRecon() +} + +// TestRunHuntFiltersFindingsByIncludePaths ports +// tests/test_hunt_include_paths.py::test_run_hunt_filters_findings_by_include_paths. +// +// The Python test additionally asserts that its fake runner was handed +// include_paths, which only holds because that fake accepts **kwargs and so +// binds the cascade's FIRST call shape. No real hunter does — the Go +// hunterInvocation signature has no include_paths parameter at all, and +// TestCascadeNeverPassesTheEarlyStopThreshold pins that. What the assertion is +// really about is the observable behaviour below: include_paths filters the +// findings AFTER dedup, while total_raw still counts what the hunters produced. +func TestRunHuntFiltersFindingsByIncludePaths(t *testing.T) { + withStrategyTable(t, strategyEntry{ + Strategy: schemas.HuntStrategyInjection, + Run: func(context.Context, appx.Harnesser, string, schemas.ReconResult, config.DepthProfile) (schemas.HuntResult, error) { + result := schemas.NewHuntResult() + result.Findings = []schemas.RawFinding{ + testFinding("src/keep.py", "keep"), + testFinding("src/drop.py", "drop"), + } + return result, nil + }, + }) + withDedup(t, passthroughDedup) + + result, err := RunHunt( + context.Background(), &appx.Fake{}, ".", includePathsRecon(), "standard", + DefaultMaxConcurrentHunters, DefaultEarlyStopFileThreshold, []string{"src/keep.py"}, + ) + if err != nil { + t.Fatalf("RunHunt: %v", err) + } + + var paths []string + for _, finding := range result.Findings { + paths = append(paths, finding.FilePath) + } + if !reflect.DeepEqual(paths, []string{"src/keep.py"}) { + t.Errorf("findings = %v, want [src/keep.py]", paths) + } + if result.TotalRaw != 2 { + t.Errorf("total_raw = %d, want 2", result.TotalRaw) + } + if result.DeduplicatedCount != 1 { + t.Errorf("deduplicated_count = %d, want 1", result.DeduplicatedCount) + } + if !reflect.DeepEqual(result.StrategiesRun, []string{"injection"}) { + t.Errorf("strategies_run = %v, want [injection]", result.StrategiesRun) + } +} + +// TestNormalizeIncludePaths pins the set construction: blanks dropped, entries +// trimmed, matching exact on file_path. +func TestNormalizeIncludePaths(t *testing.T) { + got := normalizeIncludePaths([]string{" a.py ", "", " ", "b.py", "a.py"}) + want := map[string]struct{}{"a.py": {}, "b.py": {}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("normalizeIncludePaths = %v, want %v", got, want) + } + + findings := []schemas.RawFinding{ + {FilePath: "a.py"}, {FilePath: "src/a.py"}, {FilePath: "b.py"}, {FilePath: " a.py "}, + } + kept := applyIncludePaths(findings, []string{" a.py "}) + if len(kept) != 1 || kept[0].FilePath != "a.py" { + t.Fatalf("applyIncludePaths kept %v, want exactly the exact-match a.py", kept) + } + // An empty include list disables the filter entirely. + if got := applyIncludePaths(findings, nil); len(got) != len(findings) { + t.Fatalf("a nil include list must not filter (kept %d of %d)", len(got), len(findings)) + } + if got := applyIncludePaths(findings, []string{}); len(got) != len(findings) { + t.Fatalf("an empty include list must not filter (kept %d of %d)", len(got), len(findings)) + } + // A list of only-blank entries IS truthy in Python, so it filters + // everything out. + if got := applyIncludePaths(findings, []string{"", " "}); len(got) != 0 { + t.Fatalf("an all-blank include list must drop every finding (kept %d)", len(got)) + } +} + +// TestRunHuntFlattensInStrategyOrder asserts the findings arrive in table +// order, not completion order. +func TestRunHuntFlattensInStrategyOrder(t *testing.T) { + makeRunner := func(name string, delay int) hunterInvocation { + return func(context.Context, appx.Harnesser, string, schemas.ReconResult, config.DepthProfile) (schemas.HuntResult, error) { + // Burn some time so the slow hunters finish out of order. + for i := 0; i < delay*10000; i++ { + _ = i + } + result := schemas.NewHuntResult() + result.Findings = []schemas.RawFinding{testFinding(name+".py", name)} + return result, nil + } + } + withStrategyTable(t, + strategyEntry{schemas.HuntStrategyInjection, makeRunner("injection", 30)}, + strategyEntry{schemas.HuntStrategyXSS, makeRunner("xss", 20)}, + strategyEntry{schemas.HuntStrategyDos, makeRunner("dos", 1)}, + ) + withDedup(t, passthroughDedup) + + result, err := RunHunt( + context.Background(), &appx.Fake{}, ".", emptyRecon(), "standard", + DefaultMaxConcurrentHunters, DefaultEarlyStopFileThreshold, nil, + ) + if err != nil { + t.Fatalf("RunHunt: %v", err) + } + var paths []string + for _, finding := range result.Findings { + paths = append(paths, finding.FilePath) + } + if want := []string{"injection.py", "xss.py", "dos.py"}; !reflect.DeepEqual(paths, want) { + t.Fatalf("findings = %v, want %v (strategy order)", paths, want) + } +} + +// TestRunHuntSwallowsHunterErrors pins `gather(..., return_exceptions=True)`: +// a failing hunter contributes nothing and the phase still succeeds. +func TestRunHuntSwallowsHunterErrors(t *testing.T) { + withStrategyTable(t, + strategyEntry{schemas.HuntStrategyInjection, func(context.Context, appx.Harnesser, string, schemas.ReconResult, config.DepthProfile) (schemas.HuntResult, error) { + return schemas.HuntResult{}, fmt.Errorf("Hunt location scanner harness error: boom") + }}, + strategyEntry{schemas.HuntStrategyXSS, func(context.Context, appx.Harnesser, string, schemas.ReconResult, config.DepthProfile) (schemas.HuntResult, error) { + result := schemas.NewHuntResult() + result.Findings = []schemas.RawFinding{testFinding("ok.py", "ok")} + return result, nil + }}, + ) + withDedup(t, passthroughDedup) + + result, err := RunHunt( + context.Background(), &appx.Fake{}, ".", emptyRecon(), "standard", + DefaultMaxConcurrentHunters, DefaultEarlyStopFileThreshold, nil, + ) + if err != nil { + t.Fatalf("a failing hunter must not fail the phase: %v", err) + } + if len(result.Findings) != 1 || result.Findings[0].FilePath != "ok.py" { + t.Fatalf("findings = %v, want only ok.py", result.Findings) + } + if result.TotalRaw != 1 { + t.Errorf("total_raw = %d, want 1", result.TotalRaw) + } + // strategies_run still names EVERY selected strategy, including the one + // that blew up — Python builds it from `strategies`, not from the results. + if want := []string{"injection", "xss"}; !reflect.DeepEqual(result.StrategiesRun, want) { + t.Errorf("strategies_run = %v, want %v", result.StrategiesRun, want) + } +} + +// TestRunHuntPropagatesDedupError pins the one error run_hunt can return. +func TestRunHuntPropagatesDedupError(t *testing.T) { + withStrategyTable(t, strategyEntry{schemas.HuntStrategyInjection, + func(context.Context, appx.Harnesser, string, schemas.ReconResult, config.DepthProfile) (schemas.HuntResult, error) { + return schemas.NewHuntResult(), nil + }}) + withDedup(t, func(context.Context, []schemas.RawFinding, schemas.ReconResult, appx.Harnesser, string) (schemas.HuntResult, error) { + return schemas.HuntResult{}, fmt.Errorf("mkdtemp: no space left on device") + }) + + if _, err := RunHunt( + context.Background(), &appx.Fake{}, ".", emptyRecon(), "standard", + DefaultMaxConcurrentHunters, DefaultEarlyStopFileThreshold, nil, + ); err == nil { + t.Fatal("want the dedup error to propagate") + } +} + +// TestConcurrencyLimit pins `max(1, min(max_concurrent_hunters, len(strategies)))`. +func TestConcurrencyLimit(t *testing.T) { + cases := []struct{ maxHunters, strategies, want int }{ + {4, 11, 4}, {4, 3, 3}, {1, 11, 1}, {0, 11, 1}, {-5, 11, 1}, {4, 0, 1}, {100, 5, 5}, + } + for _, tc := range cases { + if got := concurrencyLimit(tc.maxHunters, tc.strategies); got != tc.want { + t.Errorf("concurrencyLimit(%d, %d) = %d, want %d", tc.maxHunters, tc.strategies, got, tc.want) + } + } +} + +// TestRunHuntConcurrencyBound asserts hunters really are gated by the semaphore. +func TestRunHuntConcurrencyBound(t *testing.T) { + for _, maxHunters := range []int{1, 2, 4} { + maxHunters := maxHunters + t.Run(strconv.Itoa(maxHunters), func(t *testing.T) { + var inflight, peak int32 + runner := func(context.Context, appx.Harnesser, string, schemas.ReconResult, config.DepthProfile) (schemas.HuntResult, error) { + now := atomic.AddInt32(&inflight, 1) + for { + old := atomic.LoadInt32(&peak) + if now <= old || atomic.CompareAndSwapInt32(&peak, old, now) { + break + } + } + for i := 0; i < 20000; i++ { + _ = i + } + atomic.AddInt32(&inflight, -1) + return schemas.NewHuntResult(), nil + } + entries := make([]strategyEntry, 0, len(AllStrategies())) + for _, strategy := range AllStrategies() { + entries = append(entries, strategyEntry{strategy, runner}) + } + withStrategyTable(t, entries...) + withDedup(t, passthroughDedup) + + if _, err := RunHunt( + context.Background(), &appx.Fake{}, ".", emptyRecon(), "standard", + maxHunters, DefaultEarlyStopFileThreshold, nil, + ); err != nil { + t.Fatalf("RunHunt: %v", err) + } + if got := int(atomic.LoadInt32(&peak)); got > maxHunters { + t.Fatalf("peak hunter concurrency %d exceeds the bound %d", got, maxHunters) + } + }) + } +} + +// TestRunHuntEarlyStopThresholdIsInert pins the dead parameter: changing it +// changes nothing, because the cascade never delivers it. +func TestRunHuntEarlyStopThresholdIsInert(t *testing.T) { + var seen []string + withStrategyTable(t, strategyEntry{schemas.HuntStrategyCrypto, + func(ctx context.Context, app appx.Harnesser, repoPath string, recon schemas.ReconResult, depth config.DepthProfile) (schemas.HuntResult, error) { + return runCryptoHunter(ctx, app, repoPath, recon, string(depth)) + }}) + withDedup(t, passthroughDedup) + + recon := emptyRecon() + recon.SecurityContext.CryptoUsage = []schemas.CryptoUsage{{Algorithm: "MD5", UsageContext: strptr("password hashing")}} + + for _, threshold := range []int{30, 999} { + fake := newHuntFake(nil, cannedEnriched()) + if _, err := RunHunt( + context.Background(), fake, fixtureRepo, recon, "thorough", + DefaultMaxConcurrentHunters, threshold, nil, + ); err != nil { + t.Fatalf("RunHunt: %v", err) + } + seen = append(seen, fake.onlyScanPrompt(t)) + } + if seen[0] != seen[1] { + t.Fatal("early_stop_file_threshold changed the prompt; it must be inert") + } + if !strings.Contains(seen[0], "if you inspect thorough files") { + t.Fatalf("crypto's early-stop line should carry the depth string, got:\n%s", + lastLines(seen[0], 8)) + } +} + +func lastLines(s string, n int) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, "\n") +} + +// --------------------------------------------------------------------------- +// run_hunt_streaming +// --------------------------------------------------------------------------- + +// TestRunHuntStreamingPublishesAndCloses pins the stream contract: one batch per +// hunter that found something NEW, the channel closed exactly once at the end, +// and the final HuntResult built from the fingerprint-unique findings. +func TestRunHuntStreamingPublishesAndCloses(t *testing.T) { + shared := testFinding("shared.py", "shared") // same fingerprint from two hunters + withStrategyTable(t, + strategyEntry{schemas.HuntStrategyInjection, func(context.Context, appx.Harnesser, string, schemas.ReconResult, config.DepthProfile) (schemas.HuntResult, error) { + result := schemas.NewHuntResult() + result.Findings = []schemas.RawFinding{testFinding("a.py", "a"), shared} + return result, nil + }}, + strategyEntry{schemas.HuntStrategyXSS, func(context.Context, appx.Harnesser, string, schemas.ReconResult, config.DepthProfile) (schemas.HuntResult, error) { + result := schemas.NewHuntResult() + result.Findings = []schemas.RawFinding{shared, testFinding("b.py", "b")} + return result, nil + }}, + ) + withDedup(t, passthroughDedup) + + // Buffer for every strategy, matching Python's unbounded asyncio.Queue. + stream := make(chan []schemas.RawFinding, len(AllStrategies())) + result, err := RunHuntStreaming( + context.Background(), &appx.Fake{}, ".", emptyRecon(), stream, "standard", + DefaultMaxConcurrentHunters, DefaultEarlyStopFileThreshold, nil, + ) + if err != nil { + t.Fatalf("RunHuntStreaming: %v", err) + } + + var streamed []string + batches := 0 + for batch := range stream { + batches++ + for _, finding := range batch { + streamed = append(streamed, finding.FilePath) + } + } + if batches != 2 { + t.Errorf("streamed %d batches, want 2 (one per hunter with new findings)", batches) + } + if len(streamed) != 3 { + t.Errorf("streamed %d findings, want 3 (the duplicate is published once)", len(streamed)) + } + + // total_raw counts all four hunter outputs, not the three unique ones. + if result.TotalRaw != 4 { + t.Errorf("total_raw = %d, want 4", result.TotalRaw) + } + if result.DeduplicatedCount != 3 { + t.Errorf("deduplicated_count = %d, want 3", result.DeduplicatedCount) + } + if want := []string{"injection", "xss"}; !reflect.DeepEqual(result.StrategiesRun, want) { + t.Errorf("strategies_run = %v, want %v", result.StrategiesRun, want) + } +} + +// TestRunHuntStreamingSeedsFingerprints pins the seeding rule, which is NOT the +// sha256 form dedup.ComputeFingerprint uses. +func TestRunHuntStreamingSeedsFingerprints(t *testing.T) { + withStrategyTable(t, strategyEntry{schemas.HuntStrategyInjection, + func(context.Context, appx.Harnesser, string, schemas.ReconResult, config.DepthProfile) (schemas.HuntResult, error) { + finding := testFinding("app/x.py", "x") + finding.Fingerprint = "" // hunter left it empty + finding.StartLine = 17 + finding.CweID = "CWE-89" + result := schemas.NewHuntResult() + result.Findings = []schemas.RawFinding{finding} + return result, nil + }}) + + var handed []schemas.RawFinding + withDedup(t, func(_ context.Context, findings []schemas.RawFinding, _ schemas.ReconResult, _ appx.Harnesser, _ string) (schemas.HuntResult, error) { + handed = append(handed, findings...) + return passthroughDedup(context.Background(), findings, schemas.ReconResult{}, nil, "") + }) + + stream := make(chan []schemas.RawFinding, 4) + if _, err := RunHuntStreaming( + context.Background(), &appx.Fake{}, ".", emptyRecon(), stream, "standard", + DefaultMaxConcurrentHunters, DefaultEarlyStopFileThreshold, nil, + ); err != nil { + t.Fatalf("RunHuntStreaming: %v", err) + } + batch := <-stream + if got, want := batch[0].Fingerprint, "app/x.py:17:CWE-89"; got != want { + t.Errorf("streamed fingerprint = %q, want %q", got, want) + } + if len(handed) != 1 || handed[0].Fingerprint != "app/x.py:17:CWE-89" { + t.Errorf("dedup received %#v, want the seeded fingerprint", handed) + } +} + +// TestRunHuntStreamingClosesOnHunterFailure asserts the stream terminates even +// when every hunter blows up, so a consumer ranging over it cannot hang. +func TestRunHuntStreamingClosesOnHunterFailure(t *testing.T) { + withStrategyTable(t, strategyEntry{schemas.HuntStrategyInjection, + func(context.Context, appx.Harnesser, string, schemas.ReconResult, config.DepthProfile) (schemas.HuntResult, error) { + return schemas.HuntResult{}, fmt.Errorf("boom") + }}) + withDedup(t, passthroughDedup) + + stream := make(chan []schemas.RawFinding, 4) + result, err := RunHuntStreaming( + context.Background(), &appx.Fake{}, ".", emptyRecon(), stream, "standard", + DefaultMaxConcurrentHunters, DefaultEarlyStopFileThreshold, nil, + ) + if err != nil { + t.Fatalf("RunHuntStreaming: %v", err) + } + count := 0 + for range stream { + count++ + } + if count != 0 { + t.Errorf("streamed %d batches, want 0", count) + } + if result.TotalRaw != 0 || len(result.Findings) != 0 { + t.Errorf("result = %+v, want an empty one", result) + } +} + +// TestHuntResultJSONShape guards the wire contract the reasoner adapter will +// serialize: the exact pydantic key set, in declaration order. +func TestHuntResultJSONShape(t *testing.T) { + b, err := json.Marshal(schemas.NewHuntResult()) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `{"findings":[],"chains":[],"total_raw":0,"deduplicated_count":0,"chain_count":0,"strategies_run":[],"hunt_duration_seconds":0}` + if string(b) != want { + t.Fatalf("HuntResult JSON = %s, want %s", b, want) + } +} diff --git a/go/internal/agents/hunt/hunters_test.go b/go/internal/agents/hunt/hunters_test.go new file mode 100644 index 0000000..6b2e844 --- /dev/null +++ b/go/internal/agents/hunt/hunters_test.go @@ -0,0 +1,703 @@ +package hunt + +// Tests for the twelve hunter modules under src/sec_af/agents/hunt. +// +// Validation contract (behaviour, not implementation): +// +// - each hunter's SCAN PROMPT is byte-identical to the Python module's, for +// the direct call AND for the call __init__.py's argument cascade makes; +// - each hunter enriches with its own finding_type/strategy pair and its own +// recon context, and the assembled HuntResult (findings, counters, +// strategies_run) matches Python's model_dump(); +// - the four gated hunters (crypto, supply_chain, api_security, +// business_logic/logic) make NO harness call when their gate is closed, and +// return the exact empty shape Python returns — which is not the same shape +// for all four; +// - a scan that finds no locations returns without enriching, again in each +// hunter's own empty shape; +// - crypto partitions its usage contexts into security-critical and +// non-security candidates by substring, in recon order, with a context +// landing in both lists when it matches both tables; +// - run_logic_hunter is indistinguishable from run_business_logic_hunter. + +import ( + "context" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// hunterCase describes one hunter for the table-driven tests. Direct calls the +// exported entry point the way src/sec_af/reasoners/hunt.py does — depth +// "standard" where the hunter accepts one, max_files_without_signal at its 30 +// default — and Prompt builds the same call's scan prompt plus the recon +// context it embeds. +type hunterCase struct { + Name string + FindingType string + Strategy string + Direct func(ctx context.Context, app appx.Harnesser, repoPath string, recon schemas.ReconResult) (schemas.HuntResult, error) + Prompt func(repoPath string, recon schemas.ReconResult) (string, string) +} + +func hunterCases() []hunterCase { + return []hunterCase{ + {"injection", "sast", "injection", + func(ctx context.Context, app appx.Harnesser, repo string, r schemas.ReconResult) (schemas.HuntResult, error) { + return RunInjectionHunter(ctx, app, repo, r, "standard", 30) + }, + func(repo string, r schemas.ReconResult) (string, string) { + return injectionScanPrompt(repo, r, "standard", "30") + }}, + {"xss", "sast", "xss", + func(ctx context.Context, app appx.Harnesser, repo string, r schemas.ReconResult) (schemas.HuntResult, error) { + return RunXSSHunter(ctx, app, repo, r, "standard", 30) + }, + func(repo string, r schemas.ReconResult) (string, string) { + return xssScanPrompt(repo, r, "standard", "30") + }}, + {"dos", "sast", "dos", + func(ctx context.Context, app appx.Harnesser, repo string, r schemas.ReconResult) (schemas.HuntResult, error) { + return RunDosHunter(ctx, app, repo, r, "standard", 30) + }, + func(repo string, r schemas.ReconResult) (string, string) { + return dosScanPrompt(repo, r, "standard", "30") + }}, + {"ssrf", "sast", "ssrf", + func(ctx context.Context, app appx.Harnesser, repo string, r schemas.ReconResult) (schemas.HuntResult, error) { + return RunSSRFHunter(ctx, app, repo, r, "standard", 30) + }, + func(repo string, r schemas.ReconResult) (string, string) { + return ssrfScanPrompt(repo, r, "standard", "30") + }}, + {"auth", "sast", "auth", + func(ctx context.Context, app appx.Harnesser, repo string, r schemas.ReconResult) (schemas.HuntResult, error) { + return RunAuthHunter(ctx, app, repo, r, "standard", 30) + }, + func(repo string, r schemas.ReconResult) (string, string) { + return authScanPrompt(repo, r, "standard", "30") + }}, + {"crypto", "sast", "crypto", + func(ctx context.Context, app appx.Harnesser, repo string, r schemas.ReconResult) (schemas.HuntResult, error) { + return RunCryptoHunter(ctx, app, repo, r, 30) + }, + func(repo string, r schemas.ReconResult) (string, string) { + return cryptoScanPrompt(repo, r, "30") + }}, + {"business_logic", "logic", "business_logic", + func(ctx context.Context, app appx.Harnesser, repo string, r schemas.ReconResult) (schemas.HuntResult, error) { + return RunBusinessLogicHunter(ctx, app, repo, r, "standard", 30, "") + }, + func(repo string, r schemas.ReconResult) (string, string) { + return businessLogicScanPrompt(repo, r, "standard", "30", "") + }}, + {"logic", "logic", "business_logic", + func(ctx context.Context, app appx.Harnesser, repo string, r schemas.ReconResult) (schemas.HuntResult, error) { + return RunLogicHunter(ctx, app, repo, r, "standard", 30, "") + }, + func(repo string, r schemas.ReconResult) (string, string) { + return businessLogicScanPrompt(repo, r, "standard", "30", "") + }}, + {"data_exposure", "sast", "data_exposure", + func(ctx context.Context, app appx.Harnesser, repo string, r schemas.ReconResult) (schemas.HuntResult, error) { + return RunDataExposureHunter(ctx, app, repo, r, 30) + }, + func(repo string, r schemas.ReconResult) (string, string) { + return dataExposureScanPrompt(repo, r, "30") + }}, + {"supply_chain", "sca", "supply_chain", + func(ctx context.Context, app appx.Harnesser, repo string, r schemas.ReconResult) (schemas.HuntResult, error) { + return RunSupplyChainHunter(ctx, app, repo, r, 30) + }, + func(repo string, r schemas.ReconResult) (string, string) { + return supplyChainScanPrompt(repo, r, "30") + }}, + {"config_secrets", "config", "config_secrets", + func(ctx context.Context, app appx.Harnesser, repo string, r schemas.ReconResult) (schemas.HuntResult, error) { + return RunConfigSecretsHunter(ctx, app, repo, r, 30) + }, + func(repo string, r schemas.ReconResult) (string, string) { + return configSecretsScanPrompt(repo, r, "30") + }}, + {"api_security", "api", "api_security", + func(ctx context.Context, app appx.Harnesser, repo string, r schemas.ReconResult) (schemas.HuntResult, error) { + return RunAPISecurityHunter(ctx, app, repo, r, 30) + }, + func(repo string, r schemas.ReconResult) (string, string) { + return apiSecurityScanPrompt(repo, r, "30") + }}, + } +} + +// TestDirectPromptDigestsMatchPython pins every hunter's DIRECT-call scan +// prompt, and the two enrichment prompts it would produce for the canned +// locations, by SHA-256 against the Python functions. +// +// The digest form is deliberate: the enrichment prompt embeds the same recon +// context as the scan prompt, so storing twenty-four more full texts would add +// a quarter-megabyte of testdata and no coverage. A digest mismatch is still an +// exact-bytes failure; the five full-text goldens below make the common +// failures readable. +func TestDirectPromptDigestsMatchPython(t *testing.T) { + var golden struct { + Scan map[string]string `json:"scan"` + Enrich map[string][]string `json:"enrich"` + } + goldenJSON(t, "direct_prompt_sha256", &golden) + recon := loadRecon(t, "recon_fixture") + + for _, hc := range hunterCases() { + hc := hc + t.Run(hc.Name, func(t *testing.T) { + scanPrompt, reconContext := hc.Prompt(fixtureRepo, recon) + if got, want := sha256Hex(ScanPrompt(scanPrompt)), golden.Scan[hc.Name]; got != want { + // The digest compared here is of the FULL step-1 prompt (the + // scan_locations wrapper around the hunter's own text), which + // is what Python's _S4App recorded. + t.Fatalf("scan prompt digest = %s, want %s", got, want) + } + wantEnrich := golden.Enrich[hc.Name] + if len(wantEnrich) != len(cannedLocations()) { + t.Fatalf("golden has %d enrich digests, want %d", len(wantEnrich), len(cannedLocations())) + } + for i, location := range cannedLocations() { + got := sha256Hex(EnrichPrompt(location, hc.FindingType, hc.Strategy, reconContext)) + if got != wantEnrich[i] { + t.Fatalf("enrich prompt %d digest = %s, want %s", i, got, wantEnrich[i]) + } + } + }) + } +} + +// TestDirectPromptTextMatchesPython compares the full text for the five hunters +// whose direct-call prompt differs from the one the cascade produces (their +// early-stop value is 30 here and the depth string there). +// +// Every prompt golden in this package is the prompt as app.harness SAW it — +// i.e. the hunter's own text already wrapped in the shared scan_locations +// template — because that is where gen_golden.py's fake recorded it. +func TestDirectPromptTextMatchesPython(t *testing.T) { + recon := loadRecon(t, "recon_fixture") + byName := map[string]hunterCase{} + for _, hc := range hunterCases() { + byName[hc.Name] = hc + } + for _, name := range []string{"crypto", "data_exposure", "supply_chain", "config_secrets", "api_security"} { + name := name + t.Run(name, func(t *testing.T) { + scanPrompt, _ := byName[name].Prompt(fixtureRepo, recon) + assertTextEqual(t, "direct_prompt_"+name, ScanPrompt(scanPrompt), goldenText(t, "direct_prompt_"+name)) + }) + } +} + +// TestLogicHunterIsBusinessLogicHunter pins logic.py's delegation: same prompt +// (down to the byte), same finding_type, same strategies_run. +func TestLogicHunterIsBusinessLogicHunter(t *testing.T) { + recon := loadRecon(t, "recon_fixture") + + logicPrompt, logicContext := businessLogicScanPrompt(fixtureRepo, recon, "standard", "30", "") + assertTextEqual(t, "logic == business_logic prompt", ScanPrompt(logicPrompt), + goldenText(t, "prompt_business_logic_standard")) + + logicFake := newHuntFake(cannedLocations(), cannedEnriched()) + logicResult, err := RunLogicHunter(context.Background(), logicFake, fixtureRepo, recon, "standard", 30, "") + if err != nil { + t.Fatalf("RunLogicHunter: %v", err) + } + blFake := newHuntFake(cannedLocations(), cannedEnriched()) + blResult, err := RunBusinessLogicHunter(context.Background(), blFake, fixtureRepo, recon, "standard", 30, "") + if err != nil { + t.Fatalf("RunBusinessLogicHunter: %v", err) + } + if !reflect.DeepEqual(scrubIDs(jsonTree(t, logicResult)), scrubIDs(jsonTree(t, blResult))) { + t.Fatalf("run_logic_hunter and run_business_logic_hunter disagree%s", + diffJSON(scrubIDs(jsonTree(t, logicResult)), scrubIDs(jsonTree(t, blResult)))) + } + if got := logicResult.StrategiesRun; !reflect.DeepEqual(got, []string{"business_logic"}) { + t.Fatalf("strategies_run = %v, want [business_logic] — logic never names itself", got) + } + if logicContext == "" { + t.Fatal("recon context is empty") + } +} + +// TestHunterResultsMatchPython runs every hunter end to end over the canned +// two-location scan and compares the whole HuntResult against Python's +// model_dump(). +func TestHunterResultsMatchPython(t *testing.T) { + var golden map[string]map[string]any + goldenJSON(t, "hunter_results", &golden) + recon := loadRecon(t, "recon_fixture") + + for _, hc := range hunterCases() { + hc := hc + t.Run(hc.Name, func(t *testing.T) { + want, ok := golden[hc.Name] + if !ok { + t.Fatalf("no hunter_results golden for %s", hc.Name) + } + fake := newHuntFake(cannedLocations(), cannedEnriched()) + got, err := hc.Direct(context.Background(), fake, fixtureRepo, recon) + if err != nil { + t.Fatalf("%s: %v", hc.Name, err) + } + if len(fake.scanPrompts()) != 1 { + t.Fatalf("want 1 scan call, got %d", len(fake.scanPrompts())) + } + if len(fake.enrichPrompts()) != len(cannedLocations()) { + t.Fatalf("want %d enrich calls, got %d", len(cannedLocations()), len(fake.enrichPrompts())) + } + gotTree := scrubIDs(jsonTree(t, got)) + wantTree := scrubIDs(jsonTree(t, want)) + if !reflect.DeepEqual(gotTree, wantTree) { + t.Fatalf("%s HuntResult mismatch%s", hc.Name, diffJSON(gotTree, wantTree)) + } + }) + } +} + +// TestHunterEmptyLocationsMatchPython pins the "scanner found nothing" return +// for every hunter — one scan call, no enrichment, and each hunter's own empty +// shape (six bare, five naming their strategy). +func TestHunterEmptyLocationsMatchPython(t *testing.T) { + var golden map[string]struct { + ScanCalls int `json:"scan_calls"` + EnrichCalls int `json:"enrich_calls"` + Want map[string]any `json:"want"` + } + goldenJSON(t, "hunter_empty_locations", &golden) + recon := loadRecon(t, "recon_fixture") + + for _, hc := range hunterCases() { + hc := hc + t.Run(hc.Name, func(t *testing.T) { + want, ok := golden[hc.Name] + if !ok { + t.Fatalf("no hunter_empty_locations golden for %s", hc.Name) + } + fake := newHuntFake(nil, cannedEnriched()) + got, err := hc.Direct(context.Background(), fake, fixtureRepo, recon) + if err != nil { + t.Fatalf("%s: %v", hc.Name, err) + } + if len(fake.scanPrompts()) != want.ScanCalls { + t.Errorf("scan calls = %d, want %d", len(fake.scanPrompts()), want.ScanCalls) + } + if len(fake.enrichPrompts()) != want.EnrichCalls { + t.Errorf("enrich calls = %d, want %d", len(fake.enrichPrompts()), want.EnrichCalls) + } + gotTree := scrubIDs(jsonTree(t, got)) + wantTree := scrubIDs(jsonTree(t, want.Want)) + if !reflect.DeepEqual(gotTree, wantTree) { + t.Fatalf("%s empty HuntResult mismatch%s", hc.Name, diffJSON(gotTree, wantTree)) + } + }) + } +} + +// TestHunterSkipsMatchPython pins the four gates: no harness call at all, and +// the exact empty shape each gate returns. +func TestHunterSkipsMatchPython(t *testing.T) { + var golden map[string]struct { + HarnessCalls int `json:"harness_calls"` + Want map[string]any `json:"want"` + } + goldenJSON(t, "hunter_skips", &golden) + + rich := loadRecon(t, "recon_fixture") + + // gen_golden.py's no_crypto: the rich recon with an empty crypto_usage. + noCrypto := loadRecon(t, "recon_fixture") + noCrypto.SecurityContext = schemas.SecurityContext{ + AuthModel: "jwt", + AuthDetails: "x", + CryptoUsage: []schemas.CryptoUsage{}, + FrameworkSecurity: append([]string(nil), rich.SecurityContext.FrameworkSecurity...), + SecurityHeaders: append([]string(nil), rich.SecurityContext.SecurityHeaders...), + DeploymentSignals: append([]string(nil), rich.SecurityContext.DeploymentSignals...), + } + + // gen_golden.py's no_deps: direct_count back to 0. + noDeps := loadRecon(t, "recon_fixture") + noDeps.Dependencies = schemas.NewDependencyReport() + noDeps.Dependencies.DirectCount = 0 + noDeps.Dependencies.TransitiveCount = 9 + + // gen_golden.py's no_api: an empty api_surface. + noAPI := loadRecon(t, "recon_fixture") + noAPI.Architecture.APISurface = []schemas.APIEndpoint{} + + cases := map[string]func(ctx context.Context, app appx.Harnesser) (schemas.HuntResult, error){ + "crypto_no_usage": func(ctx context.Context, app appx.Harnesser) (schemas.HuntResult, error) { + return RunCryptoHunter(ctx, app, fixtureRepo, noCrypto, 30) + }, + "crypto_empty_recon": func(ctx context.Context, app appx.Harnesser) (schemas.HuntResult, error) { + return RunCryptoHunter(ctx, app, fixtureRepo, emptyRecon(), 30) + }, + "supply_chain_no_direct_deps": func(ctx context.Context, app appx.Harnesser) (schemas.HuntResult, error) { + return RunSupplyChainHunter(ctx, app, fixtureRepo, noDeps, 30) + }, + "api_security_no_surface": func(ctx context.Context, app appx.Harnesser) (schemas.HuntResult, error) { + return RunAPISecurityHunter(ctx, app, fixtureRepo, noAPI, 30) + }, + "business_logic_quick": func(ctx context.Context, app appx.Harnesser) (schemas.HuntResult, error) { + return RunBusinessLogicHunter(ctx, app, fixtureRepo, rich, "quick", 30, "") + }, + "logic_quick": func(ctx context.Context, app appx.Harnesser) (schemas.HuntResult, error) { + return RunLogicHunter(ctx, app, fixtureRepo, rich, "quick", 30, "") + }, + } + if len(cases) != len(golden) { + t.Fatalf("golden has %d skip cases, the test covers %d", len(golden), len(cases)) + } + + for name, run := range cases { + name, run := name, run + t.Run(name, func(t *testing.T) { + want := golden[name] + fake := newHuntFake(cannedLocations(), cannedEnriched()) + got, err := run(context.Background(), fake) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if len(fake.Harnesses) != want.HarnessCalls { + t.Errorf("harness calls = %d, want %d", len(fake.Harnesses), want.HarnessCalls) + } + gotTree := scrubIDs(jsonTree(t, got)) + wantTree := scrubIDs(jsonTree(t, want.Want)) + if !reflect.DeepEqual(gotTree, wantTree) { + t.Fatalf("%s HuntResult mismatch%s", name, diffJSON(gotTree, wantTree)) + } + }) + } +} + +// TestReconContextBlocksMatchPython pins the two inline JSON context builders +// (which are hunt's own code, not context.py's) against json.dumps(..., indent=2) +// of the same model_dump, for a rich and an all-defaults recon. +func TestReconContextBlocksMatchPython(t *testing.T) { + rich := loadRecon(t, "recon_fixture") + empty := emptyRecon() + + // dos, ssrf and xss declare three byte-identical copies of the helper. + for _, name := range []string{"dos", "ssrf", "xss"} { + assertTextEqual(t, "recon_context_block_"+name, + entryFlowContextBlock(rich), goldenText(t, "recon_context_block_"+name)) + } + assertTextEqual(t, "recon_context_block_dos_empty", + entryFlowContextBlock(empty), goldenText(t, "recon_context_block_dos_empty")) + + assertTextEqual(t, "recon_context_block_business_logic", + businessLogicContextBlock(rich), goldenText(t, "recon_context_block_business_logic")) + assertTextEqual(t, "recon_context_block_business_logic_empty", + businessLogicContextBlock(empty), goldenText(t, "recon_context_block_business_logic_empty")) +} + +// TestReconContextBlockTruncation states the slice limits, which differ between +// the two builders (10 entry points / 10 flows vs 15 / 20 endpoints / 20 flows). +// +// The rich fixture holds 17 entry points, 20 endpoints and 20 flows — enough +// for the entry-point limits but exactly at the endpoint/flow ceilings — so the +// counts are asserted against a synthetic recon carrying 30 of each. +func TestReconContextBlockTruncation(t *testing.T) { + recon := emptyRecon() + for i := 0; i < 30; i++ { + recon.Architecture.EntryPoints = append(recon.Architecture.EntryPoints, + schemas.EntryPoint{Kind: "http", Identifier: "e", FilePath: "f.py", Line: i}) + recon.Architecture.APISurface = append(recon.Architecture.APISurface, + schemas.APIEndpoint{Method: "GET", Path: "/p", Handler: "h", FilePath: "f.py", Line: i}) + recon.DataFlows.Flows = append(recon.DataFlows.Flows, + schemas.DataFlow{Source: "s", Sink: "k", Path: []schemas.ReconDataFlowStep{}, Files: []string{}}) + } + + entryFlow := entryFlowContextBlock(recon) + if got := strings.Count(entryFlow, `"kind":`); got != 10 { + t.Errorf("entryFlowContextBlock kept %d entry points, want 10", got) + } + if got := strings.Count(entryFlow, `"sanitized":`); got != 10 { + t.Errorf("entryFlowContextBlock kept %d data flows, want 10", got) + } + if strings.Contains(entryFlow, `"api_surface"`) { + t.Error("entryFlowContextBlock must not carry api_surface") + } + + businessLogic := businessLogicContextBlock(recon) + if got := strings.Count(businessLogic, `"kind":`); got != 15 { + t.Errorf("businessLogicContextBlock kept %d entry points, want 15", got) + } + if got := strings.Count(businessLogic, `"handler":`); got != 20 { + t.Errorf("businessLogicContextBlock kept %d api endpoints, want 20", got) + } + if got := strings.Count(businessLogic, `"sanitized":`); got != 20 { + t.Errorf("businessLogicContextBlock kept %d data flows, want 20", got) + } + if !strings.Contains(businessLogic, `"auth_details":`) { + t.Error("businessLogicContextBlock must carry auth_details") + } +} + +// --------------------------------------------------------------------------- +// crypto +// --------------------------------------------------------------------------- + +func strptr(s string) *string { return &s } +func boolptr(b bool) *bool { return &b } + +// cryptoUsageCases mirrors gen_golden.py's crypto usage-context table. A nil +// entry is Python's None usage_context. +func cryptoUsageCases() map[string][]*string { + return map[string][]*string{ + "mixed": { + strptr("password hashing"), + strptr("file integrity checksum"), + strptr("etag generation for cache"), + strptr("TLS session key derivation"), + strptr("unrelated purpose"), + nil, + strptr(""), + }, + "none": {strptr("unrelated purpose"), strptr("widget rendering")}, + "both_terms": {strptr("auth token cache")}, + } +} + +func cryptoUsageRecon(contexts []*string) schemas.ReconResult { + recon := emptyRecon() + usage := make([]schemas.CryptoUsage, 0, len(contexts)) + for _, c := range contexts { + usage = append(usage, schemas.CryptoUsage{Algorithm: "MD5", UsageContext: c, IsWeak: boolptr(true)}) + } + recon.SecurityContext.CryptoUsage = usage + return recon +} + +// TestCryptoUsagePartitionMatchesPython pins _usage_contexts, +// _filter_contexts_by_terms and should_run_crypto_hunter, plus the prompt each +// partition produces. +func TestCryptoUsagePartitionMatchesPython(t *testing.T) { + var golden map[string]struct { + UsageContexts []string `json:"usage_contexts"` + SecurityCritical []string `json:"security_critical"` + NonSecurity []string `json:"non_security"` + ShouldRun bool `json:"should_run"` + } + goldenJSON(t, "crypto_usage_partition", &golden) + + for name, contexts := range cryptoUsageCases() { + name, contexts := name, contexts + t.Run(name, func(t *testing.T) { + want, ok := golden[name] + if !ok { + t.Fatalf("no crypto_usage_partition golden for %s", name) + } + recon := cryptoUsageRecon(contexts) + + if got := cryptoUsageContexts(recon); !reflect.DeepEqual(got, want.UsageContexts) { + t.Errorf("usage contexts = %#v, want %#v", got, want.UsageContexts) + } + gotCritical := filterContextsByTerms(cryptoUsageContexts(recon), cryptoSecurityCriticalTerms) + if !reflect.DeepEqual(gotCritical, want.SecurityCritical) { + t.Errorf("security-critical = %#v, want %#v", gotCritical, want.SecurityCritical) + } + gotNon := filterContextsByTerms(cryptoUsageContexts(recon), cryptoNonSecurityTerms) + if !reflect.DeepEqual(gotNon, want.NonSecurity) { + t.Errorf("non-security = %#v, want %#v", gotNon, want.NonSecurity) + } + if got := ShouldRunCryptoHunter(recon); got != want.ShouldRun { + t.Errorf("should_run = %v, want %v", got, want.ShouldRun) + } + + prompt, _ := cryptoScanPrompt(fixtureRepo, recon, "30") + assertTextEqual(t, "crypto_prompt_"+name, ScanPrompt(prompt), goldenText(t, "crypto_prompt_"+name)) + }) + } +} + +// TestCryptoTermTables pins the two substring tables verbatim. +func TestCryptoTermTables(t *testing.T) { + var golden struct { + SecurityCritical []string `json:"security_critical"` + NonSecurity []string `json:"non_security"` + } + goldenJSON(t, "crypto_term_tables", &golden) + if !reflect.DeepEqual(cryptoSecurityCriticalTerms, golden.SecurityCritical) { + t.Errorf("_SECURITY_CRITICAL_TERMS = %#v, want %#v", cryptoSecurityCriticalTerms, golden.SecurityCritical) + } + if !reflect.DeepEqual(cryptoNonSecurityTerms, golden.NonSecurity) { + t.Errorf("_NON_SECURITY_TERMS = %#v, want %#v", cryptoNonSecurityTerms, golden.NonSecurity) + } +} + +// TestCryptoCandidateListFallback pins the `else "none"` spelling both +// candidate lines use when nothing matched. +func TestCryptoCandidateListFallback(t *testing.T) { + if got := cryptoCandidateList(nil); got != "none" { + t.Errorf("empty candidates render as %q, want %q", got, "none") + } + if got := cryptoCandidateList([]string{"a", "b"}); got != "a, b" { + t.Errorf("candidates render as %q, want %q", got, "a, b") + } +} + +// --------------------------------------------------------------------------- +// auth / business_logic knobs +// --------------------------------------------------------------------------- + +// TestAuthDepthLabelMatchesPython pins _depth_label, including the strip() that +// makes it differ from config.NormalizeDepth. +func TestAuthDepthLabelMatchesPython(t *testing.T) { + var golden map[string]string + goldenJSON(t, "auth_depth_label", &golden) + if len(golden) == 0 { + t.Fatal("auth_depth_label golden is empty") + } + for in, want := range golden { + if got := authDepthLabel(in); got != want { + t.Errorf("authDepthLabel(%q) = %q, want %q", in, got, want) + } + } + // The strip() is what separates this from the lenient normalizer used + // everywhere else in the pipeline. + if authDepthLabel(" THOROUGH ") != "thorough" { + t.Error("authDepthLabel must trim before matching") + } +} + +// TestAuthTargetCWEs pins the CWE list joined into {{TARGET_CWES}}. +func TestAuthTargetCWEs(t *testing.T) { + var golden []string + goldenJSON(t, "auth_target_cwes", &golden) + if !reflect.DeepEqual(authTargetCWEs, golden) { + t.Fatalf("_TARGET_CWES = %#v, want %#v", authTargetCWEs, golden) + } +} + +// TestBusinessLogicEnabledMatchesPython pins the depth gate. +func TestBusinessLogicEnabledMatchesPython(t *testing.T) { + var golden map[string]bool + goldenJSON(t, "business_logic_enabled", &golden) + if len(golden) == 0 { + t.Fatal("business_logic_enabled golden is empty") + } + for in, want := range golden { + if got := IsBusinessLogicHunterEnabled(in); got != want { + t.Errorf("IsBusinessLogicHunterEnabled(%q) = %v, want %v", in, got, want) + } + if got := IsLogicHunterEnabled(in); got != want { + t.Errorf("IsLogicHunterEnabled(%q) = %v, want %v", in, got, want) + } + } +} + +// TestBusinessLogicDepthPromptMatchesPython pins the optional +// "- Additional depth guidance: ..." tail, which only a deliberate caller can +// trigger (the argument cascade never delivers it). +func TestBusinessLogicDepthPromptMatchesPython(t *testing.T) { + recon := loadRecon(t, "recon_small") + got, _ := businessLogicScanPrompt(fixtureRepo, recon, "thorough", "30", ThoroughDepthPrompt) + assertTextEqual(t, "business_logic_prompt_with_depth_prompt", ScanPrompt(got), + goldenText(t, "business_logic_prompt_with_depth_prompt")) + + if !strings.HasSuffix(got, "\n- Additional depth guidance: "+ThoroughDepthPrompt) { + t.Fatal("the depth-guidance tail must be appended last, on its own line") + } + without, _ := businessLogicScanPrompt(fixtureRepo, recon, "thorough", "30", "") + if strings.Contains(without, "Additional depth guidance") { + t.Fatal("an empty depth_prompt must add nothing") + } +} + +// --------------------------------------------------------------------------- +// direct ports of tests/test_hunt_crypto.py +// --------------------------------------------------------------------------- + +// cryptoTestRecon ports tests/test_hunt_crypto.py::_recon_with_crypto_usage — +// an otherwise-default recon carrying one non-security and one security-critical +// crypto usage. +func cryptoTestRecon() schemas.ReconResult { + recon := emptyRecon() + recon.SecurityContext.AuthModel = "session" + recon.SecurityContext.AuthDetails = "cookie" + recon.SecurityContext.CryptoUsage = []schemas.CryptoUsage{ + {Algorithm: "MD5", UsageContext: strptr("file integrity checksum"), IsWeak: boolptr(true)}, + {Algorithm: "SHA1", UsageContext: strptr("password hashing"), IsWeak: boolptr(true)}, + } + return recon +} + +// TestCryptoHunterPromptIncludesContextAwareRiskGating ports +// tests/test_hunt_crypto.py::test_crypto_hunter_prompt_includes_context_aware_risk_gating. +func TestCryptoHunterPromptIncludesContextAwareRiskGating(t *testing.T) { + fake := newHuntFake(nil, cannedEnriched()) + if _, err := RunCryptoHunter(context.Background(), fake, ".", cryptoTestRecon(), 30); err != nil { + t.Fatalf("RunCryptoHunter: %v", err) + } + prompt := fake.onlyScanPrompt(t) + + for _, want := range []string{ + "- Focus CWEs: CWE-326, CWE-327, CWE-328, CWE-330, CWE-916, CWE-259, CWE-321, CWE-798", + "Prioritize weak crypto findings only when used in security-sensitive contexts", + "file integrity checksum", + "password hashing", + } { + if !strings.Contains(prompt, want) { + t.Errorf("crypto prompt is missing %q", want) + } + } + // And the gating itself: the checksum context is a NON-security candidate + // while the password one is security-critical. + if !strings.Contains(prompt, "- Security-critical usage candidates: password hashing\n") { + t.Error("security-critical candidates line is wrong") + } + if !strings.Contains(prompt, "- Non-security usage candidates: file integrity checksum\n") { + t.Error("non-security candidates line is wrong") + } +} + +// TestCryptoHunterSkipsWhenReconHasNoCryptoUsage ports +// tests/test_hunt_crypto.py::test_crypto_hunter_skips_when_recon_has_no_crypto_usage: +// the bare HuntResult() shape and, crucially, NO harness call at all (the +// Python test asserts `app.prompt == ""`). +func TestCryptoHunterSkipsWhenReconHasNoCryptoUsage(t *testing.T) { + recon := emptyRecon() + recon.SecurityContext.CryptoUsage = []schemas.CryptoUsage{} + + fake := newHuntFake(cannedLocations(), cannedEnriched()) + got, err := RunCryptoHunter(context.Background(), fake, ".", recon, 30) + if err != nil { + t.Fatalf("RunCryptoHunter: %v", err) + } + if !reflect.DeepEqual(got, schemas.NewHuntResult()) { + t.Errorf("result = %+v, want the bare HuntResult()", got) + } + if len(fake.Harnesses) != 0 { + t.Errorf("made %d harness calls, want 0", len(fake.Harnesses)) + } +} + +// TestHuntPromptTemplatesAreEmbedded guards every template this package loads +// with prompts.MustLoad — which panics on a missing name — plus logic.txt, +// which ships in the Python tree and is deliberately loaded by nobody +// (logic.py forwards to business_logic.py without reading it). +func TestHuntPromptTemplatesAreEmbedded(t *testing.T) { + loaded := []string{ + scanPromptPath, enrichPromptPath, + injectionPromptPath, xssPromptPath, dosPromptPath, ssrfPromptPath, authPromptPath, + cryptoPromptPath, businessLogicPromptPath, dataExposurePromptPath, + supplyChainPromptPath, configSecretsPromptPath, apiSecurityPromptPath, + } + for _, rel := range loaded { + if body, err := prompts.Load(rel); err != nil || body == "" { + t.Errorf("prompts.Load(%q) = %d bytes, err %v", rel, len(body), err) + } + } + if body, err := prompts.Load("hunt/logic.txt"); err != nil || body == "" { + t.Errorf("hunt/logic.txt must stay embedded even though no code path reads it: %v", err) + } +} diff --git a/go/internal/agents/hunt/injection.go b/go/internal/agents/hunt/injection.go new file mode 100644 index 0000000..3104e1e --- /dev/null +++ b/go/internal/agents/hunt/injection.go @@ -0,0 +1,72 @@ +package hunt + +// Ports src/sec_af/agents/hunt/injection.py. + +import ( + "context" + "strconv" + "strings" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/recontext" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +const injectionPromptPath = "hunt/injection.txt" + +// injectionScanPrompt builds the exact prompt run_injection_hunter sends, and +// returns the recon context it embedded — the hunter reuses that same string +// for the enrichment step, so it is computed once here. +// +// earlyStop is the pre-rendered text for the early-stop rule. Python +// interpolates `max_files_without_signal` there with an f-string and never +// checks its type; see the package doc for why that matters. +func injectionScanPrompt(repoPath string, recon schemas.ReconResult, depth, earlyStop string) (scanPrompt, reconContext string) { + reconContext = recontext.ReconContextForInjection(recon) + template := prompts.MustLoad(injectionPromptPath) + template = strings.ReplaceAll(template, "{{RECON_CONTEXT}}", reconContext) + template = strings.ReplaceAll(template, "{{LANGUAGE_HINTS}}", recontext.LanguageHintsForContext(recon)) + template = strings.ReplaceAll(template, "{{FRAMEWORK_HINTS}}", recontext.FrameworkHintsForContext(recon)) + + scanPrompt = template + + "\n\nCONTEXT:\n" + + "- Repository path: " + repoPath + "\n" + + "- Depth profile: " + depth + "\n" + + "- Early stop rule: if you inspect " + + earlyStop + " files without credible signal, " + + "stop and return empty findings.\n" + + "- Focus on RECON entry points and data flows as primary source-to-sink paths.\n" + + "- Explore the codebase, trace data flows from sources to sinks, and identify injection points.\n" + + "- Take multiple turns to build findings incrementally and write final JSON only when complete." + return scanPrompt, reconContext +} + +// runInjectionHunter is the shared body; earlyStop is pre-rendered text so the +// hunt table can pass the value Python's argument cascade actually delivers. +func runInjectionHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, depth, earlyStop string, +) (schemas.HuntResult, error) { + scanPrompt, reconContext := injectionScanPrompt(repoPath, recon, depth, earlyStop) + return runHunterBody(ctx, app, repoPath, hunterSpec{ + ScanPrompt: scanPrompt, + ReconContext: reconContext, + FindingType: "sast", + Strategy: "injection", + // Python parity: `return HuntResult()` — strategies_run keeps its [] + // default rather than naming the strategy that just ran. + EmptyStrategiesRun: nil, + }) +} + +// RunInjectionHunter ports injection.py run_injection_hunter: +// +// async def run_injection_hunter(app, repo_path, recon_result, depth, +// max_files_without_signal: int = 30) -> HuntResult +func RunInjectionHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, depth string, maxFilesWithoutSignal int, +) (schemas.HuntResult, error) { + return runInjectionHunter(ctx, app, repoPath, recon, depth, strconv.Itoa(maxFilesWithoutSignal)) +} diff --git a/go/internal/agents/hunt/logic.go b/go/internal/agents/hunt/logic.go new file mode 100644 index 0000000..c710e83 --- /dev/null +++ b/go/internal/agents/hunt/logic.go @@ -0,0 +1,46 @@ +package hunt + +// Ports src/sec_af/agents/hunt/logic.py — a thin alias module. +// +// logic.py exists only so the LOGIC_BUGS spelling of the strategy has a hunter +// of its own; both of its functions forward verbatim to business_logic.py, and +// nothing in the module reads its own prompts/hunt/logic.txt template (that +// file is loaded by no code path in the repository). logic.py also imports +// extract_harness_result and never calls it. +// +// It is NOT in _STRATEGY_RUNNERS: schemas.HuntStrategy.LOGIC_BUGS aliases +// BUSINESS_LOGIC, so the table's business_logic entry already covers it and +// __init__.py never reaches run_logic_hunter. src/sec_af/reasoners/hunt.py +// registers it as its own reasoner, which is where this entry point is used. + +import ( + "context" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// IsLogicHunterEnabled ports logic.py is_logic_hunter_enabled — a straight +// delegation to is_business_logic_hunter_enabled. +func IsLogicHunterEnabled(depth string) bool { + return IsBusinessLogicHunterEnabled(depth) +} + +// RunLogicHunter ports logic.py run_logic_hunter: +// +// async def run_logic_hunter(app, repo_path, recon, depth, +// max_files_without_signal=30, depth_prompt="") -> HuntResult: +// return await run_business_logic_hunter( +// app=app, repo_path=repo_path, recon_result=recon, +// depth=depth, max_files_without_signal=max_files_without_signal, +// depth_prompt=depth_prompt) +// +// Every argument is forwarded unchanged, so the prompt, the findings and the +// strategies_run value ("business_logic", never "logic") are byte-identical to +// RunBusinessLogicHunter's for the same inputs. +func RunLogicHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, depth string, maxFilesWithoutSignal int, depthPrompt string, +) (schemas.HuntResult, error) { + return RunBusinessLogicHunter(ctx, app, repoPath, recon, depth, maxFilesWithoutSignal, depthPrompt) +} diff --git a/go/internal/agents/hunt/scan_enrich.go b/go/internal/agents/hunt/scan_enrich.go new file mode 100644 index 0000000..5d74af1 --- /dev/null +++ b/go/internal/agents/hunt/scan_enrich.go @@ -0,0 +1,387 @@ +package hunt + +// Ports src/sec_af/agents/hunt/_scan_enrich.py — the two-step scan/enrich +// harness pipeline and the RawFinding assembler that all twelve hunters share. + +import ( + "context" + "os" + "strconv" + "strings" + + "golang.org/x/sync/errgroup" + "golang.org/x/sync/semaphore" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/harnessx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// The two shared templates, as the embed-relative names internal/prompts uses. +// Python spells them as module-level Paths computed off __file__: +// +// PROMPTS_DIR = Path(__file__).resolve().parents[2] / "prompts" / "hunt" +// SCAN_PROMPT_PATH = PROMPTS_DIR / "scan_locations.txt" +// ENRICH_PROMPT_PATH = PROMPTS_DIR / "enrich_finding.txt" +const ( + scanPromptPath = "hunt/scan_locations.txt" + enrichPromptPath = "hunt/enrich_finding.txt" +) + +// The agent names extract_harness_result prints and embeds in its errors. +const ( + scanExtractName = "Hunt location scanner" + enrichExtractName = "Hunt finding enricher" +) + +// DefaultEnrichConcurrency ports enrich_locations_parallel's +// `max_concurrent: int = 5` default. +const DefaultEnrichConcurrency = 5 + +// toFindingType ports _to_finding_type: +// +// try: return FindingType(value.lower()) +// except ValueError: return FindingType.SAST +// +// Python parity: the lower() happens BEFORE the lookup, so "SAST" resolves and +// "logic " (trailing space) does not. +func toFindingType(value string) schemas.FindingType { + if v, err := schemas.ParseFindingType(strings.ToLower(value)); err == nil { + return v + } + return schemas.FindingTypeSast +} + +// toSeverity ports _to_severity — same shape, MEDIUM fallback. +func toSeverity(value string) schemas.Severity { + if v, err := schemas.ParseSeverity(strings.ToLower(value)); err == nil { + return v + } + return schemas.SeverityMedium +} + +// toConfidence ports _to_confidence — same shape, MEDIUM fallback. +func toConfidence(value string) schemas.Confidence { + if v, err := schemas.ParseConfidence(strings.ToLower(value)); err == nil { + return v + } + return schemas.ConfidenceMedium +} + +// ScanPrompt builds the Step 1 prompt: the shared scan_locations template with +// the hunter's own prompt substituted for {{HUNTER_PROMPT}}. +// +// Extracted as a pure function so a golden test can compare it byte-for-byte +// against the Python builder (testdata/golden/scan_locations_prompt.txt). +func ScanPrompt(hunterPrompt string) string { + return strings.ReplaceAll(prompts.MustLoad(scanPromptPath), "{{HUNTER_PROMPT}}", hunterPrompt) +} + +// ScanLocations ports _scan_enrich.py scan_locations: +// +// async def scan_locations(app, prompt, repo_path) -> list[VulnLocation]: +// scan_template = SCAN_PROMPT_PATH.read_text(encoding="utf-8") +// scan_prompt = scan_template.replace("{{HUNTER_PROMPT}}", prompt) +// harness_cwd = tempfile.mkdtemp(prefix="secaf-hunt-scan-") +// try: +// result = await app.harness(prompt=scan_prompt, schema=ScanLocationsResult, +// cwd=harness_cwd, project_dir=repo_path) +// parsed = extract_harness_result(result, ScanLocationsResult, "Hunt location scanner") +// return parsed.locations +// finally: +// shutil.rmtree(harness_cwd, ignore_errors=True) +// +// Python parity: `str.replace` replaces EVERY occurrence, so a template with +// two {{HUNTER_PROMPT}} markers would get both filled — strings.ReplaceAll, not +// strings.Replace(…, 1). The harness runs with Cwd on a private scratch dir and +// ProjectDir on the repository, so the coding agent explores the repo but +// writes its JSON output outside it; `shutil.rmtree(..., ignore_errors=True)` +// maps to a deferred os.RemoveAll whose error is deliberately dropped. +func ScanLocations(ctx context.Context, app appx.Harnesser, prompt, repoPath string) ([]schemas.VulnLocation, error) { + scanPrompt := ScanPrompt(prompt) + + harnessCwd, err := os.MkdirTemp("", "secaf-hunt-scan-") + if err != nil { + return nil, err + } + defer os.RemoveAll(harnessCwd) + + parsed, err := harnessx.RunExtract[schemas.ScanLocationsResult]( + ctx, app, scanPrompt, + harness.Options{Cwd: harnessCwd, ProjectDir: repoPath}, + scanExtractName, + ) + if err != nil { + return nil, err + } + return parsed.Locations, nil +} + +// EnrichPrompt builds the Step 2 prompt for one location. +// +// Ports the seven chained `.replace(...)` calls in enrich_location. Order is +// irrelevant to the output here (no substituted value contains another marker +// in practice) but is kept identical to Python's anyway. +func EnrichPrompt(location schemas.VulnLocation, findingType, strategy, reconContext string) string { + prompt := prompts.MustLoad(enrichPromptPath) + prompt = strings.ReplaceAll(prompt, "{{FINDING_TYPE}}", findingType) + prompt = strings.ReplaceAll(prompt, "{{STRATEGY}}", strategy) + prompt = strings.ReplaceAll(prompt, "{{RECON_CONTEXT}}", reconContext) + prompt = strings.ReplaceAll(prompt, "{{FILE_PATH}}", location.FilePath) + // Python interpolates `str(location.start_line)`, an int, so no thousands + // separators and a leading '-' for negatives — strconv.Itoa matches. + prompt = strings.ReplaceAll(prompt, "{{START_LINE}}", strconv.Itoa(location.StartLine)) + prompt = strings.ReplaceAll(prompt, "{{CODE_SNIPPET}}", location.CodeSnippet) + prompt = strings.ReplaceAll(prompt, "{{PATTERN_TYPE}}", location.PatternType) + return prompt +} + +// EnrichLocation ports _scan_enrich.py enrich_location. +// +// Python parity: the temp-dir prefix embeds the strategy — +// `tempfile.mkdtemp(prefix=f"secaf-hunt-enrich-{strategy}-")` — so a strategy +// containing a path separator would change where the scratch dir lands. Go's +// os.MkdirTemp REJECTS a pattern containing a separator (it returns +// ErrPatternHasSeparator) where Python's mkdtemp would happily build a nested +// path; every strategy this port passes is a bare HuntStrategy value, so the +// two agree in practice. The error is returned rather than swallowed. +func EnrichLocation( + ctx context.Context, + app appx.Harnesser, + location schemas.VulnLocation, + findingType, strategy, reconContext, repoPath string, +) (schemas.EnrichedFinding, error) { + enrichPrompt := EnrichPrompt(location, findingType, strategy, reconContext) + + harnessCwd, err := os.MkdirTemp("", "secaf-hunt-enrich-"+strategy+"-") + if err != nil { + return schemas.EnrichedFinding{}, err + } + defer os.RemoveAll(harnessCwd) + + return harnessx.RunExtract[schemas.EnrichedFinding]( + ctx, app, enrichPrompt, + harness.Options{Cwd: harnessCwd, ProjectDir: repoPath}, + enrichExtractName, + ) +} + +// EnrichLocationsParallel ports _scan_enrich.py enrich_locations_parallel: +// +// if not locations: return [] +// semaphore = asyncio.Semaphore(max(1, max_concurrent)) +// async def _run(location): async with semaphore: return await enrich_location(...) +// return await asyncio.gather(*[_run(location) for location in locations]) +// +// Concurrency parity: +// +// - the bound is `max(1, max_concurrent)`, so a zero or negative +// max_concurrent still admits one enrichment at a time rather than +// deadlocking; +// - results are index-aligned with locations. asyncio.gather preserves the +// input order regardless of completion order, so the port writes into a +// pre-sized slice rather than appending; +// - errgroup.Group is used WITHOUT WithContext so a failing enrichment does +// not cancel its siblings — matching gather(return_exceptions=False), which +// never cancels the other awaitables either. Wait() returns the first error +// by completion time, the same one gather surfaces. +// - DIFFERENCE: Wait() blocks until every goroutine has finished, while +// `await gather(...)` resumes the caller as soon as the first exception +// fires and leaves the rest running detached. The returned value is +// identical; only the moment of return differs, and every caller's next act +// on error is to propagate it. +// +// On error the returned slice is nil: Python's caller never reads the partial +// results either, because the exception propagates out of the `await`. +// +// A cancelled ctx surfaces through semaphore.Acquire as ctx.Err(), which is the +// closest Go analogue of the CancelledError asyncio would raise inside the +// gathered coroutines. +func EnrichLocationsParallel( + ctx context.Context, + app appx.Harnesser, + locations []schemas.VulnLocation, + findingType, strategy, reconContext, repoPath string, + maxConcurrent int, +) ([]schemas.EnrichedFinding, error) { + if len(locations) == 0 { + // Python parity: the early return is `[]`, not None. + return []schemas.EnrichedFinding{}, nil + } + + limit := maxConcurrent + if limit < 1 { + limit = 1 + } + sem := semaphore.NewWeighted(int64(limit)) + + out := make([]schemas.EnrichedFinding, len(locations)) + var g errgroup.Group + for i := range locations { + i := i + g.Go(func() error { + if err := sem.Acquire(ctx, 1); err != nil { + return err + } + defer sem.Release(1) + enriched, err := EnrichLocation( + ctx, app, locations[i], findingType, strategy, reconContext, repoPath, + ) + if err != nil { + return err + } + out[i] = enriched + return nil + }) + } + if err := g.Wait(); err != nil { + return nil, err + } + return out, nil +} + +// AssembleFinding ports _scan_enrich.py assemble_finding: +// +// snippet_line_count = max(1, location.code_snippet.count("\n") + 1) +// data_flow = None +// summary = enriched.data_flow_summary.strip() +// if summary: +// data_flow = [DataFlowStep(file_path=location.file_path, line=location.start_line, +// component=strategy, operation=summary)] +// return RawFinding(..., end_line=location.start_line + snippet_line_count - 1, ...) +// +// Python parity notes: +// +// - END LINE. `count("\n") + 1` counts LINES, so a snippet with no newline is +// one line and end_line == start_line; a snippet with a TRAILING newline +// ("one\ntwo\n") counts as three, so end_line overshoots by one. That is +// reproduced, not corrected. The `max(1, ...)` can never bite (count is +// never negative) and is kept only to mirror the source. +// - DATA FLOW. An empty-after-strip summary leaves data_flow at None, which +// model_dump()s as `null` — hence a nil slice here, not an empty one. The +// step's `component` is the STRATEGY, not a code component, and its `line` +// is the location's start_line. +// - `strip()` vs strings.TrimSpace: Python strips every character whose +// str.isspace() is true, which includes U+001C..U+001F (the file/group/ +// record/unit separators); Go's unicode.IsSpace does not. Both strip the +// ASCII whitespace, NEL and NBSP that real LLM output contains. +// - CWE NAME. Python passes `cwe_name=enriched.cwe_id` — the ID, not a name. +// Deliberate duplication, reproduced. +// - The finding's `id` and `fingerprint` are pydantic +// `default_factory=lambda: str(uuid4())`, so both are FRESH RANDOM UUIDs +// here; the fingerprint is overwritten with a real content fingerprint +// later, by dedup. schemas.NewRawFinding mints both the same way. +func AssembleFinding( + location schemas.VulnLocation, + enriched schemas.EnrichedFinding, + findingType, strategy string, +) schemas.RawFinding { + snippetLineCount := strings.Count(location.CodeSnippet, "\n") + 1 + if snippetLineCount < 1 { + snippetLineCount = 1 + } + + var dataFlow []schemas.ReconDataFlowStep + if summary := strings.TrimSpace(enriched.DataFlowSummary); summary != "" { + dataFlow = []schemas.ReconDataFlowStep{{ + FilePath: location.FilePath, + Line: location.StartLine, + Component: strategy, + Operation: summary, + }} + } + + finding := schemas.NewRawFinding() + finding.HunterStrategy = strategy + finding.Title = enriched.Title + finding.Description = enriched.Description + finding.FindingType = toFindingType(findingType) + finding.CweID = enriched.CweID + finding.CweName = enriched.CweID + finding.FilePath = location.FilePath + finding.StartLine = location.StartLine + finding.EndLine = location.StartLine + snippetLineCount - 1 + finding.CodeSnippet = location.CodeSnippet + finding.EstimatedSeverity = toSeverity(enriched.Severity) + finding.Confidence = toConfidence(enriched.Confidence) + finding.DataFlow = dataFlow + return finding +} + +// hunterSpec is everything the shared hunter body needs after a hunter module +// has built its own scan prompt. Every hunter's tail is byte-identical apart +// from these five values, so it is written once here rather than eleven times. +type hunterSpec struct { + // ScanPrompt is the hunter-specific prompt handed to ScanLocations. + ScanPrompt string + // ReconContext is what the enrichment step embeds; it is the SAME string + // the hunter substituted into its own template, not a re-derivation. + ReconContext string + // FindingType is the literal Python passes as `finding_type=` ("sast", + // "sca", "config", "api", "logic"). + FindingType string + // Strategy is the literal Python passes as `strategy=`. + Strategy string + // EmptyStrategiesRun is `strategies_run` on the "scanner found nothing" + // early return. Six hunters return a bare HuntResult() there (so the field + // keeps its `[]` default) and five return HuntResult(strategies_run=[s]); + // nil means the bare form. + EmptyStrategiesRun []string +} + +// runHunterBody is the shared tail of every hunter: scan, short-circuit on an +// empty location list, enrich in parallel, zip and assemble, and report the +// counters. +// +// Python parity: the zip() over (locations, enriched_findings) is safe because +// enrich_locations_parallel returns exactly one result per location, in order — +// a shorter enriched list would silently TRUNCATE the findings in Python, which +// is why the Go port keeps the two index-aligned rather than appending. +func runHunterBody( + ctx context.Context, + app appx.Harnesser, + repoPath string, + spec hunterSpec, +) (schemas.HuntResult, error) { + locations, err := ScanLocations(ctx, app, spec.ScanPrompt, repoPath) + if err != nil { + return schemas.HuntResult{}, err + } + if len(locations) == 0 { + empty := schemas.NewHuntResult() + if spec.EmptyStrategiesRun != nil { + empty.StrategiesRun = spec.EmptyStrategiesRun + } + return empty, nil + } + + enrichedFindings, err := EnrichLocationsParallel( + ctx, app, locations, spec.FindingType, spec.Strategy, + spec.ReconContext, repoPath, DefaultEnrichConcurrency, + ) + if err != nil { + return schemas.HuntResult{}, err + } + + n := len(locations) + if len(enrichedFindings) < n { + n = len(enrichedFindings) + } + findings := make([]schemas.RawFinding, 0, n) + for i := 0; i < n; i++ { + findings = append(findings, AssembleFinding( + locations[i], enrichedFindings[i], spec.FindingType, spec.Strategy, + )) + } + + result := schemas.NewHuntResult() + result.Findings = findings + result.TotalRaw = len(findings) + result.DeduplicatedCount = len(findings) + result.ChainCount = 0 + result.StrategiesRun = []string{spec.Strategy} + return result, nil +} diff --git a/go/internal/agents/hunt/scan_enrich_test.go b/go/internal/agents/hunt/scan_enrich_test.go new file mode 100644 index 0000000..8e35ac1 --- /dev/null +++ b/go/internal/agents/hunt/scan_enrich_test.go @@ -0,0 +1,421 @@ +package hunt + +// Tests for src/sec_af/agents/hunt/_scan_enrich.py. +// +// Validation contract (behaviour, not implementation): +// +// - the Step 1 prompt is the scan_locations template with the hunter's prompt +// substituted, byte-for-byte; +// - the Step 2 prompt is the enrich_finding template with all seven markers +// substituted, byte-for-byte; +// - both harness calls run with Cwd on a fresh scratch directory that is +// removed afterwards and ProjectDir on the repository; +// - a harness that reports an error surfaces as +// " harness error: " and no findings; +// - enrichment runs at most max(1, maxConcurrent) at a time, returns one +// result per location IN LOCATION ORDER, and an empty location list makes +// no harness call at all; +// - AssembleFinding derives end_line from the snippet's line count, drops a +// whitespace-only data-flow summary, coerces unknown severity/confidence/ +// finding_type to their documented fallbacks, and copies cwe_id into +// cwe_name. + +import ( + "context" + "os" + "reflect" + "strings" + "sync/atomic" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// TestScanPromptMatchesPython pins ScanPrompt against scan_locations() run in +// Python over the same hunter prompt. +func TestScanPromptMatchesPython(t *testing.T) { + var input struct { + HunterPrompt string `json:"hunter_prompt"` + } + goldenJSON(t, "scan_locations_input", &input) + assertTextEqual(t, "scan_locations_prompt", ScanPrompt(input.HunterPrompt), goldenText(t, "scan_locations_prompt")) +} + +// TestEnrichPromptMatchesPython pins EnrichPrompt against enrich_location() run +// in Python over the same location and context. +func TestEnrichPromptMatchesPython(t *testing.T) { + var input struct { + Location schemas.VulnLocation `json:"location"` + FindingType string `json:"finding_type"` + Strategy string `json:"strategy"` + ReconContext string `json:"recon_context"` + } + goldenJSON(t, "enrich_location_input", &input) + got := EnrichPrompt(input.Location, input.FindingType, input.Strategy, input.ReconContext) + assertTextEqual(t, "enrich_location_prompt", got, goldenText(t, "enrich_location_prompt")) +} + +// TestAssembleFindingMatchesPython replays every case gen_golden.py ran through +// the real assemble_finding: end-line arithmetic, the data-flow gate, and the +// three enum coercions. +func TestAssembleFindingMatchesPython(t *testing.T) { + var cases []struct { + Name string `json:"name"` + Location schemas.VulnLocation `json:"location"` + Enriched schemas.EnrichedFinding `json:"enriched"` + FindingType string `json:"finding_type"` + Strategy string `json:"strategy"` + Want map[string]any `json:"want"` + } + goldenJSON(t, "assemble_finding", &cases) + if len(cases) == 0 { + t.Fatal("assemble_finding golden is empty") + } + for _, tc := range cases { + tc := tc + t.Run(tc.Name, func(t *testing.T) { + got := AssembleFinding(tc.Location, tc.Enriched, tc.FindingType, tc.Strategy) + gotTree := scrubIDs(jsonTree(t, got)) + wantTree := scrubIDs(jsonTree(t, tc.Want)) + if !reflect.DeepEqual(gotTree, wantTree) { + t.Fatalf("assemble_finding %s mismatch%s", tc.Name, diffJSON(gotTree, wantTree)) + } + }) + } +} + +// TestEnumCoercionFallbacks covers _to_finding_type / _to_severity / +// _to_confidence directly, including the lower() that runs before the lookup +// and the fallbacks an out-of-vocabulary value lands on. +func TestEnumCoercionFallbacks(t *testing.T) { + findingTypes := map[string]schemas.FindingType{ + "sast": schemas.FindingTypeSast, "SAST": schemas.FindingTypeSast, + "sca": schemas.FindingTypeSca, "Config": schemas.FindingTypeConfig, + "logic": schemas.FindingTypeLogic, "api": schemas.FindingTypeAPI, + "secrets": schemas.FindingTypeSecrets, + // Fallbacks: unknown, empty, and a value that only differs by padding. + "not_a_type": schemas.FindingTypeSast, "": schemas.FindingTypeSast, + " sast ": schemas.FindingTypeSast, + } + for in, want := range findingTypes { + if got := toFindingType(in); got != want { + t.Errorf("toFindingType(%q) = %q, want %q", in, got, want) + } + } + + severities := map[string]schemas.Severity{ + "critical": schemas.SeverityCritical, "HIGH": schemas.SeverityHigh, + "Medium": schemas.SeverityMedium, "low": schemas.SeverityLow, + "info": schemas.SeverityInfo, + // Fallback is MEDIUM, not INFO. + "catastrophic": schemas.SeverityMedium, "": schemas.SeverityMedium, + " high ": schemas.SeverityMedium, + } + for in, want := range severities { + if got := toSeverity(in); got != want { + t.Errorf("toSeverity(%q) = %q, want %q", in, got, want) + } + } + + confidences := map[string]schemas.Confidence{ + "high": schemas.ConfidenceHigh, "MEDIUM": schemas.ConfidenceMedium, + "Low": schemas.ConfidenceLow, + // Fallback is MEDIUM. + "certain": schemas.ConfidenceMedium, "": schemas.ConfidenceMedium, + } + for in, want := range confidences { + if got := toConfidence(in); got != want { + t.Errorf("toConfidence(%q) = %q, want %q", in, got, want) + } + } +} + +// TestAssembleFindingEndLineArithmetic states the end-line rule on its own, +// including the trailing-newline overshoot the port reproduces. +func TestAssembleFindingEndLineArithmetic(t *testing.T) { + cases := []struct { + snippet string + startLine int + wantEnd int + }{ + {"", 5, 5}, // no newline -> one line + {"one", 5, 5}, // no newline -> one line + {"one\ntwo", 5, 6}, // two lines + {"one\ntwo\n", 5, 7}, // TRAILING newline counts a third line + {"\n\n\n", 1, 4}, // three newlines -> four lines + {"a\r\nb", 10, 11}, // CRLF: only the \n counts + {"one\ntwo", 0, 1}, // start_line 0 is not special-cased + {"one\ntwo", -3, -2}, // nor is a negative one + } + for _, tc := range cases { + location := schemas.VulnLocation{FilePath: "f", StartLine: tc.startLine, CodeSnippet: tc.snippet} + got := AssembleFinding(location, schemas.EnrichedFinding{}, "sast", "injection") + if got.EndLine != tc.wantEnd { + t.Errorf("snippet %q start %d: end_line = %d, want %d", tc.snippet, tc.startLine, got.EndLine, tc.wantEnd) + } + } +} + +// TestAssembleFindingDataFlowGate states the data-flow rule: the summary is +// stripped, an empty result leaves data_flow nil (JSON null), and a surviving +// summary produces exactly one step whose component is the STRATEGY. +func TestAssembleFindingDataFlowGate(t *testing.T) { + location := schemas.VulnLocation{FilePath: "app/x.py", StartLine: 12, CodeSnippet: "x"} + + for _, blank := range []string{"", " ", "\n\t \n"} { + got := AssembleFinding(location, schemas.EnrichedFinding{DataFlowSummary: blank}, "sast", "ssrf") + if got.DataFlow != nil { + t.Errorf("summary %q: want nil data_flow, got %#v", blank, got.DataFlow) + } + } + + got := AssembleFinding( + location, schemas.EnrichedFinding{DataFlowSummary: " src -> sink "}, "sast", "ssrf", + ) + want := []schemas.ReconDataFlowStep{{ + FilePath: "app/x.py", Line: 12, Component: "ssrf", Operation: "src -> sink", + }} + if !reflect.DeepEqual(got.DataFlow, want) { + t.Errorf("data_flow = %#v, want %#v", got.DataFlow, want) + } +} + +// TestAssembleFindingCopiesCweIDIntoName pins the deliberate duplication +// `cwe_name=enriched.cwe_id`. +func TestAssembleFindingCopiesCweIDIntoName(t *testing.T) { + got := AssembleFinding( + schemas.VulnLocation{}, schemas.EnrichedFinding{CweID: "CWE-89"}, "sast", "injection", + ) + if got.CweID != "CWE-89" || got.CweName != "CWE-89" { + t.Fatalf("cwe_id/cwe_name = %q/%q, want CWE-89/CWE-89", got.CweID, got.CweName) + } +} + +// TestScanLocationsHarnessOptions asserts the scratch-directory contract: Cwd +// is a fresh directory that is NOT the repository, ProjectDir is, and the +// directory is gone once the call returns. +func TestScanLocationsHarnessOptions(t *testing.T) { + fake := newHuntFake(cannedLocations(), cannedEnriched()) + if _, err := ScanLocations(context.Background(), fake, "HUNTER", fixtureRepo); err != nil { + t.Fatalf("ScanLocations: %v", err) + } + if len(fake.Harnesses) != 1 { + t.Fatalf("want 1 harness call, got %d", len(fake.Harnesses)) + } + opts := fake.Harnesses[0].Opts + if opts.ProjectDir != fixtureRepo { + t.Errorf("ProjectDir = %q, want %q", opts.ProjectDir, fixtureRepo) + } + if opts.Cwd == "" || opts.Cwd == fixtureRepo { + t.Fatalf("Cwd = %q, want a private scratch directory", opts.Cwd) + } + if !strings.Contains(opts.Cwd, "secaf-hunt-scan-") { + t.Errorf("Cwd = %q, want the secaf-hunt-scan- prefix", opts.Cwd) + } + if _, err := os.Stat(opts.Cwd); !os.IsNotExist(err) { + t.Errorf("scratch dir %q still exists after the call (stat err %v)", opts.Cwd, err) + } +} + +// TestEnrichLocationHarnessOptions is the same contract for step 2, whose +// prefix embeds the strategy. +func TestEnrichLocationHarnessOptions(t *testing.T) { + fake := newHuntFake(cannedLocations(), cannedEnriched()) + _, err := EnrichLocation( + context.Background(), fake, cannedLocations()[0], "sast", "supply_chain", "CTX", fixtureRepo, + ) + if err != nil { + t.Fatalf("EnrichLocation: %v", err) + } + opts := fake.Harnesses[0].Opts + if !strings.Contains(opts.Cwd, "secaf-hunt-enrich-supply_chain-") { + t.Errorf("Cwd = %q, want the secaf-hunt-enrich-supply_chain- prefix", opts.Cwd) + } + if opts.ProjectDir != fixtureRepo { + t.Errorf("ProjectDir = %q, want %q", opts.ProjectDir, fixtureRepo) + } + if _, err := os.Stat(opts.Cwd); !os.IsNotExist(err) { + t.Errorf("scratch dir %q still exists after the call", opts.Cwd) + } +} + +// TestScanLocationsHarnessErrorMessage pins extract_harness_result's error +// text for the scan agent name. +func TestScanLocationsHarnessErrorMessage(t *testing.T) { + fake := &appx.Fake{HarnessFn: func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return &harness.Result{IsError: true, ErrorMessage: "provider exited 1"}, nil + }} + _, err := ScanLocations(context.Background(), fake, "HUNTER", fixtureRepo) + if err == nil { + t.Fatal("want an error") + } + if got, want := err.Error(), "Hunt location scanner harness error: provider exited 1"; got != want { + t.Fatalf("error = %q, want %q", got, want) + } +} + +// TestEnrichLocationHarnessErrorMessage is the same for the enrich agent name. +func TestEnrichLocationHarnessErrorMessage(t *testing.T) { + fake := &appx.Fake{HarnessFn: func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return &harness.Result{IsError: true, ErrorMessage: "timeout"}, nil + }} + _, err := EnrichLocation( + context.Background(), fake, schemas.VulnLocation{}, "sast", "auth", "CTX", fixtureRepo, + ) + if err == nil { + t.Fatal("want an error") + } + if got, want := err.Error(), "Hunt finding enricher harness error: timeout"; got != want { + t.Fatalf("error = %q, want %q", got, want) + } +} + +// TestEnrichLocationsParallelEmpty pins the `if not locations: return []` +// short-circuit — no harness call, and an EMPTY (not nil) slice. +func TestEnrichLocationsParallelEmpty(t *testing.T) { + fake := newHuntFake(nil, cannedEnriched()) + got, err := EnrichLocationsParallel( + context.Background(), fake, nil, "sast", "injection", "CTX", fixtureRepo, DefaultEnrichConcurrency, + ) + if err != nil { + t.Fatalf("EnrichLocationsParallel: %v", err) + } + if got == nil { + t.Fatal("want an empty slice, got nil") + } + if len(got) != 0 { + t.Fatalf("want 0 results, got %d", len(got)) + } + if len(fake.Harnesses) != 0 { + t.Fatalf("want 0 harness calls, got %d", len(fake.Harnesses)) + } +} + +// TestEnrichLocationsParallelPreservesOrder asserts result[i] belongs to +// location[i] regardless of completion order — the invariant assemble_finding's +// zip() depends on. +func TestEnrichLocationsParallelPreservesOrder(t *testing.T) { + locations := make([]schemas.VulnLocation, 8) + for i := range locations { + locations[i] = schemas.VulnLocation{ + FilePath: "f" + string(rune('a'+i)) + ".py", + StartLine: i, + PatternType: "p", + } + } + fake := &appx.Fake{HarnessFn: func(_ context.Context, prompt string, _ map[string]any, dest any, _ harness.Options) (*harness.Result, error) { + // Answer with the file path the prompt names, so a mis-ordered result + // is immediately visible. + out := dest.(*schemas.EnrichedFinding) + for _, location := range locations { + if strings.Contains(prompt, locationBlock(location)) { + out.Title = location.FilePath + } + } + return &harness.Result{Parsed: dest}, nil + }} + + got, err := EnrichLocationsParallel( + context.Background(), fake, locations, "sast", "injection", "CTX", fixtureRepo, 4, + ) + if err != nil { + t.Fatalf("EnrichLocationsParallel: %v", err) + } + if len(got) != len(locations) { + t.Fatalf("want %d results, got %d", len(locations), len(got)) + } + for i, location := range locations { + if got[i].Title != location.FilePath { + t.Errorf("result[%d].Title = %q, want %q", i, got[i].Title, location.FilePath) + } + } +} + +// TestEnrichLocationsParallelConcurrencyBound asserts the semaphore: at most +// max(1, maxConcurrent) enrichments are ever in flight, and a non-positive +// bound still admits one rather than deadlocking. +func TestEnrichLocationsParallelConcurrencyBound(t *testing.T) { + for _, limit := range []int{1, 2, 5, 0, -3} { + limit := limit + t.Run(strings.TrimSpace(itoaSigned(limit)), func(t *testing.T) { + want := limit + if want < 1 { + want = 1 + } + locations := make([]schemas.VulnLocation, 12) + for i := range locations { + locations[i] = schemas.VulnLocation{FilePath: "f.py", StartLine: i} + } + var inflight, peak int32 + fake := &appx.Fake{HarnessFn: func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + now := atomic.AddInt32(&inflight, 1) + for { + old := atomic.LoadInt32(&peak) + if now <= old || atomic.CompareAndSwapInt32(&peak, old, now) { + break + } + } + // Give the scheduler a chance to over-admit if the bound is broken. + for i := 0; i < 200; i++ { + _ = i + } + atomic.AddInt32(&inflight, -1) + return &harness.Result{Parsed: new(schemas.EnrichedFinding)}, nil + }} + if _, err := EnrichLocationsParallel( + context.Background(), fake, locations, "sast", "injection", "CTX", fixtureRepo, limit, + ); err != nil { + t.Fatalf("EnrichLocationsParallel: %v", err) + } + if got := int(atomic.LoadInt32(&peak)); got > want { + t.Fatalf("peak concurrency %d exceeds the bound %d", got, want) + } + if got := fake.MaxConcurrentHarness(); got > want { + t.Fatalf("appx.Fake peak concurrency %d exceeds the bound %d", got, want) + } + }) + } +} + +// TestEnrichLocationsParallelPropagatesError asserts a failing enrichment +// surfaces as an error and a nil result, the way Python's gather re-raises. +func TestEnrichLocationsParallelPropagatesError(t *testing.T) { + locations := []schemas.VulnLocation{{FilePath: "a.py"}, {FilePath: "b.py"}} + fake := &appx.Fake{HarnessFn: func(_ context.Context, prompt string, _ map[string]any, dest any, _ harness.Options) (*harness.Result, error) { + if strings.Contains(prompt, "- File path: b.py\n") { + return &harness.Result{IsError: true, ErrorMessage: "boom"}, nil + } + return &harness.Result{Parsed: dest}, nil + }} + got, err := EnrichLocationsParallel( + context.Background(), fake, locations, "sast", "injection", "CTX", fixtureRepo, 5, + ) + if err == nil { + t.Fatal("want an error") + } + if got != nil { + t.Fatalf("want a nil result alongside the error, got %#v", got) + } + if !strings.Contains(err.Error(), "Hunt finding enricher harness error: boom") { + t.Fatalf("error = %v, want the enricher harness error", err) + } +} + +// itoaSigned keeps the sub-test names readable for negative bounds. +func itoaSigned(n int) string { + if n < 0 { + return "neg" + itoaSigned(-n) + } + digits := "" + if n == 0 { + return "0" + } + for n > 0 { + digits = string(rune('0'+n%10)) + digits + n /= 10 + } + return digits +} diff --git a/go/internal/agents/hunt/ssrf.go b/go/internal/agents/hunt/ssrf.go new file mode 100644 index 0000000..f81fb32 --- /dev/null +++ b/go/internal/agents/hunt/ssrf.go @@ -0,0 +1,63 @@ +package hunt + +// Ports src/sec_af/agents/hunt/ssrf.py. + +import ( + "context" + "strconv" + "strings" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/recontext" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +const ssrfPromptPath = "hunt/ssrf.txt" + +// ssrfScanPrompt builds the exact prompt run_ssrf_hunter sends. +// +// Python parity: ssrf.py's CONTEXT block is injection.py's with "identify +// injection points" swapped for "identify SSRF points", but the early-stop +// sentence is written as ONE f-string here where injection.py splits it across +// three concatenated literals — the resulting bytes are identical. +func ssrfScanPrompt(repoPath string, recon schemas.ReconResult, depth, earlyStop string) (scanPrompt, reconContext string) { + reconContext = entryFlowContextBlock(recon) + template := prompts.MustLoad(ssrfPromptPath) + template = strings.ReplaceAll(template, "{{RECON_CONTEXT_JSON}}", reconContext) + template = strings.ReplaceAll(template, "{{LANGUAGE_HINTS}}", recontext.LanguageHintsForContext(recon)) + template = strings.ReplaceAll(template, "{{FRAMEWORK_HINTS}}", recontext.FrameworkHintsForContext(recon)) + + scanPrompt = template + + "\n\nCONTEXT:\n" + + "- Repository path: " + repoPath + "\n" + + "- Depth profile: " + depth + "\n" + + "- Early stop rule: if you inspect " + earlyStop + + " files without credible signal, stop and return empty findings.\n" + + "- Focus on RECON entry points and data flows as primary source-to-sink paths.\n" + + "- Explore the codebase, trace data flows from sources to sinks, and identify SSRF points.\n" + + "- Take multiple turns to build findings incrementally and write final JSON only when complete." + return scanPrompt, reconContext +} + +func runSSRFHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, depth, earlyStop string, +) (schemas.HuntResult, error) { + scanPrompt, reconContext := ssrfScanPrompt(repoPath, recon, depth, earlyStop) + return runHunterBody(ctx, app, repoPath, hunterSpec{ + ScanPrompt: scanPrompt, + ReconContext: reconContext, + FindingType: "sast", + Strategy: "ssrf", + EmptyStrategiesRun: nil, // Python: bare HuntResult() + }) +} + +// RunSSRFHunter ports ssrf.py run_ssrf_hunter. +func RunSSRFHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, depth string, maxFilesWithoutSignal int, +) (schemas.HuntResult, error) { + return runSSRFHunter(ctx, app, repoPath, recon, depth, strconv.Itoa(maxFilesWithoutSignal)) +} diff --git a/go/internal/agents/hunt/supply_chain.go b/go/internal/agents/hunt/supply_chain.go new file mode 100644 index 0000000..75bfea3 --- /dev/null +++ b/go/internal/agents/hunt/supply_chain.go @@ -0,0 +1,87 @@ +package hunt + +// Ports src/sec_af/agents/hunt/supply_chain.py. + +import ( + "context" + "strconv" + "strings" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/recontext" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +const supplyChainPromptPath = "hunt/supply_chain.txt" + +// ShouldRunSupplyChainHunter ports supply_chain.py +// should_run_supply_chain_hunter: +// +// return recon.dependencies.direct_count > 0 +// +// Python parity: the gate reads direct_count only. A repository whose SBOM is +// populated but whose direct_count was never set still skips the hunter. +func ShouldRunSupplyChainHunter(recon schemas.ReconResult) bool { + return recon.Dependencies.DirectCount > 0 +} + +// supplyChainScanPrompt builds the exact prompt run_supply_chain_hunter sends. +// +// Python parity: the early-stop sentence says "manifests/files", not "files", +// and the whole block ends with a trailing newline (only auth.py does the +// same). earlyStop is where the argument cascade lands the depth string for +// this hunter (package doc). +func supplyChainScanPrompt(repoPath string, recon schemas.ReconResult, earlyStop string) (scanPrompt, reconContext string) { + reconContext = recontext.ReconContextForSupplyChain(recon) + template := prompts.MustLoad(supplyChainPromptPath) + template = strings.ReplaceAll(template, "{{RECON_CONTEXT}}", reconContext) + template = strings.ReplaceAll(template, "{{LANGUAGE_HINTS}}", recontext.LanguageHintsForContext(recon)) + template = strings.ReplaceAll(template, "{{FRAMEWORK_HINTS}}", recontext.FrameworkHintsForContext(recon)) + + scanPrompt = template + + "\n\nCONTEXT:\n" + + "- Repository path: " + repoPath + "\n" + + "- Hunt strategy: supply_chain (CWE-1104, CWE-829).\n" + + "- Early stop rule: if you inspect " + + earlyStop + " manifests/files without credible dependency risk, " + + "stop and return empty findings.\n" + + "- Focus manifests/lockfiles (package.json, requirements.txt, go.mod, Pipfile, " + + "poetry.lock, package-lock.json, yarn.lock, pnpm-lock.yaml, Cargo.toml).\n" + + "- Take multiple turns: inspect manifests/lockfiles, validate dependency risks, " + + "then produce final structured findings.\n" + + "- Write final JSON only when analysis is complete.\n" + return scanPrompt, reconContext +} + +func runSupplyChainHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, earlyStop string, +) (schemas.HuntResult, error) { + // Python parity: `_empty_supply_chain_result()` spells out + // HuntResult(findings=[], chains=[], strategies_run=[]), which is the bare + // default shape. + if !ShouldRunSupplyChainHunter(recon) { + return schemas.NewHuntResult(), nil + } + scanPrompt, reconContext := supplyChainScanPrompt(repoPath, recon, earlyStop) + return runHunterBody(ctx, app, repoPath, hunterSpec{ + ScanPrompt: scanPrompt, + ReconContext: reconContext, + // Python parity: the only hunter tagging its findings "sca". + FindingType: "sca", + Strategy: "supply_chain", + EmptyStrategiesRun: []string{"supply_chain"}, + }) +} + +// RunSupplyChainHunter ports supply_chain.py run_supply_chain_hunter: +// +// async def run_supply_chain_hunter(app, repo_path, recon, +// max_files_without_signal: int = 30) -> HuntResult +func RunSupplyChainHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, maxFilesWithoutSignal int, +) (schemas.HuntResult, error) { + return runSupplyChainHunter(ctx, app, repoPath, recon, strconv.Itoa(maxFilesWithoutSignal)) +} diff --git a/go/internal/agents/hunt/testdata/golden/assemble_finding.json b/go/internal/agents/hunt/testdata/golden/assemble_finding.json new file mode 100644 index 0000000..7e9f0f1 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/assemble_finding.json @@ -0,0 +1,179 @@ +[ + { + "name": "multiline_snippet_sast", + "location": { + "file_path": "app/api/users.py", + "start_line": 42, + "code_snippet": "query = f\"SELECT * FROM users WHERE id = {user_id}\"\ncursor.execute(query)", + "pattern_type": "sql_injection" + }, + "enriched": { + "title": "SQL injection in user lookup", + "description": "user_id flows unescaped into an f-string query.", + "cwe_id": "CWE-89", + "severity": "HIGH", + "confidence": "high", + "data_flow_summary": " request.args['id'] -> query -> cursor.execute " + }, + "finding_type": "sast", + "strategy": "injection", + "want": { + "id": "", + "hunter_strategy": "injection", + "title": "SQL injection in user lookup", + "description": "user_id flows unescaped into an f-string query.", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "CWE-89", + "owasp_category": null, + "file_path": "app/api/users.py", + "start_line": 42, + "end_line": 43, + "function_name": null, + "code_snippet": "query = f\"SELECT * FROM users WHERE id = {user_id}\"\ncursor.execute(query)", + "estimated_severity": "high", + "confidence": "high", + "data_flow": [ + { + "file_path": "app/api/users.py", + "line": 42, + "component": "injection", + "operation": "request.args['id'] -> query -> cursor.execute" + } + ], + "related_files": [], + "fingerprint": "" + } + }, + { + "name": "single_line_bad_enums", + "location": { + "file_path": "app/utils/hash.py", + "start_line": 7, + "code_snippet": "digest = hashlib.md5(password).hexdigest()", + "pattern_type": "weak_hash" + }, + "enriched": { + "title": "Weak hash for password storage", + "description": "MD5 used to derive a credential digest.", + "cwe_id": "CWE-327", + "severity": "catastrophic", + "confidence": "certain", + "data_flow_summary": " " + }, + "finding_type": "sast", + "strategy": "crypto", + "want": { + "id": "", + "hunter_strategy": "crypto", + "title": "Weak hash for password storage", + "description": "MD5 used to derive a credential digest.", + "finding_type": "sast", + "cwe_id": "CWE-327", + "cwe_name": "CWE-327", + "owasp_category": null, + "file_path": "app/utils/hash.py", + "start_line": 7, + "end_line": 7, + "function_name": null, + "code_snippet": "digest = hashlib.md5(password).hexdigest()", + "estimated_severity": "medium", + "confidence": "medium", + "data_flow": null, + "related_files": [], + "fingerprint": "" + } + }, + { + "name": "empty_snippet_unknown_type", + "location": { + "file_path": "a.py", + "start_line": 0, + "code_snippet": "", + "pattern_type": "" + }, + "enriched": { + "title": "t", + "description": "d", + "cwe_id": "", + "severity": "", + "confidence": "", + "data_flow_summary": "x" + }, + "finding_type": "not_a_type", + "strategy": "logic", + "want": { + "id": "", + "hunter_strategy": "logic", + "title": "t", + "description": "d", + "finding_type": "sast", + "cwe_id": "", + "cwe_name": "", + "owasp_category": null, + "file_path": "a.py", + "start_line": 0, + "end_line": 0, + "function_name": null, + "code_snippet": "", + "estimated_severity": "medium", + "confidence": "medium", + "data_flow": [ + { + "file_path": "a.py", + "line": 0, + "component": "logic", + "operation": "x" + } + ], + "related_files": [], + "fingerprint": "" + } + }, + { + "name": "trailing_newline_snippet_uppercase_type", + "location": { + "file_path": "b/c.js", + "start_line": 10, + "code_snippet": "one\ntwo\n", + "pattern_type": "p" + }, + "enriched": { + "title": "t", + "description": "d", + "cwe_id": "CWE-1", + "severity": "INFO", + "confidence": "LOW", + "data_flow_summary": "\n flow \n" + }, + "finding_type": "SCA", + "strategy": "supply_chain", + "want": { + "id": "", + "hunter_strategy": "supply_chain", + "title": "t", + "description": "d", + "finding_type": "sca", + "cwe_id": "CWE-1", + "cwe_name": "CWE-1", + "owasp_category": null, + "file_path": "b/c.js", + "start_line": 10, + "end_line": 12, + "function_name": null, + "code_snippet": "one\ntwo\n", + "estimated_severity": "info", + "confidence": "low", + "data_flow": [ + { + "file_path": "b/c.js", + "line": 10, + "component": "supply_chain", + "operation": "flow" + } + ], + "related_files": [], + "fingerprint": "" + } + } +] diff --git a/go/internal/agents/hunt/testdata/golden/auth_depth_label.json b/go/internal/agents/hunt/testdata/golden/auth_depth_label.json new file mode 100644 index 0000000..d435624 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/auth_depth_label.json @@ -0,0 +1,8 @@ +{ + "quick": "quick", + "Standard": "standard", + " THOROUGH ": "thorough", + "bogus": "standard", + "": "standard", + " ": "standard" +} diff --git a/go/internal/agents/hunt/testdata/golden/auth_target_cwes.json b/go/internal/agents/hunt/testdata/golden/auth_target_cwes.json new file mode 100644 index 0000000..8676f53 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/auth_target_cwes.json @@ -0,0 +1,7 @@ +[ + "CWE-287", + "CWE-306", + "CWE-862", + "CWE-863", + "CWE-352" +] diff --git a/go/internal/agents/hunt/testdata/golden/business_logic_enabled.json b/go/internal/agents/hunt/testdata/golden/business_logic_enabled.json new file mode 100644 index 0000000..4b46a5c --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/business_logic_enabled.json @@ -0,0 +1,8 @@ +{ + "quick": false, + "QUICK": false, + "standard": true, + "thorough": true, + "bogus": true, + "": true +} diff --git a/go/internal/agents/hunt/testdata/golden/business_logic_prompt_with_depth_prompt.txt b/go/internal/agents/hunt/testdata/golden/business_logic_prompt_with_depth_prompt.txt new file mode 100644 index 0000000..f76f228 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/business_logic_prompt_with_depth_prompt.txt @@ -0,0 +1,164 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are the SEC-AF Business Logic Hunter focused on high-impact workflow abuse and invariant violations. + +CONTEXT: +You are in HUNT phase. Analyze this repository using the provided recon context and return HuntResult JSON. + +RECON_CONTEXT: +{ + "app_type": "web_api", + "frameworks": [ + "django" + ], + "languages": [ + "python" + ], + "auth_model": "jwt", + "auth_details": "HS256 access tokens", + "entry_points": [ + { + "kind": "http", + "identifier": "POST /login", + "file_path": "app/api/auth.py", + "line": 12, + "method": "POST", + "route": "/login", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "seed", + "file_path": "app/cli.py", + "line": 3, + "method": null, + "route": null, + "auth_required": null + } + ], + "api_surface": [ + { + "method": "POST", + "path": "/login", + "handler": "login", + "file_path": "app/api/auth.py", + "line": 12, + "auth_required": false, + "rate_limited": false + }, + { + "method": "GET", + "path": "/users/{id}", + "handler": "get_user", + "file_path": "app/api/users.py", + "line": 40, + "auth_required": true, + "rate_limited": true + } + ], + "data_flows": [ + { + "source": "request.json", + "path": [ + { + "file_path": "app/api/users.py", + "line": 41, + "component": "handler", + "operation": "read id" + } + ], + "sink": "cursor.execute", + "sanitized": false, + "files": [ + "app/api/users.py" + ] + } + ] +} + +OBJECTIVE: +Find business-logic vulnerabilities by reasoning about intended behavior versus actual implementation behavior. + +FOCUS CWES: +- CWE-840: Business Logic Errors +- CWE-841: Improper Enforcement of Behavioral Workflow +- CWE-362: Race Condition +- CWE-367: Time-of-check Time-of-use (TOCTOU) Race Condition +- CWE-639: Authorization Bypass Through User-Controlled Key (IDOR patterns) + +HUNT FOR THESE PATTERNS: +- Race conditions in concurrent requests, especially check-then-act updates without robust locking/transactions +- State machine violations in multi-step workflows (skipping mandatory steps or reordering transitions) +- Missing validation of amounts, ownership, limits, or permissions beyond basic authentication +- Workflow step bypasses (for example, directly invoking step 3 from step 1) +- Integer overflow/underflow or sign bugs in balances, pricing, credits, quantities, and counters +- TOCTOU vulnerabilities where validation/check and use/mutation are separable and exploitable +- IDOR beyond missing auth (predictable identifiers, insecure ownership reassignment, mass assignment to owner/account fields) +- Price manipulation (client-controlled cart totals, discounts, tax, shipping, or final payable amounts) + +DO NOT FLAG: +- Properly locked/serialized concurrent operations +- Explicitly validated state transitions with strict transition guards +- Server-side amount/price calculation that ignores client-submitted totals +- Rate-limited operations where the suspected abuse depends on rapid repetition + +ANALYSIS GUIDELINES: +- Start from recon entry points and data flows, then trace complete end-to-end business workflows. +- Compare behavior across layers (controller/API, domain/service, persistence, queue/worker). +- Validate whether business invariants are enforced at mutation boundaries. +- Distinguish real exploit paths from expected eventual consistency or benign races. +- Include only evidence-backed findings with concrete exploit narrative. + +OUTPUT REQUIREMENTS: +- Return JSON that strictly matches HuntResult. +- Set finding_type to "logic" for business-logic findings. +- Use cwe_id and cwe_name that match the specific issue. +- Populate file_path, start_line, end_line, code_snippet, and related_files whenever possible. +- Generate stable fingerprints from vulnerability identity and location. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +CONSTRAINTS: +- Take multiple turns: investigate workflow intent, validate exploitability, then produce findings. +- Prefer high-confidence findings over speculative broad coverage. +- No markdown, no prose outside JSON, no code fences. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Depth profile: thorough +- Early stop rule: if you inspect 30 files without credible business-logic signal, stop and return empty findings. +- Strategy: business_logic +- Focus CWEs: CWE-840, CWE-841, CWE-362, CWE-367, CWE-639. +- Reason about intended business behavior versus exploitable implementation behavior. +- Take multiple turns, trace complete workflows, and return final JSON only when complete. +- Additional depth guidance: Use deep, multi-turn analysis. Trace cross-file flows and hunt secondary pivots. diff --git a/go/internal/agents/hunt/testdata/golden/cascade_binding.json b/go/internal/agents/hunt/testdata/golden/cascade_binding.json new file mode 100644 index 0000000..29cd50d --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/cascade_binding.json @@ -0,0 +1,170 @@ +{ + "quick": { + "fixture": "small", + "hunters": { + "auth": { + "repo_path": "/fixtures/demo-repo", + "recon_result": "", + "depth": "quick", + "max_files_without_signal": 30 + }, + "data_exposure": { + "repo_path": "/fixtures/demo-repo", + "recon": "", + "max_files_without_signal": "quick" + }, + "dos": { + "repo_path": "/fixtures/demo-repo", + "recon_result": "", + "depth": "quick", + "max_files_without_signal": 30 + }, + "injection": { + "repo_path": "/fixtures/demo-repo", + "recon_result": "", + "depth": "quick", + "max_files_without_signal": 30 + }, + "ssrf": { + "repo_path": "/fixtures/demo-repo", + "recon_result": "", + "depth": "quick", + "max_files_without_signal": 30 + } + } + }, + "standard": { + "fixture": "rich", + "hunters": { + "api_security": { + "repo_path": "/fixtures/demo-repo", + "recon": "", + "max_files_without_signal": "standard" + }, + "auth": { + "repo_path": "/fixtures/demo-repo", + "recon_result": "", + "depth": "standard", + "max_files_without_signal": 30 + }, + "business_logic": { + "repo_path": "/fixtures/demo-repo", + "recon_result": "", + "depth": "standard", + "max_files_without_signal": 30, + "depth_prompt": "" + }, + "config_secrets": { + "repo_path": "/fixtures/demo-repo", + "recon": "", + "max_files_without_signal": "standard" + }, + "crypto": { + "repo_path": "/fixtures/demo-repo", + "recon": "", + "max_files_without_signal": "standard" + }, + "data_exposure": { + "repo_path": "/fixtures/demo-repo", + "recon": "", + "max_files_without_signal": "standard" + }, + "dos": { + "repo_path": "/fixtures/demo-repo", + "recon_result": "", + "depth": "standard", + "max_files_without_signal": 30 + }, + "injection": { + "repo_path": "/fixtures/demo-repo", + "recon_result": "", + "depth": "standard", + "max_files_without_signal": 30 + }, + "ssrf": { + "repo_path": "/fixtures/demo-repo", + "recon_result": "", + "depth": "standard", + "max_files_without_signal": 30 + }, + "supply_chain": { + "repo_path": "/fixtures/demo-repo", + "recon": "", + "max_files_without_signal": "standard" + }, + "xss": { + "repo_path": "/fixtures/demo-repo", + "recon_result": "", + "depth": "standard", + "max_files_without_signal": 30 + } + } + }, + "thorough": { + "fixture": "small", + "hunters": { + "api_security": { + "repo_path": "/fixtures/demo-repo", + "recon": "", + "max_files_without_signal": "thorough" + }, + "auth": { + "repo_path": "/fixtures/demo-repo", + "recon_result": "", + "depth": "thorough", + "max_files_without_signal": 30 + }, + "business_logic": { + "repo_path": "/fixtures/demo-repo", + "recon_result": "", + "depth": "thorough", + "max_files_without_signal": 30, + "depth_prompt": "" + }, + "config_secrets": { + "repo_path": "/fixtures/demo-repo", + "recon": "", + "max_files_without_signal": "thorough" + }, + "crypto": { + "repo_path": "/fixtures/demo-repo", + "recon": "", + "max_files_without_signal": "thorough" + }, + "data_exposure": { + "repo_path": "/fixtures/demo-repo", + "recon": "", + "max_files_without_signal": "thorough" + }, + "dos": { + "repo_path": "/fixtures/demo-repo", + "recon_result": "", + "depth": "thorough", + "max_files_without_signal": 30 + }, + "injection": { + "repo_path": "/fixtures/demo-repo", + "recon_result": "", + "depth": "thorough", + "max_files_without_signal": 30 + }, + "ssrf": { + "repo_path": "/fixtures/demo-repo", + "recon_result": "", + "depth": "thorough", + "max_files_without_signal": 30 + }, + "supply_chain": { + "repo_path": "/fixtures/demo-repo", + "recon": "", + "max_files_without_signal": "thorough" + }, + "xss": { + "repo_path": "/fixtures/demo-repo", + "recon_result": "", + "depth": "thorough", + "max_files_without_signal": 30 + } + } + } +} diff --git a/go/internal/agents/hunt/testdata/golden/crypto_prompt_both_terms.txt b/go/internal/agents/hunt/testdata/golden/crypto_prompt_both_terms.txt new file mode 100644 index 0000000..bb2a794 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/crypto_prompt_both_terms.txt @@ -0,0 +1,93 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are the SEC-AF Cryptography Hunter specializing in weak cryptography and predictable randomness. + +CONTEXT: +You are in SEC-AF HUNT phase and must return HuntResult JSON. +Use the RECON context summary below. +Focus on whether detected crypto patterns are used in security-sensitive contexts. + +RECON_CONTEXT: +Cryptography-focused recon summary. + +Crypto usage entries: 1 total. + +Algorithms and key handling (weak entries first): 1 total, showing top 1: +- algorithm=MD5, key_size=None, mode=None, context=auth token cache, is_weak=True + +Potential secret/key findings from config scan: none identified in recon. + +Deployment/TLS/security header signals: none identified in recon. + +TASK: +Find potential vulnerabilities related to weak cryptography. + +CWE COVERAGE (REQUIRED): +- CWE-326 Inadequate Encryption Strength +- CWE-327 Broken or Risky Cryptographic Algorithm +- CWE-328 Reversible One-Way Hash +- CWE-330 Insufficiently Random Values +- CWE-916 Weak Password Hashing +- CWE-259 Hard-Coded Password +- CWE-321 Hard-Coded Cryptographic Key +- CWE-798 Hardcoded Credentials + +WHAT TO HUNT: +- Weak algorithms: MD5, SHA1 for security decisions, DES, 3DES, RC4, ECB mode. +- Inadequate key lengths: RSA < 2048, AES < 128, weak DH parameters. +- Reversible password handling: base64/encoding used as if it were hashing. +- Predictable randomness: Math.random(), non-crypto random module usage for tokens/secrets, predictable seeds. +- Weak password storage: unsalted/fast hashes, low-iteration PBKDF2, custom insecure hashing. +- Hardcoded cryptographic keys, secrets, or static IV/nonce values. +- Security-critical usage contexts: password hashing, encryption, token/session generation, signing and verification, auth flows. +- Non-critical usage contexts: checksums, ETags, cache keys, dedup/fingerprinting. + +DO NOT FLAG: +- Crypto operations in tests or fixtures. +- MD5/SHA1 used only for non-security checksums/caching/ETags/cache keys. +- bcrypt/argon2/scrypt for password hashing (these are GOOD). +- Crypto libraries used correctly with sufficient key lengths and secure modes. +- Framework secure defaults (e.g., Django PBKDF2, bcrypt, argon2). + +OUTPUT: +- Return JSON matching HuntResult. +- findings must contain RawFinding entries with evidence-backed descriptions and correct CWE mapping. +- hunter_strategy should be "crypto". + +No language-specific hints available for detected languages. +No framework-specific hints available for detected frameworks. + +CONSTRAINTS: +- Take multiple turns: first discover crypto-relevant files, then evaluate exploitability context. +- Prefer concrete evidence (file path, line range, code snippet, why risky in context). +- No markdown/code fences; JSON output only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Hunt strategy: crypto +- Early stop rule: if you inspect 30 files without credible crypto misuse, stop and return empty findings. +- Focus CWEs: CWE-326, CWE-327, CWE-328, CWE-330, CWE-916, CWE-259, CWE-321, CWE-798 +- Security-critical usage candidates: auth token cache +- Non-security usage candidates: auth token cache +- Prioritize weak crypto findings only when used in security-sensitive contexts; avoid checksum/cache-only noise. +- Take multiple turns to explore relevant files before finalizing findings. +- Write final JSON only when analysis is complete. diff --git a/go/internal/agents/hunt/testdata/golden/crypto_prompt_mixed.txt b/go/internal/agents/hunt/testdata/golden/crypto_prompt_mixed.txt new file mode 100644 index 0000000..350c818 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/crypto_prompt_mixed.txt @@ -0,0 +1,99 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are the SEC-AF Cryptography Hunter specializing in weak cryptography and predictable randomness. + +CONTEXT: +You are in SEC-AF HUNT phase and must return HuntResult JSON. +Use the RECON context summary below. +Focus on whether detected crypto patterns are used in security-sensitive contexts. + +RECON_CONTEXT: +Cryptography-focused recon summary. + +Crypto usage entries: 7 total. + +Algorithms and key handling (weak entries first): 7 total, showing top 7: +- algorithm=MD5, key_size=None, mode=None, context=password hashing, is_weak=True +- algorithm=MD5, key_size=None, mode=None, context=file integrity checksum, is_weak=True +- algorithm=MD5, key_size=None, mode=None, context=etag generation for cache, is_weak=True +- algorithm=MD5, key_size=None, mode=None, context=TLS session key derivation, is_weak=True +- algorithm=MD5, key_size=None, mode=None, context=unrelated purpose, is_weak=True +- algorithm=MD5, key_size=None, mode=None, context=unspecified, is_weak=True +- algorithm=MD5, key_size=None, mode=None, context=unspecified, is_weak=True + +Potential secret/key findings from config scan: none identified in recon. + +Deployment/TLS/security header signals: none identified in recon. + +TASK: +Find potential vulnerabilities related to weak cryptography. + +CWE COVERAGE (REQUIRED): +- CWE-326 Inadequate Encryption Strength +- CWE-327 Broken or Risky Cryptographic Algorithm +- CWE-328 Reversible One-Way Hash +- CWE-330 Insufficiently Random Values +- CWE-916 Weak Password Hashing +- CWE-259 Hard-Coded Password +- CWE-321 Hard-Coded Cryptographic Key +- CWE-798 Hardcoded Credentials + +WHAT TO HUNT: +- Weak algorithms: MD5, SHA1 for security decisions, DES, 3DES, RC4, ECB mode. +- Inadequate key lengths: RSA < 2048, AES < 128, weak DH parameters. +- Reversible password handling: base64/encoding used as if it were hashing. +- Predictable randomness: Math.random(), non-crypto random module usage for tokens/secrets, predictable seeds. +- Weak password storage: unsalted/fast hashes, low-iteration PBKDF2, custom insecure hashing. +- Hardcoded cryptographic keys, secrets, or static IV/nonce values. +- Security-critical usage contexts: password hashing, encryption, token/session generation, signing and verification, auth flows. +- Non-critical usage contexts: checksums, ETags, cache keys, dedup/fingerprinting. + +DO NOT FLAG: +- Crypto operations in tests or fixtures. +- MD5/SHA1 used only for non-security checksums/caching/ETags/cache keys. +- bcrypt/argon2/scrypt for password hashing (these are GOOD). +- Crypto libraries used correctly with sufficient key lengths and secure modes. +- Framework secure defaults (e.g., Django PBKDF2, bcrypt, argon2). + +OUTPUT: +- Return JSON matching HuntResult. +- findings must contain RawFinding entries with evidence-backed descriptions and correct CWE mapping. +- hunter_strategy should be "crypto". + +No language-specific hints available for detected languages. +No framework-specific hints available for detected frameworks. + +CONSTRAINTS: +- Take multiple turns: first discover crypto-relevant files, then evaluate exploitability context. +- Prefer concrete evidence (file path, line range, code snippet, why risky in context). +- No markdown/code fences; JSON output only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Hunt strategy: crypto +- Early stop rule: if you inspect 30 files without credible crypto misuse, stop and return empty findings. +- Focus CWEs: CWE-326, CWE-327, CWE-328, CWE-330, CWE-916, CWE-259, CWE-321, CWE-798 +- Security-critical usage candidates: password hashing, TLS session key derivation +- Non-security usage candidates: file integrity checksum, etag generation for cache +- Prioritize weak crypto findings only when used in security-sensitive contexts; avoid checksum/cache-only noise. +- Take multiple turns to explore relevant files before finalizing findings. +- Write final JSON only when analysis is complete. diff --git a/go/internal/agents/hunt/testdata/golden/crypto_prompt_none.txt b/go/internal/agents/hunt/testdata/golden/crypto_prompt_none.txt new file mode 100644 index 0000000..2d086e9 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/crypto_prompt_none.txt @@ -0,0 +1,94 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are the SEC-AF Cryptography Hunter specializing in weak cryptography and predictable randomness. + +CONTEXT: +You are in SEC-AF HUNT phase and must return HuntResult JSON. +Use the RECON context summary below. +Focus on whether detected crypto patterns are used in security-sensitive contexts. + +RECON_CONTEXT: +Cryptography-focused recon summary. + +Crypto usage entries: 2 total. + +Algorithms and key handling (weak entries first): 2 total, showing top 2: +- algorithm=MD5, key_size=None, mode=None, context=unrelated purpose, is_weak=True +- algorithm=MD5, key_size=None, mode=None, context=widget rendering, is_weak=True + +Potential secret/key findings from config scan: none identified in recon. + +Deployment/TLS/security header signals: none identified in recon. + +TASK: +Find potential vulnerabilities related to weak cryptography. + +CWE COVERAGE (REQUIRED): +- CWE-326 Inadequate Encryption Strength +- CWE-327 Broken or Risky Cryptographic Algorithm +- CWE-328 Reversible One-Way Hash +- CWE-330 Insufficiently Random Values +- CWE-916 Weak Password Hashing +- CWE-259 Hard-Coded Password +- CWE-321 Hard-Coded Cryptographic Key +- CWE-798 Hardcoded Credentials + +WHAT TO HUNT: +- Weak algorithms: MD5, SHA1 for security decisions, DES, 3DES, RC4, ECB mode. +- Inadequate key lengths: RSA < 2048, AES < 128, weak DH parameters. +- Reversible password handling: base64/encoding used as if it were hashing. +- Predictable randomness: Math.random(), non-crypto random module usage for tokens/secrets, predictable seeds. +- Weak password storage: unsalted/fast hashes, low-iteration PBKDF2, custom insecure hashing. +- Hardcoded cryptographic keys, secrets, or static IV/nonce values. +- Security-critical usage contexts: password hashing, encryption, token/session generation, signing and verification, auth flows. +- Non-critical usage contexts: checksums, ETags, cache keys, dedup/fingerprinting. + +DO NOT FLAG: +- Crypto operations in tests or fixtures. +- MD5/SHA1 used only for non-security checksums/caching/ETags/cache keys. +- bcrypt/argon2/scrypt for password hashing (these are GOOD). +- Crypto libraries used correctly with sufficient key lengths and secure modes. +- Framework secure defaults (e.g., Django PBKDF2, bcrypt, argon2). + +OUTPUT: +- Return JSON matching HuntResult. +- findings must contain RawFinding entries with evidence-backed descriptions and correct CWE mapping. +- hunter_strategy should be "crypto". + +No language-specific hints available for detected languages. +No framework-specific hints available for detected frameworks. + +CONSTRAINTS: +- Take multiple turns: first discover crypto-relevant files, then evaluate exploitability context. +- Prefer concrete evidence (file path, line range, code snippet, why risky in context). +- No markdown/code fences; JSON output only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Hunt strategy: crypto +- Early stop rule: if you inspect 30 files without credible crypto misuse, stop and return empty findings. +- Focus CWEs: CWE-326, CWE-327, CWE-328, CWE-330, CWE-916, CWE-259, CWE-321, CWE-798 +- Security-critical usage candidates: none +- Non-security usage candidates: none +- Prioritize weak crypto findings only when used in security-sensitive contexts; avoid checksum/cache-only noise. +- Take multiple turns to explore relevant files before finalizing findings. +- Write final JSON only when analysis is complete. diff --git a/go/internal/agents/hunt/testdata/golden/crypto_term_tables.json b/go/internal/agents/hunt/testdata/golden/crypto_term_tables.json new file mode 100644 index 0000000..e6f62e2 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/crypto_term_tables.json @@ -0,0 +1,27 @@ +{ + "security_critical": [ + "password", + "passwd", + "credential", + "auth", + "token", + "session", + "encrypt", + "decrypt", + "signature", + "sign", + "verify", + "jwt", + "tls", + "ssl", + "key" + ], + "non_security": [ + "checksum", + "etag", + "cache", + "fingerprint", + "dedup", + "integrity" + ] +} diff --git a/go/internal/agents/hunt/testdata/golden/crypto_usage_partition.json b/go/internal/agents/hunt/testdata/golden/crypto_usage_partition.json new file mode 100644 index 0000000..f7cc04a --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/crypto_usage_partition.json @@ -0,0 +1,41 @@ +{ + "mixed": { + "usage_contexts": [ + "password hashing", + "file integrity checksum", + "etag generation for cache", + "TLS session key derivation", + "unrelated purpose" + ], + "security_critical": [ + "password hashing", + "TLS session key derivation" + ], + "non_security": [ + "file integrity checksum", + "etag generation for cache" + ], + "should_run": true + }, + "none": { + "usage_contexts": [ + "unrelated purpose", + "widget rendering" + ], + "security_critical": [], + "non_security": [], + "should_run": true + }, + "both_terms": { + "usage_contexts": [ + "auth token cache" + ], + "security_critical": [ + "auth token cache" + ], + "non_security": [ + "auth token cache" + ], + "should_run": true + } +} diff --git a/go/internal/agents/hunt/testdata/golden/direct_prompt_api_security.txt b/go/internal/agents/hunt/testdata/golden/direct_prompt_api_security.txt new file mode 100644 index 0000000..b29bcfb --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/direct_prompt_api_security.txt @@ -0,0 +1,182 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are an API security hunter specializing in authorization bypasses, origin validation flaws, SSRF, and open redirect vulnerabilities. + +API_SECURITY_CONTEXT: +API security-focused recon summary. + +API endpoints prioritized by missing auth/rate-limits: 20 total, showing top 15: +- POST /api/v1/thing/1 -> ThingController.action1 (app/api/thing_1.py:103, auth_required=False, rate_limited=False) +- DELETE /api/v1/thing/3 -> ThingController.action3 (app/api/thing_3.py:109, auth_required=False, rate_limited=False) +- PUT /api/v1/thing/7 -> ThingController.action7 (app/api/thing_7.py:121, auth_required=False, rate_limited=False) +- PATCH /api/v1/thing/9 -> ThingController.action9 (app/api/thing_9.py:127, auth_required=False, rate_limited=False) +- DELETE /api/v1/thing/13 -> ThingController.action13 (app/api/thing_13.py:139, auth_required=False, rate_limited=False) +- GET /api/v1/thing/15 -> ThingController.action15 (app/api/thing_15.py:145, auth_required=False, rate_limited=False) +- PATCH /api/v1/thing/19 -> ThingController.action19 (app/api/thing_19.py:157, auth_required=False, rate_limited=False) +- GET /api/v1/thing/0 -> ThingController.action0 (app/api/thing_0.py:100, auth_required=True, rate_limited=None) +- PUT /api/v1/thing/2 -> ThingController.action2 (app/api/thing_2.py:106, auth_required=None, rate_limited=True) +- PATCH /api/v1/thing/4 -> ThingController.action4 (app/api/thing_4.py:112, auth_required=True, rate_limited=None) +- GET /api/v1/thing/5 -> ThingController.action5 (app/api/thing_5.py:115, auth_required=None, rate_limited=True) +- POST /api/v1/thing/6 -> ThingController.action6 (app/api/thing_6.py:118, auth_required=True, rate_limited=None) +- DELETE /api/v1/thing/8 -> ThingController.action8 (app/api/thing_8.py:124, auth_required=None, rate_limited=True) +- GET /api/v1/thing/10 -> ThingController.action10 (app/api/thing_10.py:130, auth_required=True, rate_limited=None) +- POST /api/v1/thing/11 -> ThingController.action11 (app/api/thing_11.py:133, auth_required=None, rate_limited=True) + +HTTP/API entry points: 11 total, showing top 10: +- http handler_0 (app/entry/e0.py:10, auth_required=True) +- HTTP /v1/resource/1 (app/entry/e1.py:17, auth_required=False) +- api handler_3 (app/entry/e3.py:31, auth_required=True) +- graphql /v1/resource/4 (app/entry/e4.py:38, auth_required=False) +- rpc /v1/resource/5 (app/entry/e5.py:45, auth_required=None) +- route handler_6 (app/entry/e6.py:52, auth_required=True) +- http handler_9 (app/entry/e9.py:73, auth_required=True) +- api /v1/resource/10 (app/entry/e10.py:80, auth_required=False) +- http handler_12 (app/entry/e12.py:94, auth_required=True) +- route /v1/resource/13 (app/entry/e13.py:101, auth_required=False) + +Trust boundaries relevant to API calls: 12 total, showing top 10: +- boundary_0: internet -> app; enforcement=none +- boundary_1: dmz -> db; enforcement=waf_1, mtls_1 +- boundary_2: vpc -> cache; enforcement=waf_2, mtls_2 +- boundary_3: worker -> queue; enforcement=waf_3, mtls_3 +- boundary_4: internet -> app; enforcement=none +- boundary_5: dmz -> db; enforcement=waf_5, mtls_5 +- boundary_6: vpc -> cache; enforcement=waf_6, mtls_6 +- boundary_7: worker -> queue; enforcement=waf_7, mtls_7 +- boundary_8: internet -> app; enforcement=none +- boundary_9: dmz -> db; enforcement=waf_9, mtls_9 + +Framework/deployment API security signals: 13 total, showing top 10: +- django.middleware.csrf.CsrfViewMiddleware +- helmet defaults +- SecurityMiddleware +- rack-protection +- spring-security filter chain +- kubernetes ingress with TLS termination +- docker-compose exposes 5432 +- no WAF in front of /api +- secrets mounted from vault +- readiness probe on /healthz + +TASK: +Analyze API endpoints and supporting middleware/controllers for: +- CWE-285 Improper Authorization (BOLA/IDOR and missing ownership checks) +- CWE-346 Origin Validation Error (CORS misconfiguration, weak origin validation) +- CWE-918 SSRF (user-controlled URLs reaching server-side fetch clients) +- CWE-601 Open Redirect (untrusted redirect targets) + +Also hunt for: +- Missing/weak rate limiting on authentication and sensitive endpoints +- Mass assignment where request payloads are mapped directly to models/ORM updates +- Missing input validation (unbounded strings, unsafe coercion, negative or out-of-range values) + +WORKFLOW (follow these steps in order): +1. Read the API endpoint files and route handlers identified in the API_SECURITY_CONTEXT above. +2. For each endpoint, trace user input from request parameters/body to where it is used (database queries, redirects, outbound HTTP calls, etc.). +3. Check for missing authorization, missing input validation, CORS misconfigurations, and SSRF vectors. +4. For each confirmed vulnerability, record the file path, line number, and relevant code snippet. +5. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +SCOPE: +- Prioritize internet-facing API routes and handlers from api_surface. +- Trace request parameters and body fields to authorization checks, outbound HTTP clients, redirect responses, and persistence layers. +- Correlate evidence across route definitions, middleware, validators, and shared utilities. +- Exclude speculative findings without source evidence. + +OUTPUT: +- Populate findings with finding_type="api" and hunter_strategy="api_security". +- Include endpoint-specific evidence (method/path, file_path, lines, and relevant snippets). +- Include attack chains only when there is concrete multi-step linkage. +- Empty arrays are acceptable if no vulnerabilities are confirmed. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. + +Language: JAVASCRIPT + Key sinks: eval, Function(), innerHTML, document.write, child_process.exec, new Function + Safe patterns (skip these): DOMPurify.sanitize(), textContent assignment, parameterized pg queries + DO NOT FLAG: React JSX expressions (auto-escaped), Angular template bindings (sanitized by default) + Framework notes: Check for React/Vue/Angular-specific XSS patterns. React auto-escapes JSX. Vue v-html is dangerous. + +Language: GO + Key sinks: fmt.Sprintf into SQL, exec.Command with user input, template.HTML(), os.Exec + Safe patterns (skip these): database/sql with ? placeholders, html/template (auto-escapes), exec.Command with separate args + DO NOT FLAG: GORM parameterized queries, html/template default escaping, database/sql prepared statements + Framework notes: Go's html/template auto-escapes. text/template does NOT. Check for text/template serving HTML. + +Language: RUBY + Key sinks: eval, system, exec, send, public_send, ERB.new with user input + Safe patterns (skip these): ActiveRecord parameterized queries, Rack::Utils.escape_html, sanitize helper in Rails + DO NOT FLAG: ActiveRecord where with hash conditions, Rails CSRF protection, Rails html_safe on constants + Framework notes: Rails auto-escapes ERB templates. raw/html_safe bypasses escaping. Check for mass assignment. + +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +Framework: NEXTJS + Security features: React JSX auto-escapes output by default, API routes can share centralized auth middleware, Server/client boundaries reduce accidental secret exposure + DO NOT FLAG: React JSX escaped rendering, getServerSideProps/getServerSession with proper auth checks, Typed route handlers with validated schema guards + Watch for: dangerouslySetInnerHTML, unprotected API routes under /api, secret leakage to client bundles, rewrites/redirects from untrusted user input + Common vulns: XSS through dangerous HTML rendering, IDOR/auth bypass in API handlers, Sensitive env/config exposure in client-side code + +Framework: SPRING + Security features: Spring Security provides auth, CSRF, and filter chain defaults, JPA/Hibernate prepared parameter binding by default, Bean validation can enforce request constraints + DO NOT FLAG: PreparedStatement/JPA named parameter usage, Spring Security CSRF/auth filters clearly active, Thymeleaf auto-escaped output + Watch for: @PreAuthorize missing on sensitive methods, JdbcTemplate/raw Statement with concatenation, csrf().disable() on browser session flows, Actuator endpoints exposed without auth + Common vulns: Authz bypass from weak method-level security, SQL injection in raw JDBC queries, Sensitive management endpoint exposure + +Framework: REACT + Security features: JSX escapes strings before DOM rendering, Component model encourages explicit data flow, Framework discourages direct DOM mutation + DO NOT FLAG: Standard JSX expression rendering, textContent assignment for untrusted text, sanitized HTML via trusted DOMPurify policy + Watch for: dangerouslySetInnerHTML, untrusted URL assignment to href/src, eval/new Function in client code, token/secret exposure in bundles + Common vulns: DOM XSS through unsafe HTML injection, Open redirect via unvalidated navigation targets, Sensitive data exposure in frontend artifacts + +Framework: VUE + Security features: Mustache template interpolation escapes HTML, Component props/events provide explicit boundaries, Router guards can enforce auth flows + DO NOT FLAG: escaped template interpolation {{ value }}, validated route guards protecting private routes, sanitized content rendered through safe components + Watch for: v-html with untrusted data, dynamic component/template compilation, unsafe URL bindings in href/src, client-side auth checks without server enforcement + Common vulns: XSS through v-html and unsafe render paths, Auth bypass from client-only route protection, Open redirect patterns in router navigation + +CONSTRAINTS: +- Prefer confirmed code evidence over heuristics. +- Base all findings on actual file content you read. Do not speculate. +- Do not include markdown, prose, or code fences in the output file. JSON only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Focus only on API-relevant code paths and endpoint handlers surfaced by RECON. +- Early stop rule: if you inspect 30 files without credible API issues, stop and return empty findings. +- Read the handler files first, then generate findings. +- After gathering evidence, write the JSON output file using your Write tool. diff --git a/go/internal/agents/hunt/testdata/golden/direct_prompt_config_secrets.txt b/go/internal/agents/hunt/testdata/golden/direct_prompt_config_secrets.txt new file mode 100644 index 0000000..fa0ffed --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/direct_prompt_config_secrets.txt @@ -0,0 +1,178 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are the SEC-AF Config/Secrets Hunter for HUNT phase. + +CONTEXT: +You receive RECON context summary and must produce HuntResult JSON for strategy config_secrets. + +RECON_CONTEXT: +Config and secrets-focused recon summary. + +Detected secret-like findings: 17 total, showing top 15: +- aws_access_key at config/env_0.yaml:3; confidence=high; is_test_value=False +- github_token at config/env_1.yaml:4; confidence=medium; is_test_value=True +- private_key at config/env_2.yaml:5; confidence=low; is_test_value=None +- slack_webhook at config/env_3.yaml:6; confidence=high; is_test_value=False +- generic_api_key at config/env_4.yaml:7; confidence=medium; is_test_value=True +- aws_access_key at config/env_5.yaml:8; confidence=low; is_test_value=None +- github_token at config/env_6.yaml:9; confidence=high; is_test_value=False +- private_key at config/env_7.yaml:10; confidence=medium; is_test_value=True +- slack_webhook at config/env_8.yaml:11; confidence=low; is_test_value=None +- generic_api_key at config/env_9.yaml:12; confidence=high; is_test_value=False +- aws_access_key at config/env_10.yaml:13; confidence=medium; is_test_value=True +- github_token at config/env_11.yaml:14; confidence=low; is_test_value=None +- private_key at config/env_12.yaml:15; confidence=high; is_test_value=False +- slack_webhook at config/env_13.yaml:16; confidence=medium; is_test_value=True +- generic_api_key at config/env_14.yaml:17; confidence=low; is_test_value=None + +Configuration weaknesses from recon: 16 total, showing top 15: +- logging at config/app_0.ini:0; risk=high; key=LOG_LEVEL +- tls at config/app_1.ini:13; risk=critical; key=SSL_VERIFY +- cors at config/app_2.ini:14; risk=high; key=ALLOW_ORIGIN +- headers at config/app_3.ini:0; risk=medium; key=X_FRAME_OPTIONS +- debug at config/app_4.ini:16; risk=critical; key=DJANGO_DEBUG +- storage at config/app_5.ini:0; risk=critical; key=BUCKET_ACL +- auth at config/app_6.ini:18; risk=medium; key=SESSION_TIMEOUT +- http at config/app_7.ini:19; risk=high; key=REDIRECT_HTTPS +- secrets at config/app_8.ini:20; risk=medium; key=n/a +- trace at config/app_9.ini:21; risk=low; key=OTEL_TRACE_ALL +- network at config/app_10.ini:0; risk=medium; key=BIND_ADDR +- errors at config/app_11.ini:23; risk=high; key=SHOW_STACKTRACE +- cache at config/app_12.ini:24; risk=low; key=CACHE_TTL +- db at config/app_13.ini:25; risk=critical; key=SSLMODE +- queue at config/app_14.ini:26; risk=low; key=PREFETCH + +Security/deployment context affecting config risk: 13 total, showing top 10: +- kubernetes ingress with TLS termination +- docker-compose exposes 5432 +- no WAF in front of /api +- secrets mounted from vault +- readiness probe on /healthz +- single replica for the worker +- TLS 1.2 minimum +- internal service mesh mTLS +- django.middleware.csrf.CsrfViewMiddleware +- helmet defaults + +OBJECTIVE: +Find real security findings for: +- CWE-798 Hardcoded Credentials +- CWE-259 Hard-Coded Password +- CWE-321 Hard-Coded Cryptographic Key +- CWE-16 Configuration weaknesses + +HUNT FOR: +- API keys, passwords, bearer tokens, database credentials, cloud credentials, signing secrets, encryption keys hardcoded in source. +- Insecure defaults and configuration issues such as DEBUG enabled in production paths, permissive CORS (`*`), insecure cookies, disabled TLS verification, missing security headers, exposed admin/debug endpoints. + +DO NOT FLAG: +- `.env.example`, `config.example.*`, template/sample config files. +- Test fixtures and explicit fake values used for tests. +- Documentation snippets. +- Secure environment-variable usage patterns (for example `os.getenv(...)`, `process.env.*`, `${ENV_VAR}`). + +OUTPUT REQUIREMENTS: +- Return strict JSON matching HuntResult. +- Set `hunter_strategy` to `config_secrets` on every finding. +- Use `finding_type` as `secrets` for hardcoded secrets and `config` for configuration issues. +- Include precise file path, line range, and code snippet evidence. +- Include related files when context spans multiple files. +- Add reviewer metadata in `description` using this suffix format for each finding: + `[is_test_file=;is_example=]` + +QUALITY BAR: +- Prioritize high-confidence, exploitable findings over noisy pattern matches. +- Distinguish real credentials from placeholders by context, naming, entropy, and usage. +- If uncertain, lower confidence instead of over-claiming. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. + +Language: JAVASCRIPT + Key sinks: eval, Function(), innerHTML, document.write, child_process.exec, new Function + Safe patterns (skip these): DOMPurify.sanitize(), textContent assignment, parameterized pg queries + DO NOT FLAG: React JSX expressions (auto-escaped), Angular template bindings (sanitized by default) + Framework notes: Check for React/Vue/Angular-specific XSS patterns. React auto-escapes JSX. Vue v-html is dangerous. + +Language: GO + Key sinks: fmt.Sprintf into SQL, exec.Command with user input, template.HTML(), os.Exec + Safe patterns (skip these): database/sql with ? placeholders, html/template (auto-escapes), exec.Command with separate args + DO NOT FLAG: GORM parameterized queries, html/template default escaping, database/sql prepared statements + Framework notes: Go's html/template auto-escapes. text/template does NOT. Check for text/template serving HTML. + +Language: RUBY + Key sinks: eval, system, exec, send, public_send, ERB.new with user input + Safe patterns (skip these): ActiveRecord parameterized queries, Rack::Utils.escape_html, sanitize helper in Rails + DO NOT FLAG: ActiveRecord where with hash conditions, Rails CSRF protection, Rails html_safe on constants + Framework notes: Rails auto-escapes ERB templates. raw/html_safe bypasses escaping. Check for mass assignment. + +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +Framework: NEXTJS + Security features: React JSX auto-escapes output by default, API routes can share centralized auth middleware, Server/client boundaries reduce accidental secret exposure + DO NOT FLAG: React JSX escaped rendering, getServerSideProps/getServerSession with proper auth checks, Typed route handlers with validated schema guards + Watch for: dangerouslySetInnerHTML, unprotected API routes under /api, secret leakage to client bundles, rewrites/redirects from untrusted user input + Common vulns: XSS through dangerous HTML rendering, IDOR/auth bypass in API handlers, Sensitive env/config exposure in client-side code + +Framework: SPRING + Security features: Spring Security provides auth, CSRF, and filter chain defaults, JPA/Hibernate prepared parameter binding by default, Bean validation can enforce request constraints + DO NOT FLAG: PreparedStatement/JPA named parameter usage, Spring Security CSRF/auth filters clearly active, Thymeleaf auto-escaped output + Watch for: @PreAuthorize missing on sensitive methods, JdbcTemplate/raw Statement with concatenation, csrf().disable() on browser session flows, Actuator endpoints exposed without auth + Common vulns: Authz bypass from weak method-level security, SQL injection in raw JDBC queries, Sensitive management endpoint exposure + +Framework: REACT + Security features: JSX escapes strings before DOM rendering, Component model encourages explicit data flow, Framework discourages direct DOM mutation + DO NOT FLAG: Standard JSX expression rendering, textContent assignment for untrusted text, sanitized HTML via trusted DOMPurify policy + Watch for: dangerouslySetInnerHTML, untrusted URL assignment to href/src, eval/new Function in client code, token/secret exposure in bundles + Common vulns: DOM XSS through unsafe HTML injection, Open redirect via unvalidated navigation targets, Sensitive data exposure in frontend artifacts + +Framework: VUE + Security features: Mustache template interpolation escapes HTML, Component props/events provide explicit boundaries, Router guards can enforce auth flows + DO NOT FLAG: escaped template interpolation {{ value }}, validated route guards protecting private routes, sanitized content rendered through safe components + Watch for: v-html with untrusted data, dynamic component/template compilation, unsafe URL bindings in href/src, client-side auth checks without server enforcement + Common vulns: XSS through v-html and unsafe render paths, Auth bypass from client-only route protection, Open redirect patterns in router navigation + +CONSTRAINTS: +- Take multiple turns: first explore files, then validate candidate findings, then output final JSON. +- No markdown or code fences. JSON only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Hunt strategy: config_secrets (CWE-798, CWE-259, CWE-321, CWE-16). +- Early stop rule: if you inspect 30 files without credible secrets/config issues, stop and return empty findings. +- Use RECON ConfigReport and SecurityContext to prioritize likely real findings. +- Take multiple turns: inspect files, validate exploitability signal, then build findings. + diff --git a/go/internal/agents/hunt/testdata/golden/direct_prompt_crypto.txt b/go/internal/agents/hunt/testdata/golden/direct_prompt_crypto.txt new file mode 100644 index 0000000..381f8bb --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/direct_prompt_crypto.txt @@ -0,0 +1,185 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are the SEC-AF Cryptography Hunter specializing in weak cryptography and predictable randomness. + +CONTEXT: +You are in SEC-AF HUNT phase and must return HuntResult JSON. +Use the RECON context summary below. +Focus on whether detected crypto patterns are used in security-sensitive contexts. + +RECON_CONTEXT: +Cryptography-focused recon summary. + +Crypto usage entries: 16 total. + +Algorithms and key handling (weak entries first): 16 total, showing top 15: +- algorithm=MD5, key_size=None, mode=None, context=password hashing, is_weak=True +- algorithm=RSA, key_size=1024, mode=None, context=token signing, is_weak=True +- algorithm=DES, key_size=56, mode=CBC, context=legacy export, is_weak=True +- algorithm=HMAC-SHA1, key_size=160, mode=None, context=webhook signature, is_weak=True +- algorithm=RC4, key_size=128, mode=None, context=unspecified, is_weak=True +- algorithm=SHA-1, key_size=None, mode=None, context=checksum, is_weak=True +- algorithm=AES, key_size=128, mode=ECB, context=legacy blob, is_weak=True +- algorithm=3DES, key_size=168, mode=CBC, context=legacy tape, is_weak=True +- algorithm=AES, key_size=256, mode=GCM, context=at-rest encryption, is_weak=False +- algorithm=SHA-256, key_size=None, mode=None, context=unspecified, is_weak=False +- algorithm=ChaCha20, key_size=256, mode=Poly1305, context=transport, is_weak=False +- algorithm=bcrypt, key_size=None, mode=None, context=password hashing, is_weak=False +- algorithm=ECDSA, key_size=256, mode=None, context=JWT signing, is_weak=None +- algorithm=PBKDF2, key_size=None, mode=None, context=key derivation, is_weak=False +- algorithm=Ed25519, key_size=256, mode=None, context=package signing, is_weak=False + +Potential secret/key findings from config scan: 17 total, showing top 10: +- aws_access_key at config/env_0.yaml:3 (confidence=high) +- github_token at config/env_1.yaml:4 (confidence=medium) +- private_key at config/env_2.yaml:5 (confidence=low) +- slack_webhook at config/env_3.yaml:6 (confidence=high) +- generic_api_key at config/env_4.yaml:7 (confidence=medium) +- aws_access_key at config/env_5.yaml:8 (confidence=low) +- github_token at config/env_6.yaml:9 (confidence=high) +- private_key at config/env_7.yaml:10 (confidence=medium) +- slack_webhook at config/env_8.yaml:11 (confidence=low) +- generic_api_key at config/env_9.yaml:12 (confidence=high) + +Deployment/TLS/security header signals: 14 total, showing top 10: +- kubernetes ingress with TLS termination +- docker-compose exposes 5432 +- no WAF in front of /api +- secrets mounted from vault +- readiness probe on /healthz +- single replica for the worker +- TLS 1.2 minimum +- internal service mesh mTLS +- Content-Security-Policy: default-src 'self' +- X-Content-Type-Options: nosniff + +TASK: +Find potential vulnerabilities related to weak cryptography. + +CWE COVERAGE (REQUIRED): +- CWE-326 Inadequate Encryption Strength +- CWE-327 Broken or Risky Cryptographic Algorithm +- CWE-328 Reversible One-Way Hash +- CWE-330 Insufficiently Random Values +- CWE-916 Weak Password Hashing +- CWE-259 Hard-Coded Password +- CWE-321 Hard-Coded Cryptographic Key +- CWE-798 Hardcoded Credentials + +WHAT TO HUNT: +- Weak algorithms: MD5, SHA1 for security decisions, DES, 3DES, RC4, ECB mode. +- Inadequate key lengths: RSA < 2048, AES < 128, weak DH parameters. +- Reversible password handling: base64/encoding used as if it were hashing. +- Predictable randomness: Math.random(), non-crypto random module usage for tokens/secrets, predictable seeds. +- Weak password storage: unsalted/fast hashes, low-iteration PBKDF2, custom insecure hashing. +- Hardcoded cryptographic keys, secrets, or static IV/nonce values. +- Security-critical usage contexts: password hashing, encryption, token/session generation, signing and verification, auth flows. +- Non-critical usage contexts: checksums, ETags, cache keys, dedup/fingerprinting. + +DO NOT FLAG: +- Crypto operations in tests or fixtures. +- MD5/SHA1 used only for non-security checksums/caching/ETags/cache keys. +- bcrypt/argon2/scrypt for password hashing (these are GOOD). +- Crypto libraries used correctly with sufficient key lengths and secure modes. +- Framework secure defaults (e.g., Django PBKDF2, bcrypt, argon2). + +OUTPUT: +- Return JSON matching HuntResult. +- findings must contain RawFinding entries with evidence-backed descriptions and correct CWE mapping. +- hunter_strategy should be "crypto". + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. + +Language: JAVASCRIPT + Key sinks: eval, Function(), innerHTML, document.write, child_process.exec, new Function + Safe patterns (skip these): DOMPurify.sanitize(), textContent assignment, parameterized pg queries + DO NOT FLAG: React JSX expressions (auto-escaped), Angular template bindings (sanitized by default) + Framework notes: Check for React/Vue/Angular-specific XSS patterns. React auto-escapes JSX. Vue v-html is dangerous. + +Language: GO + Key sinks: fmt.Sprintf into SQL, exec.Command with user input, template.HTML(), os.Exec + Safe patterns (skip these): database/sql with ? placeholders, html/template (auto-escapes), exec.Command with separate args + DO NOT FLAG: GORM parameterized queries, html/template default escaping, database/sql prepared statements + Framework notes: Go's html/template auto-escapes. text/template does NOT. Check for text/template serving HTML. + +Language: RUBY + Key sinks: eval, system, exec, send, public_send, ERB.new with user input + Safe patterns (skip these): ActiveRecord parameterized queries, Rack::Utils.escape_html, sanitize helper in Rails + DO NOT FLAG: ActiveRecord where with hash conditions, Rails CSRF protection, Rails html_safe on constants + Framework notes: Rails auto-escapes ERB templates. raw/html_safe bypasses escaping. Check for mass assignment. + +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +Framework: NEXTJS + Security features: React JSX auto-escapes output by default, API routes can share centralized auth middleware, Server/client boundaries reduce accidental secret exposure + DO NOT FLAG: React JSX escaped rendering, getServerSideProps/getServerSession with proper auth checks, Typed route handlers with validated schema guards + Watch for: dangerouslySetInnerHTML, unprotected API routes under /api, secret leakage to client bundles, rewrites/redirects from untrusted user input + Common vulns: XSS through dangerous HTML rendering, IDOR/auth bypass in API handlers, Sensitive env/config exposure in client-side code + +Framework: SPRING + Security features: Spring Security provides auth, CSRF, and filter chain defaults, JPA/Hibernate prepared parameter binding by default, Bean validation can enforce request constraints + DO NOT FLAG: PreparedStatement/JPA named parameter usage, Spring Security CSRF/auth filters clearly active, Thymeleaf auto-escaped output + Watch for: @PreAuthorize missing on sensitive methods, JdbcTemplate/raw Statement with concatenation, csrf().disable() on browser session flows, Actuator endpoints exposed without auth + Common vulns: Authz bypass from weak method-level security, SQL injection in raw JDBC queries, Sensitive management endpoint exposure + +Framework: REACT + Security features: JSX escapes strings before DOM rendering, Component model encourages explicit data flow, Framework discourages direct DOM mutation + DO NOT FLAG: Standard JSX expression rendering, textContent assignment for untrusted text, sanitized HTML via trusted DOMPurify policy + Watch for: dangerouslySetInnerHTML, untrusted URL assignment to href/src, eval/new Function in client code, token/secret exposure in bundles + Common vulns: DOM XSS through unsafe HTML injection, Open redirect via unvalidated navigation targets, Sensitive data exposure in frontend artifacts + +Framework: VUE + Security features: Mustache template interpolation escapes HTML, Component props/events provide explicit boundaries, Router guards can enforce auth flows + DO NOT FLAG: escaped template interpolation {{ value }}, validated route guards protecting private routes, sanitized content rendered through safe components + Watch for: v-html with untrusted data, dynamic component/template compilation, unsafe URL bindings in href/src, client-side auth checks without server enforcement + Common vulns: XSS through v-html and unsafe render paths, Auth bypass from client-only route protection, Open redirect patterns in router navigation + +CONSTRAINTS: +- Take multiple turns: first discover crypto-relevant files, then evaluate exploitability context. +- Prefer concrete evidence (file path, line range, code snippet, why risky in context). +- No markdown/code fences; JSON output only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Hunt strategy: crypto +- Early stop rule: if you inspect 30 files without credible crypto misuse, stop and return empty findings. +- Focus CWEs: CWE-326, CWE-327, CWE-328, CWE-330, CWE-916, CWE-259, CWE-321, CWE-798 +- Security-critical usage candidates: at-rest encryption, password hashing, token signing, webhook signature, password hashing, JWT signing, key derivation, package signing, password hashing +- Non-security usage candidates: checksum +- Prioritize weak crypto findings only when used in security-sensitive contexts; avoid checksum/cache-only noise. +- Take multiple turns to explore relevant files before finalizing findings. +- Write final JSON only when analysis is complete. diff --git a/go/internal/agents/hunt/testdata/golden/direct_prompt_data_exposure.txt b/go/internal/agents/hunt/testdata/golden/direct_prompt_data_exposure.txt new file mode 100644 index 0000000..e35874c --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/direct_prompt_data_exposure.txt @@ -0,0 +1,174 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are a data exposure security analyst for SEC-AF HUNT Phase. + +CONTEXT: +You are analyzing a real repository for Data Exposure Hunter. +Use the RECON context summary below to focus your investigation. + +RECON_CONTEXT: +Data exposure-focused recon summary. + +Data flows touching likely sensitive domains: 8 total, showing top 8: +- request.cookies['session'] -> redis.set; sanitized=True; files=app/auth/session_store.py +- form['password'] -> logger.info; sanitized=False; files=app/auth/service.py, app/obs/telemetry.go +- header['Authorization'] -> jwt.decode; sanitized=True; files=app/common/jwt_tools.py +- query['email'] -> smtp.send; sanitized=False; files=app/notify/mailer.rb +- body['card_number'] -> stripe.Charge.create; sanitized=True; files=app/billing/payments.go, app/billing/core.py +- session['role'] -> permission_check; sanitized=True; files=app/auth/permissions.py, app/auth/rbac.py +- token -> cache.set; sanitized=True; files=app/common/cache.py +- request.headers['X-User-Phone'] -> audit_log; sanitized=False; files=app/obs/audit.py + +Logging/exposure-related misconfig signals: 7 total, showing top 7: +- logging at config/app_0.ini:0; risk=high; key=LOG_LEVEL +- tls at config/app_1.ini:13; risk=critical; key=SSL_VERIFY +- debug at config/app_4.ini:16; risk=critical; key=DJANGO_DEBUG +- http at config/app_7.ini:19; risk=high; key=REDIRECT_HTTPS +- trace at config/app_9.ini:21; risk=low; key=OTEL_TRACE_ALL +- errors at config/app_11.ini:23; risk=high; key=SHOW_STACKTRACE +- exposure at config/app_15.ini:0; risk=medium; key=ADMIN_PATH + +Entry points and API surface with exposure risk: 20 total, showing top 10: +- GET /api/v1/thing/0 (app/api/thing_0.py:100, auth_required=True) +- POST /api/v1/thing/1 (app/api/thing_1.py:103, auth_required=False) +- PUT /api/v1/thing/2 (app/api/thing_2.py:106, auth_required=None) +- DELETE /api/v1/thing/3 (app/api/thing_3.py:109, auth_required=False) +- PATCH /api/v1/thing/4 (app/api/thing_4.py:112, auth_required=True) +- GET /api/v1/thing/5 (app/api/thing_5.py:115, auth_required=None) +- POST /api/v1/thing/6 (app/api/thing_6.py:118, auth_required=True) +- PUT /api/v1/thing/7 (app/api/thing_7.py:121, auth_required=False) +- DELETE /api/v1/thing/8 (app/api/thing_8.py:124, auth_required=None) +- PATCH /api/v1/thing/9 (app/api/thing_9.py:127, auth_required=False) + +TASK: +Find potential data exposure vulnerabilities and return HuntResult JSON. + +WORKFLOW (follow these steps in order): +1. Read the source files referenced in the RECON context above to understand the codebase. +2. Search for logging calls, error handlers, data persistence, HTTP clients, and configuration settings in the source code. +3. Read the identified files to confirm vulnerabilities with actual code evidence. +4. For each confirmed vulnerability, record the file path, line number, and relevant code snippet. +5. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +COVERAGE: +- CWE-200: information exposure (debug endpoints, verbose responses, server/internal metadata leakage) +- CWE-209: detailed error message leakage (stack traces, SQL errors, file paths, internal host/IP details) +- CWE-532: sensitive data in logs (PII, credentials, tokens, secrets, auth headers, session IDs) +- CWE-312: cleartext storage of sensitive data (passwords, tokens, PII persisted without encryption) +- CWE-319: cleartext transmission of sensitive data (HTTP transport for auth/session/PII or disabled TLS verification) + +FOCUS AREAS: +- Logging calls, logger middleware, and request/response logging filters +- Error handlers, exception formatters, debug/traceback output, API error payloads +- Persistence layer, model fields, data-at-rest handling, local file writes/caches/backups +- HTTP clients, webhook integrations, transport settings, TLS/SSL flags +- Configuration and deployment toggles that affect production exposure + +REQUIRED OUTPUT: +- Return JSON that strictly matches HuntResult. +- Populate findings with concrete evidence; chains may be empty. +- For each finding, fill all required RawFinding fields including: + - hunter_strategy = "data_exposure" + - cwe_id and cwe_name aligned to this strategy + - file_path, start_line, end_line, code_snippet + - estimated_severity and confidence + - fingerprint stable for file_path + start_line + cwe_id +- Distinguish production impact vs development-only behavior in description and confidence. +- Empty arrays are acceptable if no vulnerabilities are confirmed. + +DO NOT FLAG: +- Logging of non-sensitive operational data (request IDs, timestamps, non-sensitive metrics) +- Error verbosity that is clearly limited to development/local debug mode +- HTTPS URLs or TLS-enabled transmissions + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. + +Language: JAVASCRIPT + Key sinks: eval, Function(), innerHTML, document.write, child_process.exec, new Function + Safe patterns (skip these): DOMPurify.sanitize(), textContent assignment, parameterized pg queries + DO NOT FLAG: React JSX expressions (auto-escaped), Angular template bindings (sanitized by default) + Framework notes: Check for React/Vue/Angular-specific XSS patterns. React auto-escapes JSX. Vue v-html is dangerous. + +Language: GO + Key sinks: fmt.Sprintf into SQL, exec.Command with user input, template.HTML(), os.Exec + Safe patterns (skip these): database/sql with ? placeholders, html/template (auto-escapes), exec.Command with separate args + DO NOT FLAG: GORM parameterized queries, html/template default escaping, database/sql prepared statements + Framework notes: Go's html/template auto-escapes. text/template does NOT. Check for text/template serving HTML. + +Language: RUBY + Key sinks: eval, system, exec, send, public_send, ERB.new with user input + Safe patterns (skip these): ActiveRecord parameterized queries, Rack::Utils.escape_html, sanitize helper in Rails + DO NOT FLAG: ActiveRecord where with hash conditions, Rails CSRF protection, Rails html_safe on constants + Framework notes: Rails auto-escapes ERB templates. raw/html_safe bypasses escaping. Check for mass assignment. + +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +Framework: NEXTJS + Security features: React JSX auto-escapes output by default, API routes can share centralized auth middleware, Server/client boundaries reduce accidental secret exposure + DO NOT FLAG: React JSX escaped rendering, getServerSideProps/getServerSession with proper auth checks, Typed route handlers with validated schema guards + Watch for: dangerouslySetInnerHTML, unprotected API routes under /api, secret leakage to client bundles, rewrites/redirects from untrusted user input + Common vulns: XSS through dangerous HTML rendering, IDOR/auth bypass in API handlers, Sensitive env/config exposure in client-side code + +Framework: SPRING + Security features: Spring Security provides auth, CSRF, and filter chain defaults, JPA/Hibernate prepared parameter binding by default, Bean validation can enforce request constraints + DO NOT FLAG: PreparedStatement/JPA named parameter usage, Spring Security CSRF/auth filters clearly active, Thymeleaf auto-escaped output + Watch for: @PreAuthorize missing on sensitive methods, JdbcTemplate/raw Statement with concatenation, csrf().disable() on browser session flows, Actuator endpoints exposed without auth + Common vulns: Authz bypass from weak method-level security, SQL injection in raw JDBC queries, Sensitive management endpoint exposure + +Framework: REACT + Security features: JSX escapes strings before DOM rendering, Component model encourages explicit data flow, Framework discourages direct DOM mutation + DO NOT FLAG: Standard JSX expression rendering, textContent assignment for untrusted text, sanitized HTML via trusted DOMPurify policy + Watch for: dangerouslySetInnerHTML, untrusted URL assignment to href/src, eval/new Function in client code, token/secret exposure in bundles + Common vulns: DOM XSS through unsafe HTML injection, Open redirect via unvalidated navigation targets, Sensitive data exposure in frontend artifacts + +Framework: VUE + Security features: Mustache template interpolation escapes HTML, Component props/events provide explicit boundaries, Router guards can enforce auth flows + DO NOT FLAG: escaped template interpolation {{ value }}, validated route guards protecting private routes, sanitized content rendered through safe components + Watch for: v-html with untrusted data, dynamic component/template compilation, unsafe URL bindings in href/src, client-side auth checks without server enforcement + Common vulns: XSS through v-html and unsafe render paths, Auth bypass from client-only route protection, Open redirect patterns in router navigation + +CONSTRAINTS: +- Prefer evidence over assumptions; if uncertain, lower confidence. +- No markdown or code fences; JSON output only. +- Base all findings on actual file content you read. Do not speculate. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Strategy: data_exposure +- Early stop rule: if you inspect 30 files without credible exposure risk, stop and return empty findings. +- Use multiple turns: inspect files first, then produce findings. +- Return final JSON only when analysis is complete. diff --git a/go/internal/agents/hunt/testdata/golden/direct_prompt_sha256.json b/go/internal/agents/hunt/testdata/golden/direct_prompt_sha256.json new file mode 100644 index 0000000..57c5e02 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/direct_prompt_sha256.json @@ -0,0 +1,66 @@ +{ + "scan": { + "injection": "cf6d0f6d8415e5067bb8ff4d84beceef1f9013e3ac3e879d29d949d5668656eb", + "xss": "5a0a69fbc2252506bfa1fd6b51dfced7182463f3e1bcad8be9b95a106088b82e", + "dos": "43a7d16d16a4c28f75ef8ee35d31e9d830edef8c2882d1d69341fec29ba2dd2e", + "ssrf": "565502128e548af4ac41afbbc3b3229c6a02f777d01c6b65fd9ce6b5d565a91c", + "auth": "03d72e8334604df7be4aa1b441848f8ebe79067b5753811304a4bfcf5e77dd37", + "crypto": "c012d99195f420df2d380335fdb14ddf43e49600a0510d0e299155520a6fe2d9", + "business_logic": "001712d5e7602460e52df7c48512185d08375b5476462d41da55a60eda43ece8", + "logic": "001712d5e7602460e52df7c48512185d08375b5476462d41da55a60eda43ece8", + "data_exposure": "4a16b73daa97aa7d3b0448e97453da6231d9a0d81b3a08aa02622c05476ba6e5", + "supply_chain": "d7497cb488eb0c9edd915da49c4dcb9b8b69cf6faa54fe20b5bbd3589591ed9f", + "config_secrets": "104a6110e168058d19a47c04406f17ecdd5dabe3f685e03583672dc4ffeac2bd", + "api_security": "4170958685e8225165e479f95e92ada12e3851fa04d04b31d9d36e51b9459e6e" + }, + "enrich": { + "injection": [ + "760b133668a3a13d6a73b0309bb923afc721c7dc69f88100a12b7afcbe42c8bd", + "c7a31ca70d9eec5b43bc9c0f9e3121e45a5992ff54cea6bab5cff81b84e8d8e7" + ], + "xss": [ + "efcec35e1150526419a36b39a4526137291b850b4da50e2450d63640e8165839", + "6e14997215bf24ee8adff3917d2a6eaec760b6f17b3a46373970e77718b4b59c" + ], + "dos": [ + "f398b344e907bda92b5738e90d47b329c9e9e6d4150d9c66b31cf72e055ba76e", + "b7cf07efc7a02f23e0dba97955a5835b1e59d2fb15b27bebdd3dd7d19fe5dccd" + ], + "ssrf": [ + "47657a189efca7944a414b5102e4956710e1c6e2e48fd09195dfee77c1689010", + "b8349ba85ea6c34d7909e8cc9b8583a7118171e03a1f5d880bd799f957416a5e" + ], + "auth": [ + "48b130cb17467b5c680f8e0eb15bbcd766b1676ad8d5f0407caad11a4bd016c6", + "9b6d972be51353fba2cb51e18f783145a720ae64eefff1cb5409755f8717ba01" + ], + "crypto": [ + "108ce0d993396356401ee622a0e7bb343f225201f33ed4fcb90f0d77b7f77588", + "ac4da4c1863fa9d7b7fad321e131a8279850c744c8b4d74031cfce08f0405ebf" + ], + "business_logic": [ + "7a027cf03d9d5cd8ef93225791970061a36f6e66998a663004049be0285ca639", + "e1bd25b5ecb309de78774aa5764ea3daa1059215a214bd948aebc6fdcb27646f" + ], + "logic": [ + "7a027cf03d9d5cd8ef93225791970061a36f6e66998a663004049be0285ca639", + "e1bd25b5ecb309de78774aa5764ea3daa1059215a214bd948aebc6fdcb27646f" + ], + "data_exposure": [ + "696eef5f90cd79632826c2c082bbb53fee6dcd295cc2fdb909911182dd5329ba", + "3c519a45da85098689781f78f225effb8504c2c1d6391f1e26de31c3964a3d48" + ], + "supply_chain": [ + "3c93052ed54c8d765a528dec27067ae43a6718e4c611408e857d6cdd0c914920", + "989cd6965263ea82ba5bf8599856cb8e381348299d8caf0546b6100e79ddafac" + ], + "config_secrets": [ + "22da7a4fd1d5cf3d8e29c7e25a2dec9955c3ccd8be30c5696876126582a9abca", + "4b930ca980b38041664d226f1e9398c366267f5631d7c2959298938440a58f5a" + ], + "api_security": [ + "0d6f7a125e6dad5680e9587ddc690e557a6ac7ca1d30ecc3dcc9c71fb550f126", + "f49ff086a9ded47ddf6409f92156f08d66b3d46a57b03d30d6ac508e384d48f0" + ] + } +} diff --git a/go/internal/agents/hunt/testdata/golden/direct_prompt_supply_chain.txt b/go/internal/agents/hunt/testdata/golden/direct_prompt_supply_chain.txt new file mode 100644 index 0000000..9e7b5ff --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/direct_prompt_supply_chain.txt @@ -0,0 +1,169 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are SEC-AF's Supply Chain Hunter specializing in dependency risk and package ecosystem abuse. + +CONTEXT: +You are in HUNT Phase and must output a HuntResult JSON object for supply-chain findings. +Use the RECON context summary below as primary evidence. + +RECON_CONTEXT: +Supply-chain-focused recon summary. + +Dependency inventory: direct=7, transitive=143, SBOM entries=14. + +Known CVE exposure (reachable/high severity first): 18 total, showing top 15: +- CVE-2023-0014 in log4j 2.14.0 (fixed=2.17.1, cvss=10.0, epss=0.97, direct=False, reachable=True) +- CVE-2023-0003 in lodash 4.17.19 (fixed=4.17.21, cvss=9.8, epss=0.9, direct=True, reachable=True) +- CVE-2023-0001 in django 4.2.1 (fixed=4.2.5, cvss=9.8, epss=0.5, direct=True, reachable=True) +- CVE-2023-0013 in pyyaml 5.3 (fixed=5.4, cvss=9.8, epss=0.5, direct=False, reachable=True) +- CVE-2023-0008 in nokogiri 1.13.6 (fixed=1.13.10, cvss=8.8, epss=0.3, direct=False, reachable=True) +- CVE-2023-0018 in curl 7.68.0 (fixed=7.88.0, cvss=8.1, epss=None, direct=True, reachable=True) +- CVE-2023-0009 in golang.org/x/net 0.7.0 (fixed=0.17.0, cvss=7.5, epss=0.44, direct=False, reachable=True) +- CVE-2023-0012 in requests 2.31.0 (fixed=2.32.0, cvss=6.5, epss=0.07, direct=True, reachable=True) +- CVE-2023-0005 in axios 0.21.1 (fixed=0.21.2, cvss=5.3, epss=0.02, direct=False, reachable=True) +- CVE-2023-0017 in libxml2 2.9.10 (fixed=unknown, cvss=None, epss=0.25, direct=False, reachable=True) +- CVE-2023-0010 in rails 7.0.4 (fixed=7.0.7, cvss=9.1, epss=0.6, direct=True, reachable=False) +- CVE-2023-0016 in zlib 1.2.11 (fixed=1.2.12, cvss=8.2, epss=0.04, direct=False, reachable=False) +- CVE-2023-0002 in urllib3 1.26.5 (fixed=unknown, cvss=7.5, epss=0.1, direct=False, reachable=False) +- CVE-2023-0015 in openssl 1.1.1k (fixed=1.1.1t, cvss=7.4, epss=0.12, direct=False, reachable=None) +- CVE-2023-0006 in jinja2 3.1.2 (fixed=unknown, cvss=6.1, epss=None, direct=False, reachable=False) + +Outdated dependencies: 12 total, showing top 10: +- pkg-0: 1.0.0 -> 2.0.0 (direct=True) +- pkg-1: 1.1.0 -> 2.1.0 (direct=False) +- pkg-2: 1.2.0 -> 2.2.0 (direct=True) +- pkg-3: 1.3.0 -> 2.3.0 (direct=False) +- pkg-4: 1.4.0 -> 2.4.0 (direct=True) +- pkg-5: 1.5.0 -> 2.5.0 (direct=False) +- pkg-6: 1.6.0 -> 2.6.0 (direct=True) +- pkg-7: 1.7.0 -> 2.7.0 (direct=False) +- pkg-8: 1.8.0 -> 2.8.0 (direct=True) +- pkg-9: 1.9.0 -> 2.9.0 (direct=False) + +Primary dependency ecosystems in this repo: 12 total, showing top 10: +- go: github.com/gin-gonic/gin@1.9.0 +- go: golang.org/x/net@0.7.0 +- npm: axios@0.21.1 +- npm: express@4.17.1 +- npm: lodash@4.17.19 +- npm: minimist@1.2.5 +- pypi: django@4.2.1 +- pypi: jinja2@3.1.2 +- pypi: requests@2.31.0 +- pypi: urllib3@1.26.5 + +TASK: +Identify supply-chain vulnerabilities with emphasis on CWE-1104 (unmaintained third-party components) +and CWE-829 (inclusion of functionality from untrusted control sphere). + +SCOPE: +- Analyze dependency manifests and lockfiles across ecosystems (npm/yarn/pnpm, Python, Go, Rust, etc.). +- Use RECON dependency context (SBOM, known CVEs, outdated packages) as primary evidence. +- Detect known vulnerable versions and include CVE IDs when available. +- Flag unmaintained/abandoned packages and stale dependencies with security impact. +- Flag typosquatting and look-alike package names likely to be malicious. +- Flag untrusted package sources, private-registry confusion, and dependency confusion patterns. +- Flag risky version pinning posture: missing lockfile, wildcard ranges, loose semver constraints. +- Flag lockfile drift/integrity issues where manifest and lock data are inconsistent. + +OUTPUT: +- Return JSON that strictly matches HuntResult. +- Use finding_type "sca" for dependency findings. +- Every finding must include concrete package evidence (package name, version, file path, and rationale). +- Include CWE mapping: CWE-1104 or CWE-829 for each finding. +- Include CVE identifiers in description/code_snippet when available from recon evidence. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. + +Language: JAVASCRIPT + Key sinks: eval, Function(), innerHTML, document.write, child_process.exec, new Function + Safe patterns (skip these): DOMPurify.sanitize(), textContent assignment, parameterized pg queries + DO NOT FLAG: React JSX expressions (auto-escaped), Angular template bindings (sanitized by default) + Framework notes: Check for React/Vue/Angular-specific XSS patterns. React auto-escapes JSX. Vue v-html is dangerous. + +Language: GO + Key sinks: fmt.Sprintf into SQL, exec.Command with user input, template.HTML(), os.Exec + Safe patterns (skip these): database/sql with ? placeholders, html/template (auto-escapes), exec.Command with separate args + DO NOT FLAG: GORM parameterized queries, html/template default escaping, database/sql prepared statements + Framework notes: Go's html/template auto-escapes. text/template does NOT. Check for text/template serving HTML. + +Language: RUBY + Key sinks: eval, system, exec, send, public_send, ERB.new with user input + Safe patterns (skip these): ActiveRecord parameterized queries, Rack::Utils.escape_html, sanitize helper in Rails + DO NOT FLAG: ActiveRecord where with hash conditions, Rails CSRF protection, Rails html_safe on constants + Framework notes: Rails auto-escapes ERB templates. raw/html_safe bypasses escaping. Check for mass assignment. + +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +Framework: NEXTJS + Security features: React JSX auto-escapes output by default, API routes can share centralized auth middleware, Server/client boundaries reduce accidental secret exposure + DO NOT FLAG: React JSX escaped rendering, getServerSideProps/getServerSession with proper auth checks, Typed route handlers with validated schema guards + Watch for: dangerouslySetInnerHTML, unprotected API routes under /api, secret leakage to client bundles, rewrites/redirects from untrusted user input + Common vulns: XSS through dangerous HTML rendering, IDOR/auth bypass in API handlers, Sensitive env/config exposure in client-side code + +Framework: SPRING + Security features: Spring Security provides auth, CSRF, and filter chain defaults, JPA/Hibernate prepared parameter binding by default, Bean validation can enforce request constraints + DO NOT FLAG: PreparedStatement/JPA named parameter usage, Spring Security CSRF/auth filters clearly active, Thymeleaf auto-escaped output + Watch for: @PreAuthorize missing on sensitive methods, JdbcTemplate/raw Statement with concatenation, csrf().disable() on browser session flows, Actuator endpoints exposed without auth + Common vulns: Authz bypass from weak method-level security, SQL injection in raw JDBC queries, Sensitive management endpoint exposure + +Framework: REACT + Security features: JSX escapes strings before DOM rendering, Component model encourages explicit data flow, Framework discourages direct DOM mutation + DO NOT FLAG: Standard JSX expression rendering, textContent assignment for untrusted text, sanitized HTML via trusted DOMPurify policy + Watch for: dangerouslySetInnerHTML, untrusted URL assignment to href/src, eval/new Function in client code, token/secret exposure in bundles + Common vulns: DOM XSS through unsafe HTML injection, Open redirect via unvalidated navigation targets, Sensitive data exposure in frontend artifacts + +Framework: VUE + Security features: Mustache template interpolation escapes HTML, Component props/events provide explicit boundaries, Router guards can enforce auth flows + DO NOT FLAG: escaped template interpolation {{ value }}, validated route guards protecting private routes, sanitized content rendered through safe components + Watch for: v-html with untrusted data, dynamic component/template compilation, unsafe URL bindings in href/src, client-side auth checks without server enforcement + Common vulns: XSS through v-html and unsafe render paths, Auth bypass from client-only route protection, Open redirect patterns in router navigation + +CONSTRAINTS: +- Take multiple turns: inspect relevant files first, then build findings incrementally. +- Do not invent package versions, CVEs, or exploitability claims. +- Prefer high-confidence findings with explicit evidence over speculative findings. +- No markdown/code fences; JSON output only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Hunt strategy: supply_chain (CWE-1104, CWE-829). +- Early stop rule: if you inspect 30 manifests/files without credible dependency risk, stop and return empty findings. +- Focus manifests/lockfiles (package.json, requirements.txt, go.mod, Pipfile, poetry.lock, package-lock.json, yarn.lock, pnpm-lock.yaml, Cargo.toml). +- Take multiple turns: inspect manifests/lockfiles, validate dependency risks, then produce final structured findings. +- Write final JSON only when analysis is complete. + diff --git a/go/internal/agents/hunt/testdata/golden/enrich_location_input.json b/go/internal/agents/hunt/testdata/golden/enrich_location_input.json new file mode 100644 index 0000000..5700fdc --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/enrich_location_input.json @@ -0,0 +1,11 @@ +{ + "location": { + "file_path": "app/api/users.py", + "start_line": 42, + "code_snippet": "query = f\"SELECT * FROM users WHERE id = {user_id}\"\ncursor.execute(query)", + "pattern_type": "sql_injection" + }, + "finding_type": "sast", + "strategy": "injection", + "recon_context": "RECON CONTEXT LINE 1\nRECON CONTEXT LINE 2" +} diff --git a/go/internal/agents/hunt/testdata/golden/enrich_location_prompt.txt b/go/internal/agents/hunt/testdata/golden/enrich_location_prompt.txt new file mode 100644 index 0000000..d6c634b --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/enrich_location_prompt.txt @@ -0,0 +1,42 @@ +ROLE: +You are Step 2 FindingEnricher for SEC-AF HUNT phase. + +TASK: +Enrich one scanned vulnerability location into one complete finding. + +CONTEXT: +- Finding type: sast +- Hunter strategy: injection + +RECON CONTEXT: +RECON CONTEXT LINE 1 +RECON CONTEXT LINE 2 + +LOCATION: +- File path: app/api/users.py +- Start line: 42 +- Pattern type: sql_injection +- Code snippet: +query = f"SELECT * FROM users WHERE id = {user_id}" +cursor.execute(query) + +WORKFLOW (follow these steps in order): +1. Read the file at the file path above, focusing on the code around the start line. +2. Analyze the code for the specific vulnerability pattern indicated by the finding type and strategy. +3. Determine the CWE ID, severity, and confidence based on the code evidence. +4. Write the final JSON output file with exactly these 6 fields. + +OUTPUT: +- Return a SINGLE JSON object (not an array, not wrapped in another object). +- The JSON object must have exactly these 6 string fields: + - title: A short descriptive title for this finding + - description: Detailed explanation of the vulnerability and its impact + - cwe_id: The CWE identifier (e.g. "CWE-89") + - severity: One of: critical, high, medium, low, info + - confidence: One of: high, medium, low + - data_flow_summary: A plain string describing the data flow (source to sink), or empty string if not applicable + +CONSTRAINTS: +- Ground every statement in the provided code location and recon context. +- Do NOT wrap the output in {"findings": [...]} or any other wrapper — return a flat object. +- Do not include markdown or prose outside JSON. diff --git a/go/internal/agents/hunt/testdata/golden/hunter_empty_locations.json b/go/internal/agents/hunt/testdata/golden/hunter_empty_locations.json new file mode 100644 index 0000000..61a016d --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/hunter_empty_locations.json @@ -0,0 +1,170 @@ +{ + "injection": { + "scan_calls": 1, + "enrich_calls": 0, + "want": { + "findings": [], + "chains": [], + "total_raw": 0, + "deduplicated_count": 0, + "chain_count": 0, + "strategies_run": [], + "hunt_duration_seconds": 0.0 + } + }, + "xss": { + "scan_calls": 1, + "enrich_calls": 0, + "want": { + "findings": [], + "chains": [], + "total_raw": 0, + "deduplicated_count": 0, + "chain_count": 0, + "strategies_run": [], + "hunt_duration_seconds": 0.0 + } + }, + "dos": { + "scan_calls": 1, + "enrich_calls": 0, + "want": { + "findings": [], + "chains": [], + "total_raw": 0, + "deduplicated_count": 0, + "chain_count": 0, + "strategies_run": [], + "hunt_duration_seconds": 0.0 + } + }, + "ssrf": { + "scan_calls": 1, + "enrich_calls": 0, + "want": { + "findings": [], + "chains": [], + "total_raw": 0, + "deduplicated_count": 0, + "chain_count": 0, + "strategies_run": [], + "hunt_duration_seconds": 0.0 + } + }, + "auth": { + "scan_calls": 1, + "enrich_calls": 0, + "want": { + "findings": [], + "chains": [], + "total_raw": 0, + "deduplicated_count": 0, + "chain_count": 0, + "strategies_run": [], + "hunt_duration_seconds": 0.0 + } + }, + "crypto": { + "scan_calls": 1, + "enrich_calls": 0, + "want": { + "findings": [], + "chains": [], + "total_raw": 0, + "deduplicated_count": 0, + "chain_count": 0, + "strategies_run": [ + "crypto" + ], + "hunt_duration_seconds": 0.0 + } + }, + "business_logic": { + "scan_calls": 1, + "enrich_calls": 0, + "want": { + "findings": [], + "chains": [], + "total_raw": 0, + "deduplicated_count": 0, + "chain_count": 0, + "strategies_run": [ + "business_logic" + ], + "hunt_duration_seconds": 0.0 + } + }, + "logic": { + "scan_calls": 1, + "enrich_calls": 0, + "want": { + "findings": [], + "chains": [], + "total_raw": 0, + "deduplicated_count": 0, + "chain_count": 0, + "strategies_run": [ + "business_logic" + ], + "hunt_duration_seconds": 0.0 + } + }, + "data_exposure": { + "scan_calls": 1, + "enrich_calls": 0, + "want": { + "findings": [], + "chains": [], + "total_raw": 0, + "deduplicated_count": 0, + "chain_count": 0, + "strategies_run": [ + "data_exposure" + ], + "hunt_duration_seconds": 0.0 + } + }, + "supply_chain": { + "scan_calls": 1, + "enrich_calls": 0, + "want": { + "findings": [], + "chains": [], + "total_raw": 0, + "deduplicated_count": 0, + "chain_count": 0, + "strategies_run": [ + "supply_chain" + ], + "hunt_duration_seconds": 0.0 + } + }, + "config_secrets": { + "scan_calls": 1, + "enrich_calls": 0, + "want": { + "findings": [], + "chains": [], + "total_raw": 0, + "deduplicated_count": 0, + "chain_count": 0, + "strategies_run": [], + "hunt_duration_seconds": 0.0 + } + }, + "api_security": { + "scan_calls": 1, + "enrich_calls": 0, + "want": { + "findings": [], + "chains": [], + "total_raw": 0, + "deduplicated_count": 0, + "chain_count": 0, + "strategies_run": [ + "api_security" + ], + "hunt_duration_seconds": 0.0 + } + } +} diff --git a/go/internal/agents/hunt/testdata/golden/hunter_results.json b/go/internal/agents/hunt/testdata/golden/hunter_results.json new file mode 100644 index 0000000..effccc9 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/hunter_results.json @@ -0,0 +1,710 @@ +{ + "injection": { + "findings": [ + { + "id": "", + "hunter_strategy": "injection", + "title": "SQL injection in user lookup", + "description": "user_id flows unescaped into an f-string query.", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "CWE-89", + "owasp_category": null, + "file_path": "app/api/users.py", + "start_line": 42, + "end_line": 43, + "function_name": null, + "code_snippet": "query = f\"SELECT * FROM users WHERE id = {user_id}\"\ncursor.execute(query)", + "estimated_severity": "high", + "confidence": "high", + "data_flow": [ + { + "file_path": "app/api/users.py", + "line": 42, + "component": "injection", + "operation": "request.args['id'] -> query -> cursor.execute" + } + ], + "related_files": [], + "fingerprint": "" + }, + { + "id": "", + "hunter_strategy": "injection", + "title": "Weak hash for password storage", + "description": "MD5 used to derive a credential digest.", + "finding_type": "sast", + "cwe_id": "CWE-327", + "cwe_name": "CWE-327", + "owasp_category": null, + "file_path": "app/utils/hash.py", + "start_line": 7, + "end_line": 7, + "function_name": null, + "code_snippet": "digest = hashlib.md5(password).hexdigest()", + "estimated_severity": "medium", + "confidence": "medium", + "data_flow": null, + "related_files": [], + "fingerprint": "" + } + ], + "chains": [], + "total_raw": 2, + "deduplicated_count": 2, + "chain_count": 0, + "strategies_run": [ + "injection" + ], + "hunt_duration_seconds": 0.0 + }, + "xss": { + "findings": [ + { + "id": "", + "hunter_strategy": "xss", + "title": "SQL injection in user lookup", + "description": "user_id flows unescaped into an f-string query.", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "CWE-89", + "owasp_category": null, + "file_path": "app/api/users.py", + "start_line": 42, + "end_line": 43, + "function_name": null, + "code_snippet": "query = f\"SELECT * FROM users WHERE id = {user_id}\"\ncursor.execute(query)", + "estimated_severity": "high", + "confidence": "high", + "data_flow": [ + { + "file_path": "app/api/users.py", + "line": 42, + "component": "xss", + "operation": "request.args['id'] -> query -> cursor.execute" + } + ], + "related_files": [], + "fingerprint": "" + }, + { + "id": "", + "hunter_strategy": "xss", + "title": "Weak hash for password storage", + "description": "MD5 used to derive a credential digest.", + "finding_type": "sast", + "cwe_id": "CWE-327", + "cwe_name": "CWE-327", + "owasp_category": null, + "file_path": "app/utils/hash.py", + "start_line": 7, + "end_line": 7, + "function_name": null, + "code_snippet": "digest = hashlib.md5(password).hexdigest()", + "estimated_severity": "medium", + "confidence": "medium", + "data_flow": null, + "related_files": [], + "fingerprint": "" + } + ], + "chains": [], + "total_raw": 2, + "deduplicated_count": 2, + "chain_count": 0, + "strategies_run": [ + "xss" + ], + "hunt_duration_seconds": 0.0 + }, + "dos": { + "findings": [ + { + "id": "", + "hunter_strategy": "dos", + "title": "SQL injection in user lookup", + "description": "user_id flows unescaped into an f-string query.", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "CWE-89", + "owasp_category": null, + "file_path": "app/api/users.py", + "start_line": 42, + "end_line": 43, + "function_name": null, + "code_snippet": "query = f\"SELECT * FROM users WHERE id = {user_id}\"\ncursor.execute(query)", + "estimated_severity": "high", + "confidence": "high", + "data_flow": [ + { + "file_path": "app/api/users.py", + "line": 42, + "component": "dos", + "operation": "request.args['id'] -> query -> cursor.execute" + } + ], + "related_files": [], + "fingerprint": "" + }, + { + "id": "", + "hunter_strategy": "dos", + "title": "Weak hash for password storage", + "description": "MD5 used to derive a credential digest.", + "finding_type": "sast", + "cwe_id": "CWE-327", + "cwe_name": "CWE-327", + "owasp_category": null, + "file_path": "app/utils/hash.py", + "start_line": 7, + "end_line": 7, + "function_name": null, + "code_snippet": "digest = hashlib.md5(password).hexdigest()", + "estimated_severity": "medium", + "confidence": "medium", + "data_flow": null, + "related_files": [], + "fingerprint": "" + } + ], + "chains": [], + "total_raw": 2, + "deduplicated_count": 2, + "chain_count": 0, + "strategies_run": [ + "dos" + ], + "hunt_duration_seconds": 0.0 + }, + "ssrf": { + "findings": [ + { + "id": "", + "hunter_strategy": "ssrf", + "title": "SQL injection in user lookup", + "description": "user_id flows unescaped into an f-string query.", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "CWE-89", + "owasp_category": null, + "file_path": "app/api/users.py", + "start_line": 42, + "end_line": 43, + "function_name": null, + "code_snippet": "query = f\"SELECT * FROM users WHERE id = {user_id}\"\ncursor.execute(query)", + "estimated_severity": "high", + "confidence": "high", + "data_flow": [ + { + "file_path": "app/api/users.py", + "line": 42, + "component": "ssrf", + "operation": "request.args['id'] -> query -> cursor.execute" + } + ], + "related_files": [], + "fingerprint": "" + }, + { + "id": "", + "hunter_strategy": "ssrf", + "title": "Weak hash for password storage", + "description": "MD5 used to derive a credential digest.", + "finding_type": "sast", + "cwe_id": "CWE-327", + "cwe_name": "CWE-327", + "owasp_category": null, + "file_path": "app/utils/hash.py", + "start_line": 7, + "end_line": 7, + "function_name": null, + "code_snippet": "digest = hashlib.md5(password).hexdigest()", + "estimated_severity": "medium", + "confidence": "medium", + "data_flow": null, + "related_files": [], + "fingerprint": "" + } + ], + "chains": [], + "total_raw": 2, + "deduplicated_count": 2, + "chain_count": 0, + "strategies_run": [ + "ssrf" + ], + "hunt_duration_seconds": 0.0 + }, + "auth": { + "findings": [ + { + "id": "", + "hunter_strategy": "auth", + "title": "SQL injection in user lookup", + "description": "user_id flows unescaped into an f-string query.", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "CWE-89", + "owasp_category": null, + "file_path": "app/api/users.py", + "start_line": 42, + "end_line": 43, + "function_name": null, + "code_snippet": "query = f\"SELECT * FROM users WHERE id = {user_id}\"\ncursor.execute(query)", + "estimated_severity": "high", + "confidence": "high", + "data_flow": [ + { + "file_path": "app/api/users.py", + "line": 42, + "component": "auth", + "operation": "request.args['id'] -> query -> cursor.execute" + } + ], + "related_files": [], + "fingerprint": "" + }, + { + "id": "", + "hunter_strategy": "auth", + "title": "Weak hash for password storage", + "description": "MD5 used to derive a credential digest.", + "finding_type": "sast", + "cwe_id": "CWE-327", + "cwe_name": "CWE-327", + "owasp_category": null, + "file_path": "app/utils/hash.py", + "start_line": 7, + "end_line": 7, + "function_name": null, + "code_snippet": "digest = hashlib.md5(password).hexdigest()", + "estimated_severity": "medium", + "confidence": "medium", + "data_flow": null, + "related_files": [], + "fingerprint": "" + } + ], + "chains": [], + "total_raw": 2, + "deduplicated_count": 2, + "chain_count": 0, + "strategies_run": [ + "auth" + ], + "hunt_duration_seconds": 0.0 + }, + "crypto": { + "findings": [ + { + "id": "", + "hunter_strategy": "crypto", + "title": "SQL injection in user lookup", + "description": "user_id flows unescaped into an f-string query.", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "CWE-89", + "owasp_category": null, + "file_path": "app/api/users.py", + "start_line": 42, + "end_line": 43, + "function_name": null, + "code_snippet": "query = f\"SELECT * FROM users WHERE id = {user_id}\"\ncursor.execute(query)", + "estimated_severity": "high", + "confidence": "high", + "data_flow": [ + { + "file_path": "app/api/users.py", + "line": 42, + "component": "crypto", + "operation": "request.args['id'] -> query -> cursor.execute" + } + ], + "related_files": [], + "fingerprint": "" + }, + { + "id": "", + "hunter_strategy": "crypto", + "title": "Weak hash for password storage", + "description": "MD5 used to derive a credential digest.", + "finding_type": "sast", + "cwe_id": "CWE-327", + "cwe_name": "CWE-327", + "owasp_category": null, + "file_path": "app/utils/hash.py", + "start_line": 7, + "end_line": 7, + "function_name": null, + "code_snippet": "digest = hashlib.md5(password).hexdigest()", + "estimated_severity": "medium", + "confidence": "medium", + "data_flow": null, + "related_files": [], + "fingerprint": "" + } + ], + "chains": [], + "total_raw": 2, + "deduplicated_count": 2, + "chain_count": 0, + "strategies_run": [ + "crypto" + ], + "hunt_duration_seconds": 0.0 + }, + "business_logic": { + "findings": [ + { + "id": "", + "hunter_strategy": "business_logic", + "title": "SQL injection in user lookup", + "description": "user_id flows unescaped into an f-string query.", + "finding_type": "logic", + "cwe_id": "CWE-89", + "cwe_name": "CWE-89", + "owasp_category": null, + "file_path": "app/api/users.py", + "start_line": 42, + "end_line": 43, + "function_name": null, + "code_snippet": "query = f\"SELECT * FROM users WHERE id = {user_id}\"\ncursor.execute(query)", + "estimated_severity": "high", + "confidence": "high", + "data_flow": [ + { + "file_path": "app/api/users.py", + "line": 42, + "component": "business_logic", + "operation": "request.args['id'] -> query -> cursor.execute" + } + ], + "related_files": [], + "fingerprint": "" + }, + { + "id": "", + "hunter_strategy": "business_logic", + "title": "Weak hash for password storage", + "description": "MD5 used to derive a credential digest.", + "finding_type": "logic", + "cwe_id": "CWE-327", + "cwe_name": "CWE-327", + "owasp_category": null, + "file_path": "app/utils/hash.py", + "start_line": 7, + "end_line": 7, + "function_name": null, + "code_snippet": "digest = hashlib.md5(password).hexdigest()", + "estimated_severity": "medium", + "confidence": "medium", + "data_flow": null, + "related_files": [], + "fingerprint": "" + } + ], + "chains": [], + "total_raw": 2, + "deduplicated_count": 2, + "chain_count": 0, + "strategies_run": [ + "business_logic" + ], + "hunt_duration_seconds": 0.0 + }, + "logic": { + "findings": [ + { + "id": "", + "hunter_strategy": "business_logic", + "title": "SQL injection in user lookup", + "description": "user_id flows unescaped into an f-string query.", + "finding_type": "logic", + "cwe_id": "CWE-89", + "cwe_name": "CWE-89", + "owasp_category": null, + "file_path": "app/api/users.py", + "start_line": 42, + "end_line": 43, + "function_name": null, + "code_snippet": "query = f\"SELECT * FROM users WHERE id = {user_id}\"\ncursor.execute(query)", + "estimated_severity": "high", + "confidence": "high", + "data_flow": [ + { + "file_path": "app/api/users.py", + "line": 42, + "component": "business_logic", + "operation": "request.args['id'] -> query -> cursor.execute" + } + ], + "related_files": [], + "fingerprint": "" + }, + { + "id": "", + "hunter_strategy": "business_logic", + "title": "Weak hash for password storage", + "description": "MD5 used to derive a credential digest.", + "finding_type": "logic", + "cwe_id": "CWE-327", + "cwe_name": "CWE-327", + "owasp_category": null, + "file_path": "app/utils/hash.py", + "start_line": 7, + "end_line": 7, + "function_name": null, + "code_snippet": "digest = hashlib.md5(password).hexdigest()", + "estimated_severity": "medium", + "confidence": "medium", + "data_flow": null, + "related_files": [], + "fingerprint": "" + } + ], + "chains": [], + "total_raw": 2, + "deduplicated_count": 2, + "chain_count": 0, + "strategies_run": [ + "business_logic" + ], + "hunt_duration_seconds": 0.0 + }, + "data_exposure": { + "findings": [ + { + "id": "", + "hunter_strategy": "data_exposure", + "title": "SQL injection in user lookup", + "description": "user_id flows unescaped into an f-string query.", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "CWE-89", + "owasp_category": null, + "file_path": "app/api/users.py", + "start_line": 42, + "end_line": 43, + "function_name": null, + "code_snippet": "query = f\"SELECT * FROM users WHERE id = {user_id}\"\ncursor.execute(query)", + "estimated_severity": "high", + "confidence": "high", + "data_flow": [ + { + "file_path": "app/api/users.py", + "line": 42, + "component": "data_exposure", + "operation": "request.args['id'] -> query -> cursor.execute" + } + ], + "related_files": [], + "fingerprint": "" + }, + { + "id": "", + "hunter_strategy": "data_exposure", + "title": "Weak hash for password storage", + "description": "MD5 used to derive a credential digest.", + "finding_type": "sast", + "cwe_id": "CWE-327", + "cwe_name": "CWE-327", + "owasp_category": null, + "file_path": "app/utils/hash.py", + "start_line": 7, + "end_line": 7, + "function_name": null, + "code_snippet": "digest = hashlib.md5(password).hexdigest()", + "estimated_severity": "medium", + "confidence": "medium", + "data_flow": null, + "related_files": [], + "fingerprint": "" + } + ], + "chains": [], + "total_raw": 2, + "deduplicated_count": 2, + "chain_count": 0, + "strategies_run": [ + "data_exposure" + ], + "hunt_duration_seconds": 0.0 + }, + "supply_chain": { + "findings": [ + { + "id": "", + "hunter_strategy": "supply_chain", + "title": "SQL injection in user lookup", + "description": "user_id flows unescaped into an f-string query.", + "finding_type": "sca", + "cwe_id": "CWE-89", + "cwe_name": "CWE-89", + "owasp_category": null, + "file_path": "app/api/users.py", + "start_line": 42, + "end_line": 43, + "function_name": null, + "code_snippet": "query = f\"SELECT * FROM users WHERE id = {user_id}\"\ncursor.execute(query)", + "estimated_severity": "high", + "confidence": "high", + "data_flow": [ + { + "file_path": "app/api/users.py", + "line": 42, + "component": "supply_chain", + "operation": "request.args['id'] -> query -> cursor.execute" + } + ], + "related_files": [], + "fingerprint": "" + }, + { + "id": "", + "hunter_strategy": "supply_chain", + "title": "Weak hash for password storage", + "description": "MD5 used to derive a credential digest.", + "finding_type": "sca", + "cwe_id": "CWE-327", + "cwe_name": "CWE-327", + "owasp_category": null, + "file_path": "app/utils/hash.py", + "start_line": 7, + "end_line": 7, + "function_name": null, + "code_snippet": "digest = hashlib.md5(password).hexdigest()", + "estimated_severity": "medium", + "confidence": "medium", + "data_flow": null, + "related_files": [], + "fingerprint": "" + } + ], + "chains": [], + "total_raw": 2, + "deduplicated_count": 2, + "chain_count": 0, + "strategies_run": [ + "supply_chain" + ], + "hunt_duration_seconds": 0.0 + }, + "config_secrets": { + "findings": [ + { + "id": "", + "hunter_strategy": "config_secrets", + "title": "SQL injection in user lookup", + "description": "user_id flows unescaped into an f-string query.", + "finding_type": "config", + "cwe_id": "CWE-89", + "cwe_name": "CWE-89", + "owasp_category": null, + "file_path": "app/api/users.py", + "start_line": 42, + "end_line": 43, + "function_name": null, + "code_snippet": "query = f\"SELECT * FROM users WHERE id = {user_id}\"\ncursor.execute(query)", + "estimated_severity": "high", + "confidence": "high", + "data_flow": [ + { + "file_path": "app/api/users.py", + "line": 42, + "component": "config_secrets", + "operation": "request.args['id'] -> query -> cursor.execute" + } + ], + "related_files": [], + "fingerprint": "" + }, + { + "id": "", + "hunter_strategy": "config_secrets", + "title": "Weak hash for password storage", + "description": "MD5 used to derive a credential digest.", + "finding_type": "config", + "cwe_id": "CWE-327", + "cwe_name": "CWE-327", + "owasp_category": null, + "file_path": "app/utils/hash.py", + "start_line": 7, + "end_line": 7, + "function_name": null, + "code_snippet": "digest = hashlib.md5(password).hexdigest()", + "estimated_severity": "medium", + "confidence": "medium", + "data_flow": null, + "related_files": [], + "fingerprint": "" + } + ], + "chains": [], + "total_raw": 2, + "deduplicated_count": 2, + "chain_count": 0, + "strategies_run": [ + "config_secrets" + ], + "hunt_duration_seconds": 0.0 + }, + "api_security": { + "findings": [ + { + "id": "", + "hunter_strategy": "api_security", + "title": "SQL injection in user lookup", + "description": "user_id flows unescaped into an f-string query.", + "finding_type": "api", + "cwe_id": "CWE-89", + "cwe_name": "CWE-89", + "owasp_category": null, + "file_path": "app/api/users.py", + "start_line": 42, + "end_line": 43, + "function_name": null, + "code_snippet": "query = f\"SELECT * FROM users WHERE id = {user_id}\"\ncursor.execute(query)", + "estimated_severity": "high", + "confidence": "high", + "data_flow": [ + { + "file_path": "app/api/users.py", + "line": 42, + "component": "api_security", + "operation": "request.args['id'] -> query -> cursor.execute" + } + ], + "related_files": [], + "fingerprint": "" + }, + { + "id": "", + "hunter_strategy": "api_security", + "title": "Weak hash for password storage", + "description": "MD5 used to derive a credential digest.", + "finding_type": "api", + "cwe_id": "CWE-327", + "cwe_name": "CWE-327", + "owasp_category": null, + "file_path": "app/utils/hash.py", + "start_line": 7, + "end_line": 7, + "function_name": null, + "code_snippet": "digest = hashlib.md5(password).hexdigest()", + "estimated_severity": "medium", + "confidence": "medium", + "data_flow": null, + "related_files": [], + "fingerprint": "" + } + ], + "chains": [], + "total_raw": 2, + "deduplicated_count": 2, + "chain_count": 0, + "strategies_run": [ + "api_security" + ], + "hunt_duration_seconds": 0.0 + } +} diff --git a/go/internal/agents/hunt/testdata/golden/hunter_skips.json b/go/internal/agents/hunt/testdata/golden/hunter_skips.json new file mode 100644 index 0000000..5267f42 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/hunter_skips.json @@ -0,0 +1,76 @@ +{ + "crypto_no_usage": { + "harness_calls": 0, + "want": { + "findings": [], + "chains": [], + "total_raw": 0, + "deduplicated_count": 0, + "chain_count": 0, + "strategies_run": [], + "hunt_duration_seconds": 0.0 + } + }, + "crypto_empty_recon": { + "harness_calls": 0, + "want": { + "findings": [], + "chains": [], + "total_raw": 0, + "deduplicated_count": 0, + "chain_count": 0, + "strategies_run": [], + "hunt_duration_seconds": 0.0 + } + }, + "supply_chain_no_direct_deps": { + "harness_calls": 0, + "want": { + "findings": [], + "chains": [], + "total_raw": 0, + "deduplicated_count": 0, + "chain_count": 0, + "strategies_run": [], + "hunt_duration_seconds": 0.0 + } + }, + "api_security_no_surface": { + "harness_calls": 0, + "want": { + "findings": [], + "chains": [], + "total_raw": 0, + "deduplicated_count": 0, + "chain_count": 0, + "strategies_run": [ + "api_security" + ], + "hunt_duration_seconds": 0.0 + } + }, + "business_logic_quick": { + "harness_calls": 0, + "want": { + "findings": [], + "chains": [], + "total_raw": 0, + "deduplicated_count": 0, + "chain_count": 0, + "strategies_run": [], + "hunt_duration_seconds": 0.0 + } + }, + "logic_quick": { + "harness_calls": 0, + "want": { + "findings": [], + "chains": [], + "total_raw": 0, + "deduplicated_count": 0, + "chain_count": 0, + "strategies_run": [], + "hunt_duration_seconds": 0.0 + } + } +} diff --git a/go/internal/agents/hunt/testdata/golden/normalize_depth.json b/go/internal/agents/hunt/testdata/golden/normalize_depth.json new file mode 100644 index 0000000..ac37cdb --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/normalize_depth.json @@ -0,0 +1,9 @@ +{ + "quick": "quick", + "QUICK": "quick", + "Standard": "standard", + "thorough": "thorough", + "bogus": "standard", + "": "standard", + " quick ": "standard" +} diff --git a/go/internal/agents/hunt/testdata/golden/prompt_api_security_standard.txt b/go/internal/agents/hunt/testdata/golden/prompt_api_security_standard.txt new file mode 100644 index 0000000..bffee9d --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_api_security_standard.txt @@ -0,0 +1,182 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are an API security hunter specializing in authorization bypasses, origin validation flaws, SSRF, and open redirect vulnerabilities. + +API_SECURITY_CONTEXT: +API security-focused recon summary. + +API endpoints prioritized by missing auth/rate-limits: 20 total, showing top 15: +- POST /api/v1/thing/1 -> ThingController.action1 (app/api/thing_1.py:103, auth_required=False, rate_limited=False) +- DELETE /api/v1/thing/3 -> ThingController.action3 (app/api/thing_3.py:109, auth_required=False, rate_limited=False) +- PUT /api/v1/thing/7 -> ThingController.action7 (app/api/thing_7.py:121, auth_required=False, rate_limited=False) +- PATCH /api/v1/thing/9 -> ThingController.action9 (app/api/thing_9.py:127, auth_required=False, rate_limited=False) +- DELETE /api/v1/thing/13 -> ThingController.action13 (app/api/thing_13.py:139, auth_required=False, rate_limited=False) +- GET /api/v1/thing/15 -> ThingController.action15 (app/api/thing_15.py:145, auth_required=False, rate_limited=False) +- PATCH /api/v1/thing/19 -> ThingController.action19 (app/api/thing_19.py:157, auth_required=False, rate_limited=False) +- GET /api/v1/thing/0 -> ThingController.action0 (app/api/thing_0.py:100, auth_required=True, rate_limited=None) +- PUT /api/v1/thing/2 -> ThingController.action2 (app/api/thing_2.py:106, auth_required=None, rate_limited=True) +- PATCH /api/v1/thing/4 -> ThingController.action4 (app/api/thing_4.py:112, auth_required=True, rate_limited=None) +- GET /api/v1/thing/5 -> ThingController.action5 (app/api/thing_5.py:115, auth_required=None, rate_limited=True) +- POST /api/v1/thing/6 -> ThingController.action6 (app/api/thing_6.py:118, auth_required=True, rate_limited=None) +- DELETE /api/v1/thing/8 -> ThingController.action8 (app/api/thing_8.py:124, auth_required=None, rate_limited=True) +- GET /api/v1/thing/10 -> ThingController.action10 (app/api/thing_10.py:130, auth_required=True, rate_limited=None) +- POST /api/v1/thing/11 -> ThingController.action11 (app/api/thing_11.py:133, auth_required=None, rate_limited=True) + +HTTP/API entry points: 11 total, showing top 10: +- http handler_0 (app/entry/e0.py:10, auth_required=True) +- HTTP /v1/resource/1 (app/entry/e1.py:17, auth_required=False) +- api handler_3 (app/entry/e3.py:31, auth_required=True) +- graphql /v1/resource/4 (app/entry/e4.py:38, auth_required=False) +- rpc /v1/resource/5 (app/entry/e5.py:45, auth_required=None) +- route handler_6 (app/entry/e6.py:52, auth_required=True) +- http handler_9 (app/entry/e9.py:73, auth_required=True) +- api /v1/resource/10 (app/entry/e10.py:80, auth_required=False) +- http handler_12 (app/entry/e12.py:94, auth_required=True) +- route /v1/resource/13 (app/entry/e13.py:101, auth_required=False) + +Trust boundaries relevant to API calls: 12 total, showing top 10: +- boundary_0: internet -> app; enforcement=none +- boundary_1: dmz -> db; enforcement=waf_1, mtls_1 +- boundary_2: vpc -> cache; enforcement=waf_2, mtls_2 +- boundary_3: worker -> queue; enforcement=waf_3, mtls_3 +- boundary_4: internet -> app; enforcement=none +- boundary_5: dmz -> db; enforcement=waf_5, mtls_5 +- boundary_6: vpc -> cache; enforcement=waf_6, mtls_6 +- boundary_7: worker -> queue; enforcement=waf_7, mtls_7 +- boundary_8: internet -> app; enforcement=none +- boundary_9: dmz -> db; enforcement=waf_9, mtls_9 + +Framework/deployment API security signals: 13 total, showing top 10: +- django.middleware.csrf.CsrfViewMiddleware +- helmet defaults +- SecurityMiddleware +- rack-protection +- spring-security filter chain +- kubernetes ingress with TLS termination +- docker-compose exposes 5432 +- no WAF in front of /api +- secrets mounted from vault +- readiness probe on /healthz + +TASK: +Analyze API endpoints and supporting middleware/controllers for: +- CWE-285 Improper Authorization (BOLA/IDOR and missing ownership checks) +- CWE-346 Origin Validation Error (CORS misconfiguration, weak origin validation) +- CWE-918 SSRF (user-controlled URLs reaching server-side fetch clients) +- CWE-601 Open Redirect (untrusted redirect targets) + +Also hunt for: +- Missing/weak rate limiting on authentication and sensitive endpoints +- Mass assignment where request payloads are mapped directly to models/ORM updates +- Missing input validation (unbounded strings, unsafe coercion, negative or out-of-range values) + +WORKFLOW (follow these steps in order): +1. Read the API endpoint files and route handlers identified in the API_SECURITY_CONTEXT above. +2. For each endpoint, trace user input from request parameters/body to where it is used (database queries, redirects, outbound HTTP calls, etc.). +3. Check for missing authorization, missing input validation, CORS misconfigurations, and SSRF vectors. +4. For each confirmed vulnerability, record the file path, line number, and relevant code snippet. +5. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +SCOPE: +- Prioritize internet-facing API routes and handlers from api_surface. +- Trace request parameters and body fields to authorization checks, outbound HTTP clients, redirect responses, and persistence layers. +- Correlate evidence across route definitions, middleware, validators, and shared utilities. +- Exclude speculative findings without source evidence. + +OUTPUT: +- Populate findings with finding_type="api" and hunter_strategy="api_security". +- Include endpoint-specific evidence (method/path, file_path, lines, and relevant snippets). +- Include attack chains only when there is concrete multi-step linkage. +- Empty arrays are acceptable if no vulnerabilities are confirmed. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. + +Language: JAVASCRIPT + Key sinks: eval, Function(), innerHTML, document.write, child_process.exec, new Function + Safe patterns (skip these): DOMPurify.sanitize(), textContent assignment, parameterized pg queries + DO NOT FLAG: React JSX expressions (auto-escaped), Angular template bindings (sanitized by default) + Framework notes: Check for React/Vue/Angular-specific XSS patterns. React auto-escapes JSX. Vue v-html is dangerous. + +Language: GO + Key sinks: fmt.Sprintf into SQL, exec.Command with user input, template.HTML(), os.Exec + Safe patterns (skip these): database/sql with ? placeholders, html/template (auto-escapes), exec.Command with separate args + DO NOT FLAG: GORM parameterized queries, html/template default escaping, database/sql prepared statements + Framework notes: Go's html/template auto-escapes. text/template does NOT. Check for text/template serving HTML. + +Language: RUBY + Key sinks: eval, system, exec, send, public_send, ERB.new with user input + Safe patterns (skip these): ActiveRecord parameterized queries, Rack::Utils.escape_html, sanitize helper in Rails + DO NOT FLAG: ActiveRecord where with hash conditions, Rails CSRF protection, Rails html_safe on constants + Framework notes: Rails auto-escapes ERB templates. raw/html_safe bypasses escaping. Check for mass assignment. + +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +Framework: NEXTJS + Security features: React JSX auto-escapes output by default, API routes can share centralized auth middleware, Server/client boundaries reduce accidental secret exposure + DO NOT FLAG: React JSX escaped rendering, getServerSideProps/getServerSession with proper auth checks, Typed route handlers with validated schema guards + Watch for: dangerouslySetInnerHTML, unprotected API routes under /api, secret leakage to client bundles, rewrites/redirects from untrusted user input + Common vulns: XSS through dangerous HTML rendering, IDOR/auth bypass in API handlers, Sensitive env/config exposure in client-side code + +Framework: SPRING + Security features: Spring Security provides auth, CSRF, and filter chain defaults, JPA/Hibernate prepared parameter binding by default, Bean validation can enforce request constraints + DO NOT FLAG: PreparedStatement/JPA named parameter usage, Spring Security CSRF/auth filters clearly active, Thymeleaf auto-escaped output + Watch for: @PreAuthorize missing on sensitive methods, JdbcTemplate/raw Statement with concatenation, csrf().disable() on browser session flows, Actuator endpoints exposed without auth + Common vulns: Authz bypass from weak method-level security, SQL injection in raw JDBC queries, Sensitive management endpoint exposure + +Framework: REACT + Security features: JSX escapes strings before DOM rendering, Component model encourages explicit data flow, Framework discourages direct DOM mutation + DO NOT FLAG: Standard JSX expression rendering, textContent assignment for untrusted text, sanitized HTML via trusted DOMPurify policy + Watch for: dangerouslySetInnerHTML, untrusted URL assignment to href/src, eval/new Function in client code, token/secret exposure in bundles + Common vulns: DOM XSS through unsafe HTML injection, Open redirect via unvalidated navigation targets, Sensitive data exposure in frontend artifacts + +Framework: VUE + Security features: Mustache template interpolation escapes HTML, Component props/events provide explicit boundaries, Router guards can enforce auth flows + DO NOT FLAG: escaped template interpolation {{ value }}, validated route guards protecting private routes, sanitized content rendered through safe components + Watch for: v-html with untrusted data, dynamic component/template compilation, unsafe URL bindings in href/src, client-side auth checks without server enforcement + Common vulns: XSS through v-html and unsafe render paths, Auth bypass from client-only route protection, Open redirect patterns in router navigation + +CONSTRAINTS: +- Prefer confirmed code evidence over heuristics. +- Base all findings on actual file content you read. Do not speculate. +- Do not include markdown, prose, or code fences in the output file. JSON only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Focus only on API-relevant code paths and endpoint handlers surfaced by RECON. +- Early stop rule: if you inspect standard files without credible API issues, stop and return empty findings. +- Read the handler files first, then generate findings. +- After gathering evidence, write the JSON output file using your Write tool. diff --git a/go/internal/agents/hunt/testdata/golden/prompt_api_security_thorough.txt b/go/internal/agents/hunt/testdata/golden/prompt_api_security_thorough.txt new file mode 100644 index 0000000..c5b0f3e --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_api_security_thorough.txt @@ -0,0 +1,95 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are an API security hunter specializing in authorization bypasses, origin validation flaws, SSRF, and open redirect vulnerabilities. + +API_SECURITY_CONTEXT: +API security-focused recon summary. + +API endpoints prioritized by missing auth/rate-limits: 2 total, showing top 2: +- POST /login -> login (app/api/auth.py:12, auth_required=False, rate_limited=False) +- GET /users/{id} -> get_user (app/api/users.py:40, auth_required=True, rate_limited=True) + +HTTP/API entry points: 1 total, showing top 1: +- http /login (app/api/auth.py:12, auth_required=False) + +Trust boundaries relevant to API calls: 1 total, showing top 1: +- edge: internet -> app; enforcement=waf + +Framework/deployment API security signals: 2 total, showing top 2: +- django-csrf +- docker + +TASK: +Analyze API endpoints and supporting middleware/controllers for: +- CWE-285 Improper Authorization (BOLA/IDOR and missing ownership checks) +- CWE-346 Origin Validation Error (CORS misconfiguration, weak origin validation) +- CWE-918 SSRF (user-controlled URLs reaching server-side fetch clients) +- CWE-601 Open Redirect (untrusted redirect targets) + +Also hunt for: +- Missing/weak rate limiting on authentication and sensitive endpoints +- Mass assignment where request payloads are mapped directly to models/ORM updates +- Missing input validation (unbounded strings, unsafe coercion, negative or out-of-range values) + +WORKFLOW (follow these steps in order): +1. Read the API endpoint files and route handlers identified in the API_SECURITY_CONTEXT above. +2. For each endpoint, trace user input from request parameters/body to where it is used (database queries, redirects, outbound HTTP calls, etc.). +3. Check for missing authorization, missing input validation, CORS misconfigurations, and SSRF vectors. +4. For each confirmed vulnerability, record the file path, line number, and relevant code snippet. +5. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +SCOPE: +- Prioritize internet-facing API routes and handlers from api_surface. +- Trace request parameters and body fields to authorization checks, outbound HTTP clients, redirect responses, and persistence layers. +- Correlate evidence across route definitions, middleware, validators, and shared utilities. +- Exclude speculative findings without source evidence. + +OUTPUT: +- Populate findings with finding_type="api" and hunter_strategy="api_security". +- Include endpoint-specific evidence (method/path, file_path, lines, and relevant snippets). +- Include attack chains only when there is concrete multi-step linkage. +- Empty arrays are acceptable if no vulnerabilities are confirmed. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +CONSTRAINTS: +- Prefer confirmed code evidence over heuristics. +- Base all findings on actual file content you read. Do not speculate. +- Do not include markdown, prose, or code fences in the output file. JSON only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Focus only on API-relevant code paths and endpoint handlers surfaced by RECON. +- Early stop rule: if you inspect thorough files without credible API issues, stop and return empty findings. +- Read the handler files first, then generate findings. +- After gathering evidence, write the JSON output file using your Write tool. diff --git a/go/internal/agents/hunt/testdata/golden/prompt_auth_quick.txt b/go/internal/agents/hunt/testdata/golden/prompt_auth_quick.txt new file mode 100644 index 0000000..7f85225 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_auth_quick.txt @@ -0,0 +1,107 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are SEC-AF's Auth/AuthZ Hunter for HUNT phase. + +MISSION: +Find potential authentication and authorization vulnerabilities with high signal and low false positives. + +REPOSITORY: +/fixtures/demo-repo + +DEPTH PROFILE: +quick + +TARGET CWES: +CWE-287, CWE-306, CWE-862, CWE-863, CWE-352 + +RECON CONTEXT SUMMARY: +Authentication/authorization-focused recon summary. + +Auth model: jwt. Details: HS256 access tokens. + +Auth/session/RBAC modules and middleware candidates: none identified in recon. + +API endpoints to validate for auth/authz coverage: 2 total, showing top 2: +- POST /login -> login (app/api/auth.py:12, auth_required=False, rate_limited=False) +- GET /users/{id} -> get_user (app/api/users.py:40, auth_required=True, rate_limited=True) + +Auth/session-relevant data flows: none identified in recon. + +Security headers and framework security signals: 2 total, showing top 2: +- Content-Security-Policy +- django-csrf + +WORKFLOW (follow these steps in order): +1. Read the source files referenced in the RECON context above to understand the codebase. +2. Search for authentication checks, authorization guards, session handling, and permission validation in the source code. +3. Read the identified files to confirm vulnerabilities with actual code evidence. +4. For each confirmed vulnerability, record the file path, line number, and relevant code snippet. +5. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +ANALYSIS REQUIREMENTS: +1. Prioritize concrete code locations showing missing, bypassed, or inconsistent auth/authz checks. +3. Focus on these CWE classes: + - CWE-287 Improper Authentication + - CWE-306 Missing Authentication for Critical Function + - CWE-862 Missing Authorization + - CWE-863 Incorrect Authorization + - CWE-352 Cross-Site Request Forgery +4. Use recon context to locate auth middleware, request guards, session handling, and RBAC/permission checks. +5. For each finding, provide stable dedup fingerprint and direct code evidence. + +AUTH-MODEL SPECIFIC CHECKS: +- If auth_model is "jwt": verify signature validation, accepted algorithms, key handling, claim validation, and privileged-token paths. +- If auth_model is "session": verify session fixation controls, cookie security flags, CSRF protections, and logout/session invalidation paths. +- If auth_model is "api_key": verify key exposure risks, scope restrictions, rotation controls, and protection of privileged endpoints. +- If auth_model is "none": treat exposed endpoints and critical operations as potentially unauthenticated unless explicit compensating controls exist. + +DO NOT FLAG: +- CSRF issues in API-only/stateless auth flows. +- Endpoints covered by router/global auth middleware where enforcement is clearly present. +- Endpoints explicitly marked as test/demo fixtures when not active in production paths. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +OUTPUT FORMAT: +- Return JSON only matching HuntResult. +- Populate `findings` with RawFinding entries for this strategy. +- Keep `chains` empty unless you discover a concrete auth chain in this strategy. +- Set `strategies_run` to ["auth"]. +- Do not return markdown. +- Do not include explanations outside JSON. +- Empty arrays are acceptable if no vulnerabilities are confirmed. +- Base all findings on actual file content you read. Do not speculate. + + +EXECUTION CONSTRAINTS: +- Early stop rule: if you inspect 30 files without credible auth issues, stop and return empty findings. + diff --git a/go/internal/agents/hunt/testdata/golden/prompt_auth_standard.txt b/go/internal/agents/hunt/testdata/golden/prompt_auth_standard.txt new file mode 100644 index 0000000..2cb4633 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_auth_standard.txt @@ -0,0 +1,189 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are SEC-AF's Auth/AuthZ Hunter for HUNT phase. + +MISSION: +Find potential authentication and authorization vulnerabilities with high signal and low false positives. + +REPOSITORY: +/fixtures/demo-repo + +DEPTH PROFILE: +standard + +TARGET CWES: +CWE-287, CWE-306, CWE-862, CWE-863, CWE-352 + +RECON CONTEXT SUMMARY: +Authentication/authorization-focused recon summary. + +Auth model: jwt. Details: HS256 JWTs issued by app/auth/service.py, refresh tokens in Redis. + +Auth/session/RBAC modules and middleware candidates: 8 total, showing top 8: +- app/auth/service.py (python) - Session and JWT issuance +- app/auth/rbac.py (python) +- app/web/middleware/csrf.js (javascript) - CSRF middleware +- app/auth/session_store.py (python) - Redis-backed sessions +- app/auth/permissions.py (python) - Role → permission table +- app/common/guard.py (python) - Assorted guard helpers +- db/migrate/2024_add_roles.rb (ruby) - Adds role column +- app/common/jwt_tools.py (python) + +API endpoints to validate for auth/authz coverage: 20 total, showing top 15: +- POST /api/v1/thing/1 -> ThingController.action1 (app/api/thing_1.py:103, auth_required=False, rate_limited=False) +- DELETE /api/v1/thing/3 -> ThingController.action3 (app/api/thing_3.py:109, auth_required=False, rate_limited=False) +- PUT /api/v1/thing/7 -> ThingController.action7 (app/api/thing_7.py:121, auth_required=False, rate_limited=False) +- PATCH /api/v1/thing/9 -> ThingController.action9 (app/api/thing_9.py:127, auth_required=False, rate_limited=False) +- DELETE /api/v1/thing/13 -> ThingController.action13 (app/api/thing_13.py:139, auth_required=False, rate_limited=False) +- GET /api/v1/thing/15 -> ThingController.action15 (app/api/thing_15.py:145, auth_required=False, rate_limited=False) +- PATCH /api/v1/thing/19 -> ThingController.action19 (app/api/thing_19.py:157, auth_required=False, rate_limited=False) +- GET /api/v1/thing/0 -> ThingController.action0 (app/api/thing_0.py:100, auth_required=True, rate_limited=None) +- PUT /api/v1/thing/2 -> ThingController.action2 (app/api/thing_2.py:106, auth_required=None, rate_limited=True) +- PATCH /api/v1/thing/4 -> ThingController.action4 (app/api/thing_4.py:112, auth_required=True, rate_limited=None) +- GET /api/v1/thing/5 -> ThingController.action5 (app/api/thing_5.py:115, auth_required=None, rate_limited=True) +- POST /api/v1/thing/6 -> ThingController.action6 (app/api/thing_6.py:118, auth_required=True, rate_limited=None) +- DELETE /api/v1/thing/8 -> ThingController.action8 (app/api/thing_8.py:124, auth_required=None, rate_limited=True) +- GET /api/v1/thing/10 -> ThingController.action10 (app/api/thing_10.py:130, auth_required=True, rate_limited=None) +- POST /api/v1/thing/11 -> ThingController.action11 (app/api/thing_11.py:133, auth_required=None, rate_limited=True) + +Auth/session-relevant data flows: 5 total, showing top 5: +- request.cookies['session'] -> redis.set (files=app/auth/session_store.py, sanitized=True) +- form['password'] -> logger.info (files=app/auth/service.py, app/obs/telemetry.go, sanitized=False) +- header['Authorization'] -> jwt.decode (files=app/common/jwt_tools.py, sanitized=True) +- session['role'] -> permission_check (files=app/auth/permissions.py, app/auth/rbac.py, sanitized=True) +- token -> cache.set (files=app/common/cache.py, sanitized=True) + +Security headers and framework security signals: 11 total, showing top 10: +- Content-Security-Policy: default-src 'self' +- X-Content-Type-Options: nosniff +- Strict-Transport-Security: max-age=31536000 +- X-Frame-Options: DENY +- Referrer-Policy: no-referrer +- Permissions-Policy: geolocation=() +- django.middleware.csrf.CsrfViewMiddleware +- helmet defaults +- SecurityMiddleware +- rack-protection + +WORKFLOW (follow these steps in order): +1. Read the source files referenced in the RECON context above to understand the codebase. +2. Search for authentication checks, authorization guards, session handling, and permission validation in the source code. +3. Read the identified files to confirm vulnerabilities with actual code evidence. +4. For each confirmed vulnerability, record the file path, line number, and relevant code snippet. +5. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +ANALYSIS REQUIREMENTS: +1. Prioritize concrete code locations showing missing, bypassed, or inconsistent auth/authz checks. +3. Focus on these CWE classes: + - CWE-287 Improper Authentication + - CWE-306 Missing Authentication for Critical Function + - CWE-862 Missing Authorization + - CWE-863 Incorrect Authorization + - CWE-352 Cross-Site Request Forgery +4. Use recon context to locate auth middleware, request guards, session handling, and RBAC/permission checks. +5. For each finding, provide stable dedup fingerprint and direct code evidence. + +AUTH-MODEL SPECIFIC CHECKS: +- If auth_model is "jwt": verify signature validation, accepted algorithms, key handling, claim validation, and privileged-token paths. +- If auth_model is "session": verify session fixation controls, cookie security flags, CSRF protections, and logout/session invalidation paths. +- If auth_model is "api_key": verify key exposure risks, scope restrictions, rotation controls, and protection of privileged endpoints. +- If auth_model is "none": treat exposed endpoints and critical operations as potentially unauthenticated unless explicit compensating controls exist. + +DO NOT FLAG: +- CSRF issues in API-only/stateless auth flows. +- Endpoints covered by router/global auth middleware where enforcement is clearly present. +- Endpoints explicitly marked as test/demo fixtures when not active in production paths. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. + +Language: JAVASCRIPT + Key sinks: eval, Function(), innerHTML, document.write, child_process.exec, new Function + Safe patterns (skip these): DOMPurify.sanitize(), textContent assignment, parameterized pg queries + DO NOT FLAG: React JSX expressions (auto-escaped), Angular template bindings (sanitized by default) + Framework notes: Check for React/Vue/Angular-specific XSS patterns. React auto-escapes JSX. Vue v-html is dangerous. + +Language: GO + Key sinks: fmt.Sprintf into SQL, exec.Command with user input, template.HTML(), os.Exec + Safe patterns (skip these): database/sql with ? placeholders, html/template (auto-escapes), exec.Command with separate args + DO NOT FLAG: GORM parameterized queries, html/template default escaping, database/sql prepared statements + Framework notes: Go's html/template auto-escapes. text/template does NOT. Check for text/template serving HTML. + +Language: RUBY + Key sinks: eval, system, exec, send, public_send, ERB.new with user input + Safe patterns (skip these): ActiveRecord parameterized queries, Rack::Utils.escape_html, sanitize helper in Rails + DO NOT FLAG: ActiveRecord where with hash conditions, Rails CSRF protection, Rails html_safe on constants + Framework notes: Rails auto-escapes ERB templates. raw/html_safe bypasses escaping. Check for mass assignment. + +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +Framework: NEXTJS + Security features: React JSX auto-escapes output by default, API routes can share centralized auth middleware, Server/client boundaries reduce accidental secret exposure + DO NOT FLAG: React JSX escaped rendering, getServerSideProps/getServerSession with proper auth checks, Typed route handlers with validated schema guards + Watch for: dangerouslySetInnerHTML, unprotected API routes under /api, secret leakage to client bundles, rewrites/redirects from untrusted user input + Common vulns: XSS through dangerous HTML rendering, IDOR/auth bypass in API handlers, Sensitive env/config exposure in client-side code + +Framework: SPRING + Security features: Spring Security provides auth, CSRF, and filter chain defaults, JPA/Hibernate prepared parameter binding by default, Bean validation can enforce request constraints + DO NOT FLAG: PreparedStatement/JPA named parameter usage, Spring Security CSRF/auth filters clearly active, Thymeleaf auto-escaped output + Watch for: @PreAuthorize missing on sensitive methods, JdbcTemplate/raw Statement with concatenation, csrf().disable() on browser session flows, Actuator endpoints exposed without auth + Common vulns: Authz bypass from weak method-level security, SQL injection in raw JDBC queries, Sensitive management endpoint exposure + +Framework: REACT + Security features: JSX escapes strings before DOM rendering, Component model encourages explicit data flow, Framework discourages direct DOM mutation + DO NOT FLAG: Standard JSX expression rendering, textContent assignment for untrusted text, sanitized HTML via trusted DOMPurify policy + Watch for: dangerouslySetInnerHTML, untrusted URL assignment to href/src, eval/new Function in client code, token/secret exposure in bundles + Common vulns: DOM XSS through unsafe HTML injection, Open redirect via unvalidated navigation targets, Sensitive data exposure in frontend artifacts + +Framework: VUE + Security features: Mustache template interpolation escapes HTML, Component props/events provide explicit boundaries, Router guards can enforce auth flows + DO NOT FLAG: escaped template interpolation {{ value }}, validated route guards protecting private routes, sanitized content rendered through safe components + Watch for: v-html with untrusted data, dynamic component/template compilation, unsafe URL bindings in href/src, client-side auth checks without server enforcement + Common vulns: XSS through v-html and unsafe render paths, Auth bypass from client-only route protection, Open redirect patterns in router navigation + +OUTPUT FORMAT: +- Return JSON only matching HuntResult. +- Populate `findings` with RawFinding entries for this strategy. +- Keep `chains` empty unless you discover a concrete auth chain in this strategy. +- Set `strategies_run` to ["auth"]. +- Do not return markdown. +- Do not include explanations outside JSON. +- Empty arrays are acceptable if no vulnerabilities are confirmed. +- Base all findings on actual file content you read. Do not speculate. + + +EXECUTION CONSTRAINTS: +- Early stop rule: if you inspect 30 files without credible auth issues, stop and return empty findings. + diff --git a/go/internal/agents/hunt/testdata/golden/prompt_auth_thorough.txt b/go/internal/agents/hunt/testdata/golden/prompt_auth_thorough.txt new file mode 100644 index 0000000..afb1041 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_auth_thorough.txt @@ -0,0 +1,107 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are SEC-AF's Auth/AuthZ Hunter for HUNT phase. + +MISSION: +Find potential authentication and authorization vulnerabilities with high signal and low false positives. + +REPOSITORY: +/fixtures/demo-repo + +DEPTH PROFILE: +thorough + +TARGET CWES: +CWE-287, CWE-306, CWE-862, CWE-863, CWE-352 + +RECON CONTEXT SUMMARY: +Authentication/authorization-focused recon summary. + +Auth model: jwt. Details: HS256 access tokens. + +Auth/session/RBAC modules and middleware candidates: none identified in recon. + +API endpoints to validate for auth/authz coverage: 2 total, showing top 2: +- POST /login -> login (app/api/auth.py:12, auth_required=False, rate_limited=False) +- GET /users/{id} -> get_user (app/api/users.py:40, auth_required=True, rate_limited=True) + +Auth/session-relevant data flows: none identified in recon. + +Security headers and framework security signals: 2 total, showing top 2: +- Content-Security-Policy +- django-csrf + +WORKFLOW (follow these steps in order): +1. Read the source files referenced in the RECON context above to understand the codebase. +2. Search for authentication checks, authorization guards, session handling, and permission validation in the source code. +3. Read the identified files to confirm vulnerabilities with actual code evidence. +4. For each confirmed vulnerability, record the file path, line number, and relevant code snippet. +5. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +ANALYSIS REQUIREMENTS: +1. Prioritize concrete code locations showing missing, bypassed, or inconsistent auth/authz checks. +3. Focus on these CWE classes: + - CWE-287 Improper Authentication + - CWE-306 Missing Authentication for Critical Function + - CWE-862 Missing Authorization + - CWE-863 Incorrect Authorization + - CWE-352 Cross-Site Request Forgery +4. Use recon context to locate auth middleware, request guards, session handling, and RBAC/permission checks. +5. For each finding, provide stable dedup fingerprint and direct code evidence. + +AUTH-MODEL SPECIFIC CHECKS: +- If auth_model is "jwt": verify signature validation, accepted algorithms, key handling, claim validation, and privileged-token paths. +- If auth_model is "session": verify session fixation controls, cookie security flags, CSRF protections, and logout/session invalidation paths. +- If auth_model is "api_key": verify key exposure risks, scope restrictions, rotation controls, and protection of privileged endpoints. +- If auth_model is "none": treat exposed endpoints and critical operations as potentially unauthenticated unless explicit compensating controls exist. + +DO NOT FLAG: +- CSRF issues in API-only/stateless auth flows. +- Endpoints covered by router/global auth middleware where enforcement is clearly present. +- Endpoints explicitly marked as test/demo fixtures when not active in production paths. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +OUTPUT FORMAT: +- Return JSON only matching HuntResult. +- Populate `findings` with RawFinding entries for this strategy. +- Keep `chains` empty unless you discover a concrete auth chain in this strategy. +- Set `strategies_run` to ["auth"]. +- Do not return markdown. +- Do not include explanations outside JSON. +- Empty arrays are acceptable if no vulnerabilities are confirmed. +- Base all findings on actual file content you read. Do not speculate. + + +EXECUTION CONSTRAINTS: +- Early stop rule: if you inspect 30 files without credible auth issues, stop and return empty findings. + diff --git a/go/internal/agents/hunt/testdata/golden/prompt_business_logic_standard.txt b/go/internal/agents/hunt/testdata/golden/prompt_business_logic_standard.txt new file mode 100644 index 0000000..99d9848 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_business_logic_standard.txt @@ -0,0 +1,938 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are the SEC-AF Business Logic Hunter focused on high-impact workflow abuse and invariant violations. + +CONTEXT: +You are in HUNT phase. Analyze this repository using the provided recon context and return HuntResult JSON. + +RECON_CONTEXT: +{ + "app_type": "web_api", + "frameworks": [ + "Django", + "next.js", + "Spring Boot", + " React ", + "unknown-fw", + "NEXT", + "Vue" + ], + "languages": [ + "Python", + "JavaScript", + "Go", + "Ruby", + "Rust", + "python" + ], + "auth_model": "jwt", + "auth_details": "HS256 JWTs issued by app/auth/service.py, refresh tokens in Redis", + "entry_points": [ + { + "kind": "http", + "identifier": "handler_0", + "file_path": "app/entry/e0.py", + "line": 10, + "method": "GET", + "route": null, + "auth_required": true + }, + { + "kind": "HTTP", + "identifier": "handler_1", + "file_path": "app/entry/e1.py", + "line": 17, + "method": "POST", + "route": "/v1/resource/1", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "handler_2", + "file_path": "app/entry/e2.py", + "line": 24, + "method": null, + "route": "/v1/resource/2", + "auth_required": null + }, + { + "kind": "api", + "identifier": "handler_3", + "file_path": "app/entry/e3.py", + "line": 31, + "method": "PUT", + "route": null, + "auth_required": true + }, + { + "kind": "graphql", + "identifier": "handler_4", + "file_path": "app/entry/e4.py", + "line": 38, + "method": "GET", + "route": "/v1/resource/4", + "auth_required": false + }, + { + "kind": "rpc", + "identifier": "handler_5", + "file_path": "app/entry/e5.py", + "line": 45, + "method": "POST", + "route": "/v1/resource/5", + "auth_required": null + }, + { + "kind": "route", + "identifier": "handler_6", + "file_path": "app/entry/e6.py", + "line": 52, + "method": null, + "route": null, + "auth_required": true + }, + { + "kind": "cron", + "identifier": "handler_7", + "file_path": "app/entry/e7.py", + "line": 59, + "method": "PUT", + "route": "/v1/resource/7", + "auth_required": false + }, + { + "kind": "queue", + "identifier": "handler_8", + "file_path": "app/entry/e8.py", + "line": 66, + "method": "GET", + "route": "/v1/resource/8", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_9", + "file_path": "app/entry/e9.py", + "line": 73, + "method": "POST", + "route": null, + "auth_required": true + }, + { + "kind": "api", + "identifier": "handler_10", + "file_path": "app/entry/e10.py", + "line": 80, + "method": null, + "route": "/v1/resource/10", + "auth_required": false + }, + { + "kind": "grpc", + "identifier": "handler_11", + "file_path": "app/entry/e11.py", + "line": 87, + "method": "PUT", + "route": "/v1/resource/11", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_12", + "file_path": "app/entry/e12.py", + "line": 94, + "method": "GET", + "route": null, + "auth_required": true + }, + { + "kind": "route", + "identifier": "handler_13", + "file_path": "app/entry/e13.py", + "line": 101, + "method": "POST", + "route": "/v1/resource/13", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "handler_14", + "file_path": "app/entry/e14.py", + "line": 108, + "method": null, + "route": "/v1/resource/14", + "auth_required": null + } + ], + "api_surface": [ + { + "method": "GET", + "path": "/api/v1/thing/0", + "handler": "ThingController.action0", + "file_path": "app/api/thing_0.py", + "line": 100, + "auth_required": true, + "rate_limited": null + }, + { + "method": "POST", + "path": "/api/v1/thing/1", + "handler": "ThingController.action1", + "file_path": "app/api/thing_1.py", + "line": 103, + "auth_required": false, + "rate_limited": false + }, + { + "method": "PUT", + "path": "/api/v1/thing/2", + "handler": "ThingController.action2", + "file_path": "app/api/thing_2.py", + "line": 106, + "auth_required": null, + "rate_limited": true + }, + { + "method": "DELETE", + "path": "/api/v1/thing/3", + "handler": "ThingController.action3", + "file_path": "app/api/thing_3.py", + "line": 109, + "auth_required": false, + "rate_limited": false + }, + { + "method": "PATCH", + "path": "/api/v1/thing/4", + "handler": "ThingController.action4", + "file_path": "app/api/thing_4.py", + "line": 112, + "auth_required": true, + "rate_limited": null + }, + { + "method": "GET", + "path": "/api/v1/thing/5", + "handler": "ThingController.action5", + "file_path": "app/api/thing_5.py", + "line": 115, + "auth_required": null, + "rate_limited": true + }, + { + "method": "POST", + "path": "/api/v1/thing/6", + "handler": "ThingController.action6", + "file_path": "app/api/thing_6.py", + "line": 118, + "auth_required": true, + "rate_limited": null + }, + { + "method": "PUT", + "path": "/api/v1/thing/7", + "handler": "ThingController.action7", + "file_path": "app/api/thing_7.py", + "line": 121, + "auth_required": false, + "rate_limited": false + }, + { + "method": "DELETE", + "path": "/api/v1/thing/8", + "handler": "ThingController.action8", + "file_path": "app/api/thing_8.py", + "line": 124, + "auth_required": null, + "rate_limited": true + }, + { + "method": "PATCH", + "path": "/api/v1/thing/9", + "handler": "ThingController.action9", + "file_path": "app/api/thing_9.py", + "line": 127, + "auth_required": false, + "rate_limited": false + }, + { + "method": "GET", + "path": "/api/v1/thing/10", + "handler": "ThingController.action10", + "file_path": "app/api/thing_10.py", + "line": 130, + "auth_required": true, + "rate_limited": null + }, + { + "method": "POST", + "path": "/api/v1/thing/11", + "handler": "ThingController.action11", + "file_path": "app/api/thing_11.py", + "line": 133, + "auth_required": null, + "rate_limited": true + }, + { + "method": "PUT", + "path": "/api/v1/thing/12", + "handler": "ThingController.action12", + "file_path": "app/api/thing_12.py", + "line": 136, + "auth_required": true, + "rate_limited": null + }, + { + "method": "DELETE", + "path": "/api/v1/thing/13", + "handler": "ThingController.action13", + "file_path": "app/api/thing_13.py", + "line": 139, + "auth_required": false, + "rate_limited": false + }, + { + "method": "PATCH", + "path": "/api/v1/thing/14", + "handler": "ThingController.action14", + "file_path": "app/api/thing_14.py", + "line": 142, + "auth_required": null, + "rate_limited": true + }, + { + "method": "GET", + "path": "/api/v1/thing/15", + "handler": "ThingController.action15", + "file_path": "app/api/thing_15.py", + "line": 145, + "auth_required": false, + "rate_limited": false + }, + { + "method": "POST", + "path": "/api/v1/thing/16", + "handler": "ThingController.action16", + "file_path": "app/api/thing_16.py", + "line": 148, + "auth_required": true, + "rate_limited": null + }, + { + "method": "PUT", + "path": "/api/v1/thing/17", + "handler": "ThingController.action17", + "file_path": "app/api/thing_17.py", + "line": 151, + "auth_required": null, + "rate_limited": true + }, + { + "method": "DELETE", + "path": "/api/v1/thing/18", + "handler": "ThingController.action18", + "file_path": "app/api/thing_18.py", + "line": 154, + "auth_required": true, + "rate_limited": null + }, + { + "method": "PATCH", + "path": "/api/v1/thing/19", + "handler": "ThingController.action19", + "file_path": "app/api/thing_19.py", + "line": 157, + "auth_required": false, + "rate_limited": false + } + ], + "data_flows": [ + { + "source": "request.args['q']", + "path": [ + { + "file_path": "app/search/index.ts", + "line": 20, + "component": "step_0_a", + "operation": "read" + }, + { + "file_path": "app/util/b.py", + "line": 40, + "component": "step_0_b", + "operation": "write" + } + ], + "sink": "cursor.execute", + "sanitized": false, + "files": [ + "app/search/index.ts", + "app/db/raw.py", + "app/util/a.py", + "app/util/b.py" + ] + }, + { + "source": "request.cookies['session']", + "path": [ + { + "file_path": "app/auth/session_store.py", + "line": 21, + "component": "step_1_a", + "operation": "read" + }, + { + "file_path": "app/auth/session_store.py", + "line": 41, + "component": "step_1_b", + "operation": "write" + } + ], + "sink": "redis.set", + "sanitized": true, + "files": [ + "app/auth/session_store.py" + ] + }, + { + "source": "request.json['url']", + "path": [ + { + "file_path": "app/integrations/webhooks.go", + "line": 22, + "component": "step_2_a", + "operation": "read" + }, + { + "file_path": "app/net/client.go", + "line": 42, + "component": "step_2_b", + "operation": "write" + } + ], + "sink": "requests.get", + "sanitized": false, + "files": [ + "app/integrations/webhooks.go", + "app/net/client.go" + ] + }, + { + "source": "form['password']", + "path": [ + { + "file_path": "app/auth/service.py", + "line": 23, + "component": "step_3_a", + "operation": "read" + }, + { + "file_path": "app/obs/telemetry.go", + "line": 43, + "component": "step_3_b", + "operation": "write" + } + ], + "sink": "logger.info", + "sanitized": false, + "files": [ + "app/auth/service.py", + "app/obs/telemetry.go" + ] + }, + { + "source": "header['Authorization']", + "path": [ + { + "file_path": "app/common/jwt_tools.py", + "line": 24, + "component": "step_4_a", + "operation": "read" + }, + { + "file_path": "app/common/jwt_tools.py", + "line": 44, + "component": "step_4_b", + "operation": "write" + } + ], + "sink": "jwt.decode", + "sanitized": true, + "files": [ + "app/common/jwt_tools.py" + ] + }, + { + "source": "query['email']", + "path": [ + { + "file_path": "app/notify/mailer.rb", + "line": 25, + "component": "step_5_a", + "operation": "read" + }, + { + "file_path": "app/notify/mailer.rb", + "line": 45, + "component": "step_5_b", + "operation": "write" + } + ], + "sink": "smtp.send", + "sanitized": false, + "files": [ + "app/notify/mailer.rb" + ] + }, + { + "source": "body['card_number']", + "path": [ + { + "file_path": "app/billing/payments.go", + "line": 26, + "component": "step_6_a", + "operation": "read" + }, + { + "file_path": "app/billing/core.py", + "line": 46, + "component": "step_6_b", + "operation": "write" + } + ], + "sink": "stripe.Charge.create", + "sanitized": true, + "files": [ + "app/billing/payments.go", + "app/billing/core.py" + ] + }, + { + "source": "path_param['id']", + "path": [ + { + "file_path": "app/api/thing_1.py", + "line": 27, + "component": "step_7_a", + "operation": "read" + }, + { + "file_path": "app/api/thing_1.py", + "line": 47, + "component": "step_7_b", + "operation": "write" + } + ], + "sink": "orm.filter", + "sanitized": true, + "files": [ + "app/api/thing_1.py" + ] + }, + { + "source": "upload.filename", + "path": [ + { + "file_path": "app/media/uploads.py", + "line": 28, + "component": "step_8_a", + "operation": "read" + }, + { + "file_path": "app/x/w.py", + "line": 48, + "component": "step_8_b", + "operation": "write" + } + ], + "sink": "open", + "sanitized": false, + "files": [ + "app/media/uploads.py", + "app/common/cache.py", + "app/common/guard.py", + "app/x/y.py", + "app/x/z.py", + "app/x/w.py" + ] + }, + { + "source": "env['DEBUG']", + "path": [ + { + "file_path": "app/web/render.py", + "line": 29, + "component": "step_9_a", + "operation": "read" + }, + { + "file_path": "app/web/render.py", + "line": 49, + "component": "step_9_b", + "operation": "write" + } + ], + "sink": "template.render", + "sanitized": false, + "files": [ + "app/web/render.py" + ] + }, + { + "source": "request.body", + "path": [ + { + "file_path": "app/admin/views.py", + "line": 30, + "component": "step_10_a", + "operation": "read" + }, + { + "file_path": "app/admin/views.py", + "line": 50, + "component": "step_10_b", + "operation": "write" + } + ], + "sink": "eval", + "sanitized": false, + "files": [ + "app/admin/views.py" + ] + }, + { + "source": "websocket.msg", + "path": [ + { + "file_path": "app/ws/handler.py", + "line": 31, + "component": "step_11_a", + "operation": "read" + }, + { + "file_path": "app/ws/exec.py", + "line": 51, + "component": "step_11_b", + "operation": "write" + } + ], + "sink": "subprocess.run", + "sanitized": false, + "files": [ + "app/ws/handler.py", + "app/ws/exec.py" + ] + }, + { + "source": "cli.argv", + "path": [ + { + "file_path": "tools/run.py", + "line": 32, + "component": "step_12_a", + "operation": "read" + }, + { + "file_path": "tools/run.py", + "line": 52, + "component": "step_12_b", + "operation": "write" + } + ], + "sink": "os.system", + "sanitized": false, + "files": [ + "tools/run.py" + ] + }, + { + "source": "queue.payload", + "path": [ + { + "file_path": "app/worker/task.py", + "line": 33, + "component": "step_13_a", + "operation": "read" + }, + { + "file_path": "app/worker/task.py", + "line": 53, + "component": "step_13_b", + "operation": "write" + } + ], + "sink": "pickle.loads", + "sanitized": false, + "files": [ + "app/worker/task.py" + ] + }, + { + "source": "request.args['redirect']", + "path": [ + { + "file_path": "app/web/redirect.py", + "line": 34, + "component": "step_14_a", + "operation": "read" + }, + { + "file_path": "app/web/redirect.py", + "line": 54, + "component": "step_14_b", + "operation": "write" + } + ], + "sink": "HttpResponseRedirect", + "sanitized": false, + "files": [ + "app/web/redirect.py" + ] + }, + { + "source": "session['role']", + "path": [ + { + "file_path": "app/auth/permissions.py", + "line": 35, + "component": "step_15_a", + "operation": "read" + }, + { + "file_path": "app/auth/rbac.py", + "line": 55, + "component": "step_15_b", + "operation": "write" + } + ], + "sink": "permission_check", + "sanitized": true, + "files": [ + "app/auth/permissions.py", + "app/auth/rbac.py" + ] + }, + { + "source": "request.files['avatar']", + "path": [ + { + "file_path": "app/media/uploads.py", + "line": 36, + "component": "step_16_a", + "operation": "read" + }, + { + "file_path": "app/media/uploads.py", + "line": 56, + "component": "step_16_b", + "operation": "write" + } + ], + "sink": "s3.put_object", + "sanitized": true, + "files": [ + "app/media/uploads.py" + ] + }, + { + "source": "token", + "path": [ + { + "file_path": "app/common/cache.py", + "line": 37, + "component": "step_17_a", + "operation": "read" + }, + { + "file_path": "app/common/cache.py", + "line": 57, + "component": "step_17_b", + "operation": "write" + } + ], + "sink": "cache.set", + "sanitized": true, + "files": [ + "app/common/cache.py" + ] + }, + { + "source": "request.headers['X-User-Phone']", + "path": [ + { + "file_path": "app/obs/audit.py", + "line": 38, + "component": "step_18_a", + "operation": "read" + }, + { + "file_path": "app/obs/audit.py", + "line": 58, + "component": "step_18_b", + "operation": "write" + } + ], + "sink": "audit_log", + "sanitized": false, + "files": [ + "app/obs/audit.py" + ] + }, + { + "source": "graphql.variables", + "path": [ + { + "file_path": "app/api/graphql.ts", + "line": 39, + "component": "step_19_a", + "operation": "read" + }, + { + "file_path": "app/api/graphql.ts", + "line": 59, + "component": "step_19_b", + "operation": "write" + } + ], + "sink": "db.query", + "sanitized": false, + "files": [ + "app/api/graphql.ts" + ] + } + ] +} + +OBJECTIVE: +Find business-logic vulnerabilities by reasoning about intended behavior versus actual implementation behavior. + +FOCUS CWES: +- CWE-840: Business Logic Errors +- CWE-841: Improper Enforcement of Behavioral Workflow +- CWE-362: Race Condition +- CWE-367: Time-of-check Time-of-use (TOCTOU) Race Condition +- CWE-639: Authorization Bypass Through User-Controlled Key (IDOR patterns) + +HUNT FOR THESE PATTERNS: +- Race conditions in concurrent requests, especially check-then-act updates without robust locking/transactions +- State machine violations in multi-step workflows (skipping mandatory steps or reordering transitions) +- Missing validation of amounts, ownership, limits, or permissions beyond basic authentication +- Workflow step bypasses (for example, directly invoking step 3 from step 1) +- Integer overflow/underflow or sign bugs in balances, pricing, credits, quantities, and counters +- TOCTOU vulnerabilities where validation/check and use/mutation are separable and exploitable +- IDOR beyond missing auth (predictable identifiers, insecure ownership reassignment, mass assignment to owner/account fields) +- Price manipulation (client-controlled cart totals, discounts, tax, shipping, or final payable amounts) + +DO NOT FLAG: +- Properly locked/serialized concurrent operations +- Explicitly validated state transitions with strict transition guards +- Server-side amount/price calculation that ignores client-submitted totals +- Rate-limited operations where the suspected abuse depends on rapid repetition + +ANALYSIS GUIDELINES: +- Start from recon entry points and data flows, then trace complete end-to-end business workflows. +- Compare behavior across layers (controller/API, domain/service, persistence, queue/worker). +- Validate whether business invariants are enforced at mutation boundaries. +- Distinguish real exploit paths from expected eventual consistency or benign races. +- Include only evidence-backed findings with concrete exploit narrative. + +OUTPUT REQUIREMENTS: +- Return JSON that strictly matches HuntResult. +- Set finding_type to "logic" for business-logic findings. +- Use cwe_id and cwe_name that match the specific issue. +- Populate file_path, start_line, end_line, code_snippet, and related_files whenever possible. +- Generate stable fingerprints from vulnerability identity and location. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. + +Language: JAVASCRIPT + Key sinks: eval, Function(), innerHTML, document.write, child_process.exec, new Function + Safe patterns (skip these): DOMPurify.sanitize(), textContent assignment, parameterized pg queries + DO NOT FLAG: React JSX expressions (auto-escaped), Angular template bindings (sanitized by default) + Framework notes: Check for React/Vue/Angular-specific XSS patterns. React auto-escapes JSX. Vue v-html is dangerous. + +Language: GO + Key sinks: fmt.Sprintf into SQL, exec.Command with user input, template.HTML(), os.Exec + Safe patterns (skip these): database/sql with ? placeholders, html/template (auto-escapes), exec.Command with separate args + DO NOT FLAG: GORM parameterized queries, html/template default escaping, database/sql prepared statements + Framework notes: Go's html/template auto-escapes. text/template does NOT. Check for text/template serving HTML. + +Language: RUBY + Key sinks: eval, system, exec, send, public_send, ERB.new with user input + Safe patterns (skip these): ActiveRecord parameterized queries, Rack::Utils.escape_html, sanitize helper in Rails + DO NOT FLAG: ActiveRecord where with hash conditions, Rails CSRF protection, Rails html_safe on constants + Framework notes: Rails auto-escapes ERB templates. raw/html_safe bypasses escaping. Check for mass assignment. + +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +Framework: NEXTJS + Security features: React JSX auto-escapes output by default, API routes can share centralized auth middleware, Server/client boundaries reduce accidental secret exposure + DO NOT FLAG: React JSX escaped rendering, getServerSideProps/getServerSession with proper auth checks, Typed route handlers with validated schema guards + Watch for: dangerouslySetInnerHTML, unprotected API routes under /api, secret leakage to client bundles, rewrites/redirects from untrusted user input + Common vulns: XSS through dangerous HTML rendering, IDOR/auth bypass in API handlers, Sensitive env/config exposure in client-side code + +Framework: SPRING + Security features: Spring Security provides auth, CSRF, and filter chain defaults, JPA/Hibernate prepared parameter binding by default, Bean validation can enforce request constraints + DO NOT FLAG: PreparedStatement/JPA named parameter usage, Spring Security CSRF/auth filters clearly active, Thymeleaf auto-escaped output + Watch for: @PreAuthorize missing on sensitive methods, JdbcTemplate/raw Statement with concatenation, csrf().disable() on browser session flows, Actuator endpoints exposed without auth + Common vulns: Authz bypass from weak method-level security, SQL injection in raw JDBC queries, Sensitive management endpoint exposure + +Framework: REACT + Security features: JSX escapes strings before DOM rendering, Component model encourages explicit data flow, Framework discourages direct DOM mutation + DO NOT FLAG: Standard JSX expression rendering, textContent assignment for untrusted text, sanitized HTML via trusted DOMPurify policy + Watch for: dangerouslySetInnerHTML, untrusted URL assignment to href/src, eval/new Function in client code, token/secret exposure in bundles + Common vulns: DOM XSS through unsafe HTML injection, Open redirect via unvalidated navigation targets, Sensitive data exposure in frontend artifacts + +Framework: VUE + Security features: Mustache template interpolation escapes HTML, Component props/events provide explicit boundaries, Router guards can enforce auth flows + DO NOT FLAG: escaped template interpolation {{ value }}, validated route guards protecting private routes, sanitized content rendered through safe components + Watch for: v-html with untrusted data, dynamic component/template compilation, unsafe URL bindings in href/src, client-side auth checks without server enforcement + Common vulns: XSS through v-html and unsafe render paths, Auth bypass from client-only route protection, Open redirect patterns in router navigation + +CONSTRAINTS: +- Take multiple turns: investigate workflow intent, validate exploitability, then produce findings. +- Prefer high-confidence findings over speculative broad coverage. +- No markdown, no prose outside JSON, no code fences. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Depth profile: standard +- Early stop rule: if you inspect 30 files without credible business-logic signal, stop and return empty findings. +- Strategy: business_logic +- Focus CWEs: CWE-840, CWE-841, CWE-362, CWE-367, CWE-639. +- Reason about intended business behavior versus exploitable implementation behavior. +- Take multiple turns, trace complete workflows, and return final JSON only when complete. diff --git a/go/internal/agents/hunt/testdata/golden/prompt_business_logic_thorough.txt b/go/internal/agents/hunt/testdata/golden/prompt_business_logic_thorough.txt new file mode 100644 index 0000000..ef49bc8 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_business_logic_thorough.txt @@ -0,0 +1,163 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are the SEC-AF Business Logic Hunter focused on high-impact workflow abuse and invariant violations. + +CONTEXT: +You are in HUNT phase. Analyze this repository using the provided recon context and return HuntResult JSON. + +RECON_CONTEXT: +{ + "app_type": "web_api", + "frameworks": [ + "django" + ], + "languages": [ + "python" + ], + "auth_model": "jwt", + "auth_details": "HS256 access tokens", + "entry_points": [ + { + "kind": "http", + "identifier": "POST /login", + "file_path": "app/api/auth.py", + "line": 12, + "method": "POST", + "route": "/login", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "seed", + "file_path": "app/cli.py", + "line": 3, + "method": null, + "route": null, + "auth_required": null + } + ], + "api_surface": [ + { + "method": "POST", + "path": "/login", + "handler": "login", + "file_path": "app/api/auth.py", + "line": 12, + "auth_required": false, + "rate_limited": false + }, + { + "method": "GET", + "path": "/users/{id}", + "handler": "get_user", + "file_path": "app/api/users.py", + "line": 40, + "auth_required": true, + "rate_limited": true + } + ], + "data_flows": [ + { + "source": "request.json", + "path": [ + { + "file_path": "app/api/users.py", + "line": 41, + "component": "handler", + "operation": "read id" + } + ], + "sink": "cursor.execute", + "sanitized": false, + "files": [ + "app/api/users.py" + ] + } + ] +} + +OBJECTIVE: +Find business-logic vulnerabilities by reasoning about intended behavior versus actual implementation behavior. + +FOCUS CWES: +- CWE-840: Business Logic Errors +- CWE-841: Improper Enforcement of Behavioral Workflow +- CWE-362: Race Condition +- CWE-367: Time-of-check Time-of-use (TOCTOU) Race Condition +- CWE-639: Authorization Bypass Through User-Controlled Key (IDOR patterns) + +HUNT FOR THESE PATTERNS: +- Race conditions in concurrent requests, especially check-then-act updates without robust locking/transactions +- State machine violations in multi-step workflows (skipping mandatory steps or reordering transitions) +- Missing validation of amounts, ownership, limits, or permissions beyond basic authentication +- Workflow step bypasses (for example, directly invoking step 3 from step 1) +- Integer overflow/underflow or sign bugs in balances, pricing, credits, quantities, and counters +- TOCTOU vulnerabilities where validation/check and use/mutation are separable and exploitable +- IDOR beyond missing auth (predictable identifiers, insecure ownership reassignment, mass assignment to owner/account fields) +- Price manipulation (client-controlled cart totals, discounts, tax, shipping, or final payable amounts) + +DO NOT FLAG: +- Properly locked/serialized concurrent operations +- Explicitly validated state transitions with strict transition guards +- Server-side amount/price calculation that ignores client-submitted totals +- Rate-limited operations where the suspected abuse depends on rapid repetition + +ANALYSIS GUIDELINES: +- Start from recon entry points and data flows, then trace complete end-to-end business workflows. +- Compare behavior across layers (controller/API, domain/service, persistence, queue/worker). +- Validate whether business invariants are enforced at mutation boundaries. +- Distinguish real exploit paths from expected eventual consistency or benign races. +- Include only evidence-backed findings with concrete exploit narrative. + +OUTPUT REQUIREMENTS: +- Return JSON that strictly matches HuntResult. +- Set finding_type to "logic" for business-logic findings. +- Use cwe_id and cwe_name that match the specific issue. +- Populate file_path, start_line, end_line, code_snippet, and related_files whenever possible. +- Generate stable fingerprints from vulnerability identity and location. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +CONSTRAINTS: +- Take multiple turns: investigate workflow intent, validate exploitability, then produce findings. +- Prefer high-confidence findings over speculative broad coverage. +- No markdown, no prose outside JSON, no code fences. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Depth profile: thorough +- Early stop rule: if you inspect 30 files without credible business-logic signal, stop and return empty findings. +- Strategy: business_logic +- Focus CWEs: CWE-840, CWE-841, CWE-362, CWE-367, CWE-639. +- Reason about intended business behavior versus exploitable implementation behavior. +- Take multiple turns, trace complete workflows, and return final JSON only when complete. diff --git a/go/internal/agents/hunt/testdata/golden/prompt_config_secrets_standard.txt b/go/internal/agents/hunt/testdata/golden/prompt_config_secrets_standard.txt new file mode 100644 index 0000000..623f087 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_config_secrets_standard.txt @@ -0,0 +1,178 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are the SEC-AF Config/Secrets Hunter for HUNT phase. + +CONTEXT: +You receive RECON context summary and must produce HuntResult JSON for strategy config_secrets. + +RECON_CONTEXT: +Config and secrets-focused recon summary. + +Detected secret-like findings: 17 total, showing top 15: +- aws_access_key at config/env_0.yaml:3; confidence=high; is_test_value=False +- github_token at config/env_1.yaml:4; confidence=medium; is_test_value=True +- private_key at config/env_2.yaml:5; confidence=low; is_test_value=None +- slack_webhook at config/env_3.yaml:6; confidence=high; is_test_value=False +- generic_api_key at config/env_4.yaml:7; confidence=medium; is_test_value=True +- aws_access_key at config/env_5.yaml:8; confidence=low; is_test_value=None +- github_token at config/env_6.yaml:9; confidence=high; is_test_value=False +- private_key at config/env_7.yaml:10; confidence=medium; is_test_value=True +- slack_webhook at config/env_8.yaml:11; confidence=low; is_test_value=None +- generic_api_key at config/env_9.yaml:12; confidence=high; is_test_value=False +- aws_access_key at config/env_10.yaml:13; confidence=medium; is_test_value=True +- github_token at config/env_11.yaml:14; confidence=low; is_test_value=None +- private_key at config/env_12.yaml:15; confidence=high; is_test_value=False +- slack_webhook at config/env_13.yaml:16; confidence=medium; is_test_value=True +- generic_api_key at config/env_14.yaml:17; confidence=low; is_test_value=None + +Configuration weaknesses from recon: 16 total, showing top 15: +- logging at config/app_0.ini:0; risk=high; key=LOG_LEVEL +- tls at config/app_1.ini:13; risk=critical; key=SSL_VERIFY +- cors at config/app_2.ini:14; risk=high; key=ALLOW_ORIGIN +- headers at config/app_3.ini:0; risk=medium; key=X_FRAME_OPTIONS +- debug at config/app_4.ini:16; risk=critical; key=DJANGO_DEBUG +- storage at config/app_5.ini:0; risk=critical; key=BUCKET_ACL +- auth at config/app_6.ini:18; risk=medium; key=SESSION_TIMEOUT +- http at config/app_7.ini:19; risk=high; key=REDIRECT_HTTPS +- secrets at config/app_8.ini:20; risk=medium; key=n/a +- trace at config/app_9.ini:21; risk=low; key=OTEL_TRACE_ALL +- network at config/app_10.ini:0; risk=medium; key=BIND_ADDR +- errors at config/app_11.ini:23; risk=high; key=SHOW_STACKTRACE +- cache at config/app_12.ini:24; risk=low; key=CACHE_TTL +- db at config/app_13.ini:25; risk=critical; key=SSLMODE +- queue at config/app_14.ini:26; risk=low; key=PREFETCH + +Security/deployment context affecting config risk: 13 total, showing top 10: +- kubernetes ingress with TLS termination +- docker-compose exposes 5432 +- no WAF in front of /api +- secrets mounted from vault +- readiness probe on /healthz +- single replica for the worker +- TLS 1.2 minimum +- internal service mesh mTLS +- django.middleware.csrf.CsrfViewMiddleware +- helmet defaults + +OBJECTIVE: +Find real security findings for: +- CWE-798 Hardcoded Credentials +- CWE-259 Hard-Coded Password +- CWE-321 Hard-Coded Cryptographic Key +- CWE-16 Configuration weaknesses + +HUNT FOR: +- API keys, passwords, bearer tokens, database credentials, cloud credentials, signing secrets, encryption keys hardcoded in source. +- Insecure defaults and configuration issues such as DEBUG enabled in production paths, permissive CORS (`*`), insecure cookies, disabled TLS verification, missing security headers, exposed admin/debug endpoints. + +DO NOT FLAG: +- `.env.example`, `config.example.*`, template/sample config files. +- Test fixtures and explicit fake values used for tests. +- Documentation snippets. +- Secure environment-variable usage patterns (for example `os.getenv(...)`, `process.env.*`, `${ENV_VAR}`). + +OUTPUT REQUIREMENTS: +- Return strict JSON matching HuntResult. +- Set `hunter_strategy` to `config_secrets` on every finding. +- Use `finding_type` as `secrets` for hardcoded secrets and `config` for configuration issues. +- Include precise file path, line range, and code snippet evidence. +- Include related files when context spans multiple files. +- Add reviewer metadata in `description` using this suffix format for each finding: + `[is_test_file=;is_example=]` + +QUALITY BAR: +- Prioritize high-confidence, exploitable findings over noisy pattern matches. +- Distinguish real credentials from placeholders by context, naming, entropy, and usage. +- If uncertain, lower confidence instead of over-claiming. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. + +Language: JAVASCRIPT + Key sinks: eval, Function(), innerHTML, document.write, child_process.exec, new Function + Safe patterns (skip these): DOMPurify.sanitize(), textContent assignment, parameterized pg queries + DO NOT FLAG: React JSX expressions (auto-escaped), Angular template bindings (sanitized by default) + Framework notes: Check for React/Vue/Angular-specific XSS patterns. React auto-escapes JSX. Vue v-html is dangerous. + +Language: GO + Key sinks: fmt.Sprintf into SQL, exec.Command with user input, template.HTML(), os.Exec + Safe patterns (skip these): database/sql with ? placeholders, html/template (auto-escapes), exec.Command with separate args + DO NOT FLAG: GORM parameterized queries, html/template default escaping, database/sql prepared statements + Framework notes: Go's html/template auto-escapes. text/template does NOT. Check for text/template serving HTML. + +Language: RUBY + Key sinks: eval, system, exec, send, public_send, ERB.new with user input + Safe patterns (skip these): ActiveRecord parameterized queries, Rack::Utils.escape_html, sanitize helper in Rails + DO NOT FLAG: ActiveRecord where with hash conditions, Rails CSRF protection, Rails html_safe on constants + Framework notes: Rails auto-escapes ERB templates. raw/html_safe bypasses escaping. Check for mass assignment. + +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +Framework: NEXTJS + Security features: React JSX auto-escapes output by default, API routes can share centralized auth middleware, Server/client boundaries reduce accidental secret exposure + DO NOT FLAG: React JSX escaped rendering, getServerSideProps/getServerSession with proper auth checks, Typed route handlers with validated schema guards + Watch for: dangerouslySetInnerHTML, unprotected API routes under /api, secret leakage to client bundles, rewrites/redirects from untrusted user input + Common vulns: XSS through dangerous HTML rendering, IDOR/auth bypass in API handlers, Sensitive env/config exposure in client-side code + +Framework: SPRING + Security features: Spring Security provides auth, CSRF, and filter chain defaults, JPA/Hibernate prepared parameter binding by default, Bean validation can enforce request constraints + DO NOT FLAG: PreparedStatement/JPA named parameter usage, Spring Security CSRF/auth filters clearly active, Thymeleaf auto-escaped output + Watch for: @PreAuthorize missing on sensitive methods, JdbcTemplate/raw Statement with concatenation, csrf().disable() on browser session flows, Actuator endpoints exposed without auth + Common vulns: Authz bypass from weak method-level security, SQL injection in raw JDBC queries, Sensitive management endpoint exposure + +Framework: REACT + Security features: JSX escapes strings before DOM rendering, Component model encourages explicit data flow, Framework discourages direct DOM mutation + DO NOT FLAG: Standard JSX expression rendering, textContent assignment for untrusted text, sanitized HTML via trusted DOMPurify policy + Watch for: dangerouslySetInnerHTML, untrusted URL assignment to href/src, eval/new Function in client code, token/secret exposure in bundles + Common vulns: DOM XSS through unsafe HTML injection, Open redirect via unvalidated navigation targets, Sensitive data exposure in frontend artifacts + +Framework: VUE + Security features: Mustache template interpolation escapes HTML, Component props/events provide explicit boundaries, Router guards can enforce auth flows + DO NOT FLAG: escaped template interpolation {{ value }}, validated route guards protecting private routes, sanitized content rendered through safe components + Watch for: v-html with untrusted data, dynamic component/template compilation, unsafe URL bindings in href/src, client-side auth checks without server enforcement + Common vulns: XSS through v-html and unsafe render paths, Auth bypass from client-only route protection, Open redirect patterns in router navigation + +CONSTRAINTS: +- Take multiple turns: first explore files, then validate candidate findings, then output final JSON. +- No markdown or code fences. JSON only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Hunt strategy: config_secrets (CWE-798, CWE-259, CWE-321, CWE-16). +- Early stop rule: if you inspect standard files without credible secrets/config issues, stop and return empty findings. +- Use RECON ConfigReport and SecurityContext to prioritize likely real findings. +- Take multiple turns: inspect files, validate exploitability signal, then build findings. + diff --git a/go/internal/agents/hunt/testdata/golden/prompt_config_secrets_thorough.txt b/go/internal/agents/hunt/testdata/golden/prompt_config_secrets_thorough.txt new file mode 100644 index 0000000..e234ec5 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_config_secrets_thorough.txt @@ -0,0 +1,94 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are the SEC-AF Config/Secrets Hunter for HUNT phase. + +CONTEXT: +You receive RECON context summary and must produce HuntResult JSON for strategy config_secrets. + +RECON_CONTEXT: +Config and secrets-focused recon summary. + +Detected secret-like findings: 1 total, showing top 1: +- api_key at .env:2; confidence=high; is_test_value=False + +Configuration weaknesses from recon: 1 total, showing top 1: +- debug at settings.py:9; risk=high; key=DEBUG + +Security/deployment context affecting config risk: 2 total, showing top 2: +- docker +- django-csrf + +OBJECTIVE: +Find real security findings for: +- CWE-798 Hardcoded Credentials +- CWE-259 Hard-Coded Password +- CWE-321 Hard-Coded Cryptographic Key +- CWE-16 Configuration weaknesses + +HUNT FOR: +- API keys, passwords, bearer tokens, database credentials, cloud credentials, signing secrets, encryption keys hardcoded in source. +- Insecure defaults and configuration issues such as DEBUG enabled in production paths, permissive CORS (`*`), insecure cookies, disabled TLS verification, missing security headers, exposed admin/debug endpoints. + +DO NOT FLAG: +- `.env.example`, `config.example.*`, template/sample config files. +- Test fixtures and explicit fake values used for tests. +- Documentation snippets. +- Secure environment-variable usage patterns (for example `os.getenv(...)`, `process.env.*`, `${ENV_VAR}`). + +OUTPUT REQUIREMENTS: +- Return strict JSON matching HuntResult. +- Set `hunter_strategy` to `config_secrets` on every finding. +- Use `finding_type` as `secrets` for hardcoded secrets and `config` for configuration issues. +- Include precise file path, line range, and code snippet evidence. +- Include related files when context spans multiple files. +- Add reviewer metadata in `description` using this suffix format for each finding: + `[is_test_file=;is_example=]` + +QUALITY BAR: +- Prioritize high-confidence, exploitable findings over noisy pattern matches. +- Distinguish real credentials from placeholders by context, naming, entropy, and usage. +- If uncertain, lower confidence instead of over-claiming. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +CONSTRAINTS: +- Take multiple turns: first explore files, then validate candidate findings, then output final JSON. +- No markdown or code fences. JSON only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Hunt strategy: config_secrets (CWE-798, CWE-259, CWE-321, CWE-16). +- Early stop rule: if you inspect thorough files without credible secrets/config issues, stop and return empty findings. +- Use RECON ConfigReport and SecurityContext to prioritize likely real findings. +- Take multiple turns: inspect files, validate exploitability signal, then build findings. + diff --git a/go/internal/agents/hunt/testdata/golden/prompt_crypto_standard.txt b/go/internal/agents/hunt/testdata/golden/prompt_crypto_standard.txt new file mode 100644 index 0000000..19f0f0a --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_crypto_standard.txt @@ -0,0 +1,185 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are the SEC-AF Cryptography Hunter specializing in weak cryptography and predictable randomness. + +CONTEXT: +You are in SEC-AF HUNT phase and must return HuntResult JSON. +Use the RECON context summary below. +Focus on whether detected crypto patterns are used in security-sensitive contexts. + +RECON_CONTEXT: +Cryptography-focused recon summary. + +Crypto usage entries: 16 total. + +Algorithms and key handling (weak entries first): 16 total, showing top 15: +- algorithm=MD5, key_size=None, mode=None, context=password hashing, is_weak=True +- algorithm=RSA, key_size=1024, mode=None, context=token signing, is_weak=True +- algorithm=DES, key_size=56, mode=CBC, context=legacy export, is_weak=True +- algorithm=HMAC-SHA1, key_size=160, mode=None, context=webhook signature, is_weak=True +- algorithm=RC4, key_size=128, mode=None, context=unspecified, is_weak=True +- algorithm=SHA-1, key_size=None, mode=None, context=checksum, is_weak=True +- algorithm=AES, key_size=128, mode=ECB, context=legacy blob, is_weak=True +- algorithm=3DES, key_size=168, mode=CBC, context=legacy tape, is_weak=True +- algorithm=AES, key_size=256, mode=GCM, context=at-rest encryption, is_weak=False +- algorithm=SHA-256, key_size=None, mode=None, context=unspecified, is_weak=False +- algorithm=ChaCha20, key_size=256, mode=Poly1305, context=transport, is_weak=False +- algorithm=bcrypt, key_size=None, mode=None, context=password hashing, is_weak=False +- algorithm=ECDSA, key_size=256, mode=None, context=JWT signing, is_weak=None +- algorithm=PBKDF2, key_size=None, mode=None, context=key derivation, is_weak=False +- algorithm=Ed25519, key_size=256, mode=None, context=package signing, is_weak=False + +Potential secret/key findings from config scan: 17 total, showing top 10: +- aws_access_key at config/env_0.yaml:3 (confidence=high) +- github_token at config/env_1.yaml:4 (confidence=medium) +- private_key at config/env_2.yaml:5 (confidence=low) +- slack_webhook at config/env_3.yaml:6 (confidence=high) +- generic_api_key at config/env_4.yaml:7 (confidence=medium) +- aws_access_key at config/env_5.yaml:8 (confidence=low) +- github_token at config/env_6.yaml:9 (confidence=high) +- private_key at config/env_7.yaml:10 (confidence=medium) +- slack_webhook at config/env_8.yaml:11 (confidence=low) +- generic_api_key at config/env_9.yaml:12 (confidence=high) + +Deployment/TLS/security header signals: 14 total, showing top 10: +- kubernetes ingress with TLS termination +- docker-compose exposes 5432 +- no WAF in front of /api +- secrets mounted from vault +- readiness probe on /healthz +- single replica for the worker +- TLS 1.2 minimum +- internal service mesh mTLS +- Content-Security-Policy: default-src 'self' +- X-Content-Type-Options: nosniff + +TASK: +Find potential vulnerabilities related to weak cryptography. + +CWE COVERAGE (REQUIRED): +- CWE-326 Inadequate Encryption Strength +- CWE-327 Broken or Risky Cryptographic Algorithm +- CWE-328 Reversible One-Way Hash +- CWE-330 Insufficiently Random Values +- CWE-916 Weak Password Hashing +- CWE-259 Hard-Coded Password +- CWE-321 Hard-Coded Cryptographic Key +- CWE-798 Hardcoded Credentials + +WHAT TO HUNT: +- Weak algorithms: MD5, SHA1 for security decisions, DES, 3DES, RC4, ECB mode. +- Inadequate key lengths: RSA < 2048, AES < 128, weak DH parameters. +- Reversible password handling: base64/encoding used as if it were hashing. +- Predictable randomness: Math.random(), non-crypto random module usage for tokens/secrets, predictable seeds. +- Weak password storage: unsalted/fast hashes, low-iteration PBKDF2, custom insecure hashing. +- Hardcoded cryptographic keys, secrets, or static IV/nonce values. +- Security-critical usage contexts: password hashing, encryption, token/session generation, signing and verification, auth flows. +- Non-critical usage contexts: checksums, ETags, cache keys, dedup/fingerprinting. + +DO NOT FLAG: +- Crypto operations in tests or fixtures. +- MD5/SHA1 used only for non-security checksums/caching/ETags/cache keys. +- bcrypt/argon2/scrypt for password hashing (these are GOOD). +- Crypto libraries used correctly with sufficient key lengths and secure modes. +- Framework secure defaults (e.g., Django PBKDF2, bcrypt, argon2). + +OUTPUT: +- Return JSON matching HuntResult. +- findings must contain RawFinding entries with evidence-backed descriptions and correct CWE mapping. +- hunter_strategy should be "crypto". + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. + +Language: JAVASCRIPT + Key sinks: eval, Function(), innerHTML, document.write, child_process.exec, new Function + Safe patterns (skip these): DOMPurify.sanitize(), textContent assignment, parameterized pg queries + DO NOT FLAG: React JSX expressions (auto-escaped), Angular template bindings (sanitized by default) + Framework notes: Check for React/Vue/Angular-specific XSS patterns. React auto-escapes JSX. Vue v-html is dangerous. + +Language: GO + Key sinks: fmt.Sprintf into SQL, exec.Command with user input, template.HTML(), os.Exec + Safe patterns (skip these): database/sql with ? placeholders, html/template (auto-escapes), exec.Command with separate args + DO NOT FLAG: GORM parameterized queries, html/template default escaping, database/sql prepared statements + Framework notes: Go's html/template auto-escapes. text/template does NOT. Check for text/template serving HTML. + +Language: RUBY + Key sinks: eval, system, exec, send, public_send, ERB.new with user input + Safe patterns (skip these): ActiveRecord parameterized queries, Rack::Utils.escape_html, sanitize helper in Rails + DO NOT FLAG: ActiveRecord where with hash conditions, Rails CSRF protection, Rails html_safe on constants + Framework notes: Rails auto-escapes ERB templates. raw/html_safe bypasses escaping. Check for mass assignment. + +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +Framework: NEXTJS + Security features: React JSX auto-escapes output by default, API routes can share centralized auth middleware, Server/client boundaries reduce accidental secret exposure + DO NOT FLAG: React JSX escaped rendering, getServerSideProps/getServerSession with proper auth checks, Typed route handlers with validated schema guards + Watch for: dangerouslySetInnerHTML, unprotected API routes under /api, secret leakage to client bundles, rewrites/redirects from untrusted user input + Common vulns: XSS through dangerous HTML rendering, IDOR/auth bypass in API handlers, Sensitive env/config exposure in client-side code + +Framework: SPRING + Security features: Spring Security provides auth, CSRF, and filter chain defaults, JPA/Hibernate prepared parameter binding by default, Bean validation can enforce request constraints + DO NOT FLAG: PreparedStatement/JPA named parameter usage, Spring Security CSRF/auth filters clearly active, Thymeleaf auto-escaped output + Watch for: @PreAuthorize missing on sensitive methods, JdbcTemplate/raw Statement with concatenation, csrf().disable() on browser session flows, Actuator endpoints exposed without auth + Common vulns: Authz bypass from weak method-level security, SQL injection in raw JDBC queries, Sensitive management endpoint exposure + +Framework: REACT + Security features: JSX escapes strings before DOM rendering, Component model encourages explicit data flow, Framework discourages direct DOM mutation + DO NOT FLAG: Standard JSX expression rendering, textContent assignment for untrusted text, sanitized HTML via trusted DOMPurify policy + Watch for: dangerouslySetInnerHTML, untrusted URL assignment to href/src, eval/new Function in client code, token/secret exposure in bundles + Common vulns: DOM XSS through unsafe HTML injection, Open redirect via unvalidated navigation targets, Sensitive data exposure in frontend artifacts + +Framework: VUE + Security features: Mustache template interpolation escapes HTML, Component props/events provide explicit boundaries, Router guards can enforce auth flows + DO NOT FLAG: escaped template interpolation {{ value }}, validated route guards protecting private routes, sanitized content rendered through safe components + Watch for: v-html with untrusted data, dynamic component/template compilation, unsafe URL bindings in href/src, client-side auth checks without server enforcement + Common vulns: XSS through v-html and unsafe render paths, Auth bypass from client-only route protection, Open redirect patterns in router navigation + +CONSTRAINTS: +- Take multiple turns: first discover crypto-relevant files, then evaluate exploitability context. +- Prefer concrete evidence (file path, line range, code snippet, why risky in context). +- No markdown/code fences; JSON output only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Hunt strategy: crypto +- Early stop rule: if you inspect standard files without credible crypto misuse, stop and return empty findings. +- Focus CWEs: CWE-326, CWE-327, CWE-328, CWE-330, CWE-916, CWE-259, CWE-321, CWE-798 +- Security-critical usage candidates: at-rest encryption, password hashing, token signing, webhook signature, password hashing, JWT signing, key derivation, package signing, password hashing +- Non-security usage candidates: checksum +- Prioritize weak crypto findings only when used in security-sensitive contexts; avoid checksum/cache-only noise. +- Take multiple turns to explore relevant files before finalizing findings. +- Write final JSON only when analysis is complete. diff --git a/go/internal/agents/hunt/testdata/golden/prompt_crypto_thorough.txt b/go/internal/agents/hunt/testdata/golden/prompt_crypto_thorough.txt new file mode 100644 index 0000000..725890c --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_crypto_thorough.txt @@ -0,0 +1,107 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are the SEC-AF Cryptography Hunter specializing in weak cryptography and predictable randomness. + +CONTEXT: +You are in SEC-AF HUNT phase and must return HuntResult JSON. +Use the RECON context summary below. +Focus on whether detected crypto patterns are used in security-sensitive contexts. + +RECON_CONTEXT: +Cryptography-focused recon summary. + +Crypto usage entries: 2 total. + +Algorithms and key handling (weak entries first): 2 total, showing top 2: +- algorithm=MD5, key_size=None, mode=None, context=password hashing, is_weak=True +- algorithm=SHA256, key_size=None, mode=None, context=etag cache key, is_weak=False + +Potential secret/key findings from config scan: 1 total, showing top 1: +- api_key at .env:2 (confidence=high) + +Deployment/TLS/security header signals: 2 total, showing top 2: +- docker +- Content-Security-Policy + +TASK: +Find potential vulnerabilities related to weak cryptography. + +CWE COVERAGE (REQUIRED): +- CWE-326 Inadequate Encryption Strength +- CWE-327 Broken or Risky Cryptographic Algorithm +- CWE-328 Reversible One-Way Hash +- CWE-330 Insufficiently Random Values +- CWE-916 Weak Password Hashing +- CWE-259 Hard-Coded Password +- CWE-321 Hard-Coded Cryptographic Key +- CWE-798 Hardcoded Credentials + +WHAT TO HUNT: +- Weak algorithms: MD5, SHA1 for security decisions, DES, 3DES, RC4, ECB mode. +- Inadequate key lengths: RSA < 2048, AES < 128, weak DH parameters. +- Reversible password handling: base64/encoding used as if it were hashing. +- Predictable randomness: Math.random(), non-crypto random module usage for tokens/secrets, predictable seeds. +- Weak password storage: unsalted/fast hashes, low-iteration PBKDF2, custom insecure hashing. +- Hardcoded cryptographic keys, secrets, or static IV/nonce values. +- Security-critical usage contexts: password hashing, encryption, token/session generation, signing and verification, auth flows. +- Non-critical usage contexts: checksums, ETags, cache keys, dedup/fingerprinting. + +DO NOT FLAG: +- Crypto operations in tests or fixtures. +- MD5/SHA1 used only for non-security checksums/caching/ETags/cache keys. +- bcrypt/argon2/scrypt for password hashing (these are GOOD). +- Crypto libraries used correctly with sufficient key lengths and secure modes. +- Framework secure defaults (e.g., Django PBKDF2, bcrypt, argon2). + +OUTPUT: +- Return JSON matching HuntResult. +- findings must contain RawFinding entries with evidence-backed descriptions and correct CWE mapping. +- hunter_strategy should be "crypto". + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +CONSTRAINTS: +- Take multiple turns: first discover crypto-relevant files, then evaluate exploitability context. +- Prefer concrete evidence (file path, line range, code snippet, why risky in context). +- No markdown/code fences; JSON output only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Hunt strategy: crypto +- Early stop rule: if you inspect thorough files without credible crypto misuse, stop and return empty findings. +- Focus CWEs: CWE-326, CWE-327, CWE-328, CWE-330, CWE-916, CWE-259, CWE-321, CWE-798 +- Security-critical usage candidates: password hashing, etag cache key +- Non-security usage candidates: etag cache key +- Prioritize weak crypto findings only when used in security-sensitive contexts; avoid checksum/cache-only noise. +- Take multiple turns to explore relevant files before finalizing findings. +- Write final JSON only when analysis is complete. diff --git a/go/internal/agents/hunt/testdata/golden/prompt_data_exposure_quick.txt b/go/internal/agents/hunt/testdata/golden/prompt_data_exposure_quick.txt new file mode 100644 index 0000000..316364e --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_data_exposure_quick.txt @@ -0,0 +1,105 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are a data exposure security analyst for SEC-AF HUNT Phase. + +CONTEXT: +You are analyzing a real repository for Data Exposure Hunter. +Use the RECON context summary below to focus your investigation. + +RECON_CONTEXT: +Data exposure-focused recon summary. + +Data flows touching likely sensitive domains: 1 total, showing top 1: +- request.json -> cursor.execute; sanitized=False; files=app/api/users.py + +Logging/exposure-related misconfig signals: 1 total, showing top 1: +- debug at settings.py:9; risk=high; key=DEBUG + +Entry points and API surface with exposure risk: 2 total, showing top 2: +- POST /login (app/api/auth.py:12, auth_required=False) +- GET /users/{id} (app/api/users.py:40, auth_required=True) + +TASK: +Find potential data exposure vulnerabilities and return HuntResult JSON. + +WORKFLOW (follow these steps in order): +1. Read the source files referenced in the RECON context above to understand the codebase. +2. Search for logging calls, error handlers, data persistence, HTTP clients, and configuration settings in the source code. +3. Read the identified files to confirm vulnerabilities with actual code evidence. +4. For each confirmed vulnerability, record the file path, line number, and relevant code snippet. +5. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +COVERAGE: +- CWE-200: information exposure (debug endpoints, verbose responses, server/internal metadata leakage) +- CWE-209: detailed error message leakage (stack traces, SQL errors, file paths, internal host/IP details) +- CWE-532: sensitive data in logs (PII, credentials, tokens, secrets, auth headers, session IDs) +- CWE-312: cleartext storage of sensitive data (passwords, tokens, PII persisted without encryption) +- CWE-319: cleartext transmission of sensitive data (HTTP transport for auth/session/PII or disabled TLS verification) + +FOCUS AREAS: +- Logging calls, logger middleware, and request/response logging filters +- Error handlers, exception formatters, debug/traceback output, API error payloads +- Persistence layer, model fields, data-at-rest handling, local file writes/caches/backups +- HTTP clients, webhook integrations, transport settings, TLS/SSL flags +- Configuration and deployment toggles that affect production exposure + +REQUIRED OUTPUT: +- Return JSON that strictly matches HuntResult. +- Populate findings with concrete evidence; chains may be empty. +- For each finding, fill all required RawFinding fields including: + - hunter_strategy = "data_exposure" + - cwe_id and cwe_name aligned to this strategy + - file_path, start_line, end_line, code_snippet + - estimated_severity and confidence + - fingerprint stable for file_path + start_line + cwe_id +- Distinguish production impact vs development-only behavior in description and confidence. +- Empty arrays are acceptable if no vulnerabilities are confirmed. + +DO NOT FLAG: +- Logging of non-sensitive operational data (request IDs, timestamps, non-sensitive metrics) +- Error verbosity that is clearly limited to development/local debug mode +- HTTPS URLs or TLS-enabled transmissions + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +CONSTRAINTS: +- Prefer evidence over assumptions; if uncertain, lower confidence. +- No markdown or code fences; JSON output only. +- Base all findings on actual file content you read. Do not speculate. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Strategy: data_exposure +- Early stop rule: if you inspect quick files without credible exposure risk, stop and return empty findings. +- Use multiple turns: inspect files first, then produce findings. +- Return final JSON only when analysis is complete. diff --git a/go/internal/agents/hunt/testdata/golden/prompt_data_exposure_standard.txt b/go/internal/agents/hunt/testdata/golden/prompt_data_exposure_standard.txt new file mode 100644 index 0000000..8bb45aa --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_data_exposure_standard.txt @@ -0,0 +1,174 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are a data exposure security analyst for SEC-AF HUNT Phase. + +CONTEXT: +You are analyzing a real repository for Data Exposure Hunter. +Use the RECON context summary below to focus your investigation. + +RECON_CONTEXT: +Data exposure-focused recon summary. + +Data flows touching likely sensitive domains: 8 total, showing top 8: +- request.cookies['session'] -> redis.set; sanitized=True; files=app/auth/session_store.py +- form['password'] -> logger.info; sanitized=False; files=app/auth/service.py, app/obs/telemetry.go +- header['Authorization'] -> jwt.decode; sanitized=True; files=app/common/jwt_tools.py +- query['email'] -> smtp.send; sanitized=False; files=app/notify/mailer.rb +- body['card_number'] -> stripe.Charge.create; sanitized=True; files=app/billing/payments.go, app/billing/core.py +- session['role'] -> permission_check; sanitized=True; files=app/auth/permissions.py, app/auth/rbac.py +- token -> cache.set; sanitized=True; files=app/common/cache.py +- request.headers['X-User-Phone'] -> audit_log; sanitized=False; files=app/obs/audit.py + +Logging/exposure-related misconfig signals: 7 total, showing top 7: +- logging at config/app_0.ini:0; risk=high; key=LOG_LEVEL +- tls at config/app_1.ini:13; risk=critical; key=SSL_VERIFY +- debug at config/app_4.ini:16; risk=critical; key=DJANGO_DEBUG +- http at config/app_7.ini:19; risk=high; key=REDIRECT_HTTPS +- trace at config/app_9.ini:21; risk=low; key=OTEL_TRACE_ALL +- errors at config/app_11.ini:23; risk=high; key=SHOW_STACKTRACE +- exposure at config/app_15.ini:0; risk=medium; key=ADMIN_PATH + +Entry points and API surface with exposure risk: 20 total, showing top 10: +- GET /api/v1/thing/0 (app/api/thing_0.py:100, auth_required=True) +- POST /api/v1/thing/1 (app/api/thing_1.py:103, auth_required=False) +- PUT /api/v1/thing/2 (app/api/thing_2.py:106, auth_required=None) +- DELETE /api/v1/thing/3 (app/api/thing_3.py:109, auth_required=False) +- PATCH /api/v1/thing/4 (app/api/thing_4.py:112, auth_required=True) +- GET /api/v1/thing/5 (app/api/thing_5.py:115, auth_required=None) +- POST /api/v1/thing/6 (app/api/thing_6.py:118, auth_required=True) +- PUT /api/v1/thing/7 (app/api/thing_7.py:121, auth_required=False) +- DELETE /api/v1/thing/8 (app/api/thing_8.py:124, auth_required=None) +- PATCH /api/v1/thing/9 (app/api/thing_9.py:127, auth_required=False) + +TASK: +Find potential data exposure vulnerabilities and return HuntResult JSON. + +WORKFLOW (follow these steps in order): +1. Read the source files referenced in the RECON context above to understand the codebase. +2. Search for logging calls, error handlers, data persistence, HTTP clients, and configuration settings in the source code. +3. Read the identified files to confirm vulnerabilities with actual code evidence. +4. For each confirmed vulnerability, record the file path, line number, and relevant code snippet. +5. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +COVERAGE: +- CWE-200: information exposure (debug endpoints, verbose responses, server/internal metadata leakage) +- CWE-209: detailed error message leakage (stack traces, SQL errors, file paths, internal host/IP details) +- CWE-532: sensitive data in logs (PII, credentials, tokens, secrets, auth headers, session IDs) +- CWE-312: cleartext storage of sensitive data (passwords, tokens, PII persisted without encryption) +- CWE-319: cleartext transmission of sensitive data (HTTP transport for auth/session/PII or disabled TLS verification) + +FOCUS AREAS: +- Logging calls, logger middleware, and request/response logging filters +- Error handlers, exception formatters, debug/traceback output, API error payloads +- Persistence layer, model fields, data-at-rest handling, local file writes/caches/backups +- HTTP clients, webhook integrations, transport settings, TLS/SSL flags +- Configuration and deployment toggles that affect production exposure + +REQUIRED OUTPUT: +- Return JSON that strictly matches HuntResult. +- Populate findings with concrete evidence; chains may be empty. +- For each finding, fill all required RawFinding fields including: + - hunter_strategy = "data_exposure" + - cwe_id and cwe_name aligned to this strategy + - file_path, start_line, end_line, code_snippet + - estimated_severity and confidence + - fingerprint stable for file_path + start_line + cwe_id +- Distinguish production impact vs development-only behavior in description and confidence. +- Empty arrays are acceptable if no vulnerabilities are confirmed. + +DO NOT FLAG: +- Logging of non-sensitive operational data (request IDs, timestamps, non-sensitive metrics) +- Error verbosity that is clearly limited to development/local debug mode +- HTTPS URLs or TLS-enabled transmissions + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. + +Language: JAVASCRIPT + Key sinks: eval, Function(), innerHTML, document.write, child_process.exec, new Function + Safe patterns (skip these): DOMPurify.sanitize(), textContent assignment, parameterized pg queries + DO NOT FLAG: React JSX expressions (auto-escaped), Angular template bindings (sanitized by default) + Framework notes: Check for React/Vue/Angular-specific XSS patterns. React auto-escapes JSX. Vue v-html is dangerous. + +Language: GO + Key sinks: fmt.Sprintf into SQL, exec.Command with user input, template.HTML(), os.Exec + Safe patterns (skip these): database/sql with ? placeholders, html/template (auto-escapes), exec.Command with separate args + DO NOT FLAG: GORM parameterized queries, html/template default escaping, database/sql prepared statements + Framework notes: Go's html/template auto-escapes. text/template does NOT. Check for text/template serving HTML. + +Language: RUBY + Key sinks: eval, system, exec, send, public_send, ERB.new with user input + Safe patterns (skip these): ActiveRecord parameterized queries, Rack::Utils.escape_html, sanitize helper in Rails + DO NOT FLAG: ActiveRecord where with hash conditions, Rails CSRF protection, Rails html_safe on constants + Framework notes: Rails auto-escapes ERB templates. raw/html_safe bypasses escaping. Check for mass assignment. + +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +Framework: NEXTJS + Security features: React JSX auto-escapes output by default, API routes can share centralized auth middleware, Server/client boundaries reduce accidental secret exposure + DO NOT FLAG: React JSX escaped rendering, getServerSideProps/getServerSession with proper auth checks, Typed route handlers with validated schema guards + Watch for: dangerouslySetInnerHTML, unprotected API routes under /api, secret leakage to client bundles, rewrites/redirects from untrusted user input + Common vulns: XSS through dangerous HTML rendering, IDOR/auth bypass in API handlers, Sensitive env/config exposure in client-side code + +Framework: SPRING + Security features: Spring Security provides auth, CSRF, and filter chain defaults, JPA/Hibernate prepared parameter binding by default, Bean validation can enforce request constraints + DO NOT FLAG: PreparedStatement/JPA named parameter usage, Spring Security CSRF/auth filters clearly active, Thymeleaf auto-escaped output + Watch for: @PreAuthorize missing on sensitive methods, JdbcTemplate/raw Statement with concatenation, csrf().disable() on browser session flows, Actuator endpoints exposed without auth + Common vulns: Authz bypass from weak method-level security, SQL injection in raw JDBC queries, Sensitive management endpoint exposure + +Framework: REACT + Security features: JSX escapes strings before DOM rendering, Component model encourages explicit data flow, Framework discourages direct DOM mutation + DO NOT FLAG: Standard JSX expression rendering, textContent assignment for untrusted text, sanitized HTML via trusted DOMPurify policy + Watch for: dangerouslySetInnerHTML, untrusted URL assignment to href/src, eval/new Function in client code, token/secret exposure in bundles + Common vulns: DOM XSS through unsafe HTML injection, Open redirect via unvalidated navigation targets, Sensitive data exposure in frontend artifacts + +Framework: VUE + Security features: Mustache template interpolation escapes HTML, Component props/events provide explicit boundaries, Router guards can enforce auth flows + DO NOT FLAG: escaped template interpolation {{ value }}, validated route guards protecting private routes, sanitized content rendered through safe components + Watch for: v-html with untrusted data, dynamic component/template compilation, unsafe URL bindings in href/src, client-side auth checks without server enforcement + Common vulns: XSS through v-html and unsafe render paths, Auth bypass from client-only route protection, Open redirect patterns in router navigation + +CONSTRAINTS: +- Prefer evidence over assumptions; if uncertain, lower confidence. +- No markdown or code fences; JSON output only. +- Base all findings on actual file content you read. Do not speculate. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Strategy: data_exposure +- Early stop rule: if you inspect standard files without credible exposure risk, stop and return empty findings. +- Use multiple turns: inspect files first, then produce findings. +- Return final JSON only when analysis is complete. diff --git a/go/internal/agents/hunt/testdata/golden/prompt_data_exposure_thorough.txt b/go/internal/agents/hunt/testdata/golden/prompt_data_exposure_thorough.txt new file mode 100644 index 0000000..4762c10 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_data_exposure_thorough.txt @@ -0,0 +1,105 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are a data exposure security analyst for SEC-AF HUNT Phase. + +CONTEXT: +You are analyzing a real repository for Data Exposure Hunter. +Use the RECON context summary below to focus your investigation. + +RECON_CONTEXT: +Data exposure-focused recon summary. + +Data flows touching likely sensitive domains: 1 total, showing top 1: +- request.json -> cursor.execute; sanitized=False; files=app/api/users.py + +Logging/exposure-related misconfig signals: 1 total, showing top 1: +- debug at settings.py:9; risk=high; key=DEBUG + +Entry points and API surface with exposure risk: 2 total, showing top 2: +- POST /login (app/api/auth.py:12, auth_required=False) +- GET /users/{id} (app/api/users.py:40, auth_required=True) + +TASK: +Find potential data exposure vulnerabilities and return HuntResult JSON. + +WORKFLOW (follow these steps in order): +1. Read the source files referenced in the RECON context above to understand the codebase. +2. Search for logging calls, error handlers, data persistence, HTTP clients, and configuration settings in the source code. +3. Read the identified files to confirm vulnerabilities with actual code evidence. +4. For each confirmed vulnerability, record the file path, line number, and relevant code snippet. +5. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +COVERAGE: +- CWE-200: information exposure (debug endpoints, verbose responses, server/internal metadata leakage) +- CWE-209: detailed error message leakage (stack traces, SQL errors, file paths, internal host/IP details) +- CWE-532: sensitive data in logs (PII, credentials, tokens, secrets, auth headers, session IDs) +- CWE-312: cleartext storage of sensitive data (passwords, tokens, PII persisted without encryption) +- CWE-319: cleartext transmission of sensitive data (HTTP transport for auth/session/PII or disabled TLS verification) + +FOCUS AREAS: +- Logging calls, logger middleware, and request/response logging filters +- Error handlers, exception formatters, debug/traceback output, API error payloads +- Persistence layer, model fields, data-at-rest handling, local file writes/caches/backups +- HTTP clients, webhook integrations, transport settings, TLS/SSL flags +- Configuration and deployment toggles that affect production exposure + +REQUIRED OUTPUT: +- Return JSON that strictly matches HuntResult. +- Populate findings with concrete evidence; chains may be empty. +- For each finding, fill all required RawFinding fields including: + - hunter_strategy = "data_exposure" + - cwe_id and cwe_name aligned to this strategy + - file_path, start_line, end_line, code_snippet + - estimated_severity and confidence + - fingerprint stable for file_path + start_line + cwe_id +- Distinguish production impact vs development-only behavior in description and confidence. +- Empty arrays are acceptable if no vulnerabilities are confirmed. + +DO NOT FLAG: +- Logging of non-sensitive operational data (request IDs, timestamps, non-sensitive metrics) +- Error verbosity that is clearly limited to development/local debug mode +- HTTPS URLs or TLS-enabled transmissions + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +CONSTRAINTS: +- Prefer evidence over assumptions; if uncertain, lower confidence. +- No markdown or code fences; JSON output only. +- Base all findings on actual file content you read. Do not speculate. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Strategy: data_exposure +- Early stop rule: if you inspect thorough files without credible exposure risk, stop and return empty findings. +- Use multiple turns: inspect files first, then produce findings. +- Return final JSON only when analysis is complete. diff --git a/go/internal/agents/hunt/testdata/golden/prompt_dos_quick.txt b/go/internal/agents/hunt/testdata/golden/prompt_dos_quick.txt new file mode 100644 index 0000000..88ff9e2 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_dos_quick.txt @@ -0,0 +1,143 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are an expert denial-of-service and resource exhaustion analyst for SEC-AF HUNT phase. + +CONTEXT: +Use the RECON context below to prioritize analysis by real entry points and known data flows. + +{ + "app_type": "web_api", + "auth_model": "jwt", + "frameworks": [ + "django" + ], + "languages": [ + "python" + ], + "entry_points": [ + { + "kind": "http", + "identifier": "POST /login", + "file_path": "app/api/auth.py", + "line": 12, + "method": "POST", + "route": "/login", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "seed", + "file_path": "app/cli.py", + "line": 3, + "method": null, + "route": null, + "auth_required": null + } + ], + "data_flows": [ + { + "source": "request.json", + "path": [ + { + "file_path": "app/api/users.py", + "line": 41, + "component": "handler", + "operation": "read id" + } + ], + "sink": "cursor.execute", + "sanitized": false, + "files": [ + "app/api/users.py" + ] + } + ] +} + +TASK: +Systematically hunt for DoS and resource exhaustion vulnerabilities in this codebase. + +WORKFLOW (follow these steps in order): +1. Read the source files referenced in the RECON context above to understand the codebase. +2. Search for unbounded loops, regex patterns, resource allocation, pagination gaps, and expensive operations in the source code. +3. Read the identified files to confirm vulnerabilities with actual code evidence. +4. For each confirmed vulnerability, record the file path, line number, and relevant code snippet. +5. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +SCOPE: +- Prioritize attacker-reachable code paths from documented entry points and API surfaces. +- Trace request handling, query building, parsing, recursion, and expensive operations for unbounded work. +- Cover these classes: + - CWE-400 Uncontrolled Resource Consumption + - CWE-770 Allocation of Resources Without Limits or Throttling + - CWE-1333 ReDoS (Inefficient Regular Expression Complexity) + - CWE-835 Loop with Unreachable Exit Condition (Infinite Loop) +- Hunt for: + - Missing pagination or hard limits on list/index endpoints + - Unbounded DB queries/scans and expensive operations without request-level limits + - Regex patterns vulnerable to catastrophic backtracking on user-controlled input + - Missing rate limiting on expensive operations + - Recursive structures without depth limits + - Unbounded file upload or payload size handling + +OUTPUT: +- Return JSON that strictly matches HuntResult. +- Populate HuntResult.findings with RawFinding entries focused on DoS/resource exhaustion issues. +- For each finding, include title, description, cwe_id, cwe_name, file_path, start_line, end_line, + code_snippet, estimated_severity, confidence, fingerprint, and related_files. +- Include data_flow when you can trace attacker control to expensive resource consumption. +- Set hunter_strategy to "dos" for each finding. +- Keep HuntResult metadata coherent (total_raw, deduplicated_count, chain_count, strategies_run, + hunt_duration_seconds). +- Empty arrays are acceptable if no vulnerabilities are confirmed. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +CONSTRAINTS: +- Prefer high confidence only when unbounded or weakly bounded behavior is proven in code. +- Do not speculate about operational outages without code evidence. +- Do not include markdown, prose outside JSON, or code fences in final output. +- Base all findings on actual file content you read. Do not speculate. +- DO NOT FLAG: + - Rate limiting enforced at gateway or middleware + - Endpoints that already enforce pagination or explicit query limits + - Recursive code with validated depth bounds + - Resource limits clearly enforced by container or infrastructure controls + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Depth profile: quick +- Early stop rule: if you inspect 30 files without credible signal, stop and return empty findings. +- Focus on RECON entry points and data flows where unbounded work can be attacker-controlled. +- Explore code paths that can trigger excessive CPU, memory, I/O, or external-service consumption. +- Take multiple turns to build findings incrementally and write final JSON only when complete. diff --git a/go/internal/agents/hunt/testdata/golden/prompt_dos_standard.txt b/go/internal/agents/hunt/testdata/golden/prompt_dos_standard.txt new file mode 100644 index 0000000..803524c --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_dos_standard.txt @@ -0,0 +1,489 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are an expert denial-of-service and resource exhaustion analyst for SEC-AF HUNT phase. + +CONTEXT: +Use the RECON context below to prioritize analysis by real entry points and known data flows. + +{ + "app_type": "web_api", + "auth_model": "jwt", + "frameworks": [ + "Django", + "next.js", + "Spring Boot", + " React ", + "unknown-fw", + "NEXT", + "Vue" + ], + "languages": [ + "Python", + "JavaScript", + "Go", + "Ruby", + "Rust", + "python" + ], + "entry_points": [ + { + "kind": "http", + "identifier": "handler_0", + "file_path": "app/entry/e0.py", + "line": 10, + "method": "GET", + "route": null, + "auth_required": true + }, + { + "kind": "HTTP", + "identifier": "handler_1", + "file_path": "app/entry/e1.py", + "line": 17, + "method": "POST", + "route": "/v1/resource/1", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "handler_2", + "file_path": "app/entry/e2.py", + "line": 24, + "method": null, + "route": "/v1/resource/2", + "auth_required": null + }, + { + "kind": "api", + "identifier": "handler_3", + "file_path": "app/entry/e3.py", + "line": 31, + "method": "PUT", + "route": null, + "auth_required": true + }, + { + "kind": "graphql", + "identifier": "handler_4", + "file_path": "app/entry/e4.py", + "line": 38, + "method": "GET", + "route": "/v1/resource/4", + "auth_required": false + }, + { + "kind": "rpc", + "identifier": "handler_5", + "file_path": "app/entry/e5.py", + "line": 45, + "method": "POST", + "route": "/v1/resource/5", + "auth_required": null + }, + { + "kind": "route", + "identifier": "handler_6", + "file_path": "app/entry/e6.py", + "line": 52, + "method": null, + "route": null, + "auth_required": true + }, + { + "kind": "cron", + "identifier": "handler_7", + "file_path": "app/entry/e7.py", + "line": 59, + "method": "PUT", + "route": "/v1/resource/7", + "auth_required": false + }, + { + "kind": "queue", + "identifier": "handler_8", + "file_path": "app/entry/e8.py", + "line": 66, + "method": "GET", + "route": "/v1/resource/8", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_9", + "file_path": "app/entry/e9.py", + "line": 73, + "method": "POST", + "route": null, + "auth_required": true + } + ], + "data_flows": [ + { + "source": "request.args['q']", + "path": [ + { + "file_path": "app/search/index.ts", + "line": 20, + "component": "step_0_a", + "operation": "read" + }, + { + "file_path": "app/util/b.py", + "line": 40, + "component": "step_0_b", + "operation": "write" + } + ], + "sink": "cursor.execute", + "sanitized": false, + "files": [ + "app/search/index.ts", + "app/db/raw.py", + "app/util/a.py", + "app/util/b.py" + ] + }, + { + "source": "request.cookies['session']", + "path": [ + { + "file_path": "app/auth/session_store.py", + "line": 21, + "component": "step_1_a", + "operation": "read" + }, + { + "file_path": "app/auth/session_store.py", + "line": 41, + "component": "step_1_b", + "operation": "write" + } + ], + "sink": "redis.set", + "sanitized": true, + "files": [ + "app/auth/session_store.py" + ] + }, + { + "source": "request.json['url']", + "path": [ + { + "file_path": "app/integrations/webhooks.go", + "line": 22, + "component": "step_2_a", + "operation": "read" + }, + { + "file_path": "app/net/client.go", + "line": 42, + "component": "step_2_b", + "operation": "write" + } + ], + "sink": "requests.get", + "sanitized": false, + "files": [ + "app/integrations/webhooks.go", + "app/net/client.go" + ] + }, + { + "source": "form['password']", + "path": [ + { + "file_path": "app/auth/service.py", + "line": 23, + "component": "step_3_a", + "operation": "read" + }, + { + "file_path": "app/obs/telemetry.go", + "line": 43, + "component": "step_3_b", + "operation": "write" + } + ], + "sink": "logger.info", + "sanitized": false, + "files": [ + "app/auth/service.py", + "app/obs/telemetry.go" + ] + }, + { + "source": "header['Authorization']", + "path": [ + { + "file_path": "app/common/jwt_tools.py", + "line": 24, + "component": "step_4_a", + "operation": "read" + }, + { + "file_path": "app/common/jwt_tools.py", + "line": 44, + "component": "step_4_b", + "operation": "write" + } + ], + "sink": "jwt.decode", + "sanitized": true, + "files": [ + "app/common/jwt_tools.py" + ] + }, + { + "source": "query['email']", + "path": [ + { + "file_path": "app/notify/mailer.rb", + "line": 25, + "component": "step_5_a", + "operation": "read" + }, + { + "file_path": "app/notify/mailer.rb", + "line": 45, + "component": "step_5_b", + "operation": "write" + } + ], + "sink": "smtp.send", + "sanitized": false, + "files": [ + "app/notify/mailer.rb" + ] + }, + { + "source": "body['card_number']", + "path": [ + { + "file_path": "app/billing/payments.go", + "line": 26, + "component": "step_6_a", + "operation": "read" + }, + { + "file_path": "app/billing/core.py", + "line": 46, + "component": "step_6_b", + "operation": "write" + } + ], + "sink": "stripe.Charge.create", + "sanitized": true, + "files": [ + "app/billing/payments.go", + "app/billing/core.py" + ] + }, + { + "source": "path_param['id']", + "path": [ + { + "file_path": "app/api/thing_1.py", + "line": 27, + "component": "step_7_a", + "operation": "read" + }, + { + "file_path": "app/api/thing_1.py", + "line": 47, + "component": "step_7_b", + "operation": "write" + } + ], + "sink": "orm.filter", + "sanitized": true, + "files": [ + "app/api/thing_1.py" + ] + }, + { + "source": "upload.filename", + "path": [ + { + "file_path": "app/media/uploads.py", + "line": 28, + "component": "step_8_a", + "operation": "read" + }, + { + "file_path": "app/x/w.py", + "line": 48, + "component": "step_8_b", + "operation": "write" + } + ], + "sink": "open", + "sanitized": false, + "files": [ + "app/media/uploads.py", + "app/common/cache.py", + "app/common/guard.py", + "app/x/y.py", + "app/x/z.py", + "app/x/w.py" + ] + }, + { + "source": "env['DEBUG']", + "path": [ + { + "file_path": "app/web/render.py", + "line": 29, + "component": "step_9_a", + "operation": "read" + }, + { + "file_path": "app/web/render.py", + "line": 49, + "component": "step_9_b", + "operation": "write" + } + ], + "sink": "template.render", + "sanitized": false, + "files": [ + "app/web/render.py" + ] + } + ] +} + +TASK: +Systematically hunt for DoS and resource exhaustion vulnerabilities in this codebase. + +WORKFLOW (follow these steps in order): +1. Read the source files referenced in the RECON context above to understand the codebase. +2. Search for unbounded loops, regex patterns, resource allocation, pagination gaps, and expensive operations in the source code. +3. Read the identified files to confirm vulnerabilities with actual code evidence. +4. For each confirmed vulnerability, record the file path, line number, and relevant code snippet. +5. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +SCOPE: +- Prioritize attacker-reachable code paths from documented entry points and API surfaces. +- Trace request handling, query building, parsing, recursion, and expensive operations for unbounded work. +- Cover these classes: + - CWE-400 Uncontrolled Resource Consumption + - CWE-770 Allocation of Resources Without Limits or Throttling + - CWE-1333 ReDoS (Inefficient Regular Expression Complexity) + - CWE-835 Loop with Unreachable Exit Condition (Infinite Loop) +- Hunt for: + - Missing pagination or hard limits on list/index endpoints + - Unbounded DB queries/scans and expensive operations without request-level limits + - Regex patterns vulnerable to catastrophic backtracking on user-controlled input + - Missing rate limiting on expensive operations + - Recursive structures without depth limits + - Unbounded file upload or payload size handling + +OUTPUT: +- Return JSON that strictly matches HuntResult. +- Populate HuntResult.findings with RawFinding entries focused on DoS/resource exhaustion issues. +- For each finding, include title, description, cwe_id, cwe_name, file_path, start_line, end_line, + code_snippet, estimated_severity, confidence, fingerprint, and related_files. +- Include data_flow when you can trace attacker control to expensive resource consumption. +- Set hunter_strategy to "dos" for each finding. +- Keep HuntResult metadata coherent (total_raw, deduplicated_count, chain_count, strategies_run, + hunt_duration_seconds). +- Empty arrays are acceptable if no vulnerabilities are confirmed. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. + +Language: JAVASCRIPT + Key sinks: eval, Function(), innerHTML, document.write, child_process.exec, new Function + Safe patterns (skip these): DOMPurify.sanitize(), textContent assignment, parameterized pg queries + DO NOT FLAG: React JSX expressions (auto-escaped), Angular template bindings (sanitized by default) + Framework notes: Check for React/Vue/Angular-specific XSS patterns. React auto-escapes JSX. Vue v-html is dangerous. + +Language: GO + Key sinks: fmt.Sprintf into SQL, exec.Command with user input, template.HTML(), os.Exec + Safe patterns (skip these): database/sql with ? placeholders, html/template (auto-escapes), exec.Command with separate args + DO NOT FLAG: GORM parameterized queries, html/template default escaping, database/sql prepared statements + Framework notes: Go's html/template auto-escapes. text/template does NOT. Check for text/template serving HTML. + +Language: RUBY + Key sinks: eval, system, exec, send, public_send, ERB.new with user input + Safe patterns (skip these): ActiveRecord parameterized queries, Rack::Utils.escape_html, sanitize helper in Rails + DO NOT FLAG: ActiveRecord where with hash conditions, Rails CSRF protection, Rails html_safe on constants + Framework notes: Rails auto-escapes ERB templates. raw/html_safe bypasses escaping. Check for mass assignment. + +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +Framework: NEXTJS + Security features: React JSX auto-escapes output by default, API routes can share centralized auth middleware, Server/client boundaries reduce accidental secret exposure + DO NOT FLAG: React JSX escaped rendering, getServerSideProps/getServerSession with proper auth checks, Typed route handlers with validated schema guards + Watch for: dangerouslySetInnerHTML, unprotected API routes under /api, secret leakage to client bundles, rewrites/redirects from untrusted user input + Common vulns: XSS through dangerous HTML rendering, IDOR/auth bypass in API handlers, Sensitive env/config exposure in client-side code + +Framework: SPRING + Security features: Spring Security provides auth, CSRF, and filter chain defaults, JPA/Hibernate prepared parameter binding by default, Bean validation can enforce request constraints + DO NOT FLAG: PreparedStatement/JPA named parameter usage, Spring Security CSRF/auth filters clearly active, Thymeleaf auto-escaped output + Watch for: @PreAuthorize missing on sensitive methods, JdbcTemplate/raw Statement with concatenation, csrf().disable() on browser session flows, Actuator endpoints exposed without auth + Common vulns: Authz bypass from weak method-level security, SQL injection in raw JDBC queries, Sensitive management endpoint exposure + +Framework: REACT + Security features: JSX escapes strings before DOM rendering, Component model encourages explicit data flow, Framework discourages direct DOM mutation + DO NOT FLAG: Standard JSX expression rendering, textContent assignment for untrusted text, sanitized HTML via trusted DOMPurify policy + Watch for: dangerouslySetInnerHTML, untrusted URL assignment to href/src, eval/new Function in client code, token/secret exposure in bundles + Common vulns: DOM XSS through unsafe HTML injection, Open redirect via unvalidated navigation targets, Sensitive data exposure in frontend artifacts + +Framework: VUE + Security features: Mustache template interpolation escapes HTML, Component props/events provide explicit boundaries, Router guards can enforce auth flows + DO NOT FLAG: escaped template interpolation {{ value }}, validated route guards protecting private routes, sanitized content rendered through safe components + Watch for: v-html with untrusted data, dynamic component/template compilation, unsafe URL bindings in href/src, client-side auth checks without server enforcement + Common vulns: XSS through v-html and unsafe render paths, Auth bypass from client-only route protection, Open redirect patterns in router navigation + +CONSTRAINTS: +- Prefer high confidence only when unbounded or weakly bounded behavior is proven in code. +- Do not speculate about operational outages without code evidence. +- Do not include markdown, prose outside JSON, or code fences in final output. +- Base all findings on actual file content you read. Do not speculate. +- DO NOT FLAG: + - Rate limiting enforced at gateway or middleware + - Endpoints that already enforce pagination or explicit query limits + - Recursive code with validated depth bounds + - Resource limits clearly enforced by container or infrastructure controls + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Depth profile: standard +- Early stop rule: if you inspect 30 files without credible signal, stop and return empty findings. +- Focus on RECON entry points and data flows where unbounded work can be attacker-controlled. +- Explore code paths that can trigger excessive CPU, memory, I/O, or external-service consumption. +- Take multiple turns to build findings incrementally and write final JSON only when complete. diff --git a/go/internal/agents/hunt/testdata/golden/prompt_dos_thorough.txt b/go/internal/agents/hunt/testdata/golden/prompt_dos_thorough.txt new file mode 100644 index 0000000..969f659 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_dos_thorough.txt @@ -0,0 +1,143 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are an expert denial-of-service and resource exhaustion analyst for SEC-AF HUNT phase. + +CONTEXT: +Use the RECON context below to prioritize analysis by real entry points and known data flows. + +{ + "app_type": "web_api", + "auth_model": "jwt", + "frameworks": [ + "django" + ], + "languages": [ + "python" + ], + "entry_points": [ + { + "kind": "http", + "identifier": "POST /login", + "file_path": "app/api/auth.py", + "line": 12, + "method": "POST", + "route": "/login", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "seed", + "file_path": "app/cli.py", + "line": 3, + "method": null, + "route": null, + "auth_required": null + } + ], + "data_flows": [ + { + "source": "request.json", + "path": [ + { + "file_path": "app/api/users.py", + "line": 41, + "component": "handler", + "operation": "read id" + } + ], + "sink": "cursor.execute", + "sanitized": false, + "files": [ + "app/api/users.py" + ] + } + ] +} + +TASK: +Systematically hunt for DoS and resource exhaustion vulnerabilities in this codebase. + +WORKFLOW (follow these steps in order): +1. Read the source files referenced in the RECON context above to understand the codebase. +2. Search for unbounded loops, regex patterns, resource allocation, pagination gaps, and expensive operations in the source code. +3. Read the identified files to confirm vulnerabilities with actual code evidence. +4. For each confirmed vulnerability, record the file path, line number, and relevant code snippet. +5. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +SCOPE: +- Prioritize attacker-reachable code paths from documented entry points and API surfaces. +- Trace request handling, query building, parsing, recursion, and expensive operations for unbounded work. +- Cover these classes: + - CWE-400 Uncontrolled Resource Consumption + - CWE-770 Allocation of Resources Without Limits or Throttling + - CWE-1333 ReDoS (Inefficient Regular Expression Complexity) + - CWE-835 Loop with Unreachable Exit Condition (Infinite Loop) +- Hunt for: + - Missing pagination or hard limits on list/index endpoints + - Unbounded DB queries/scans and expensive operations without request-level limits + - Regex patterns vulnerable to catastrophic backtracking on user-controlled input + - Missing rate limiting on expensive operations + - Recursive structures without depth limits + - Unbounded file upload or payload size handling + +OUTPUT: +- Return JSON that strictly matches HuntResult. +- Populate HuntResult.findings with RawFinding entries focused on DoS/resource exhaustion issues. +- For each finding, include title, description, cwe_id, cwe_name, file_path, start_line, end_line, + code_snippet, estimated_severity, confidence, fingerprint, and related_files. +- Include data_flow when you can trace attacker control to expensive resource consumption. +- Set hunter_strategy to "dos" for each finding. +- Keep HuntResult metadata coherent (total_raw, deduplicated_count, chain_count, strategies_run, + hunt_duration_seconds). +- Empty arrays are acceptable if no vulnerabilities are confirmed. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +CONSTRAINTS: +- Prefer high confidence only when unbounded or weakly bounded behavior is proven in code. +- Do not speculate about operational outages without code evidence. +- Do not include markdown, prose outside JSON, or code fences in final output. +- Base all findings on actual file content you read. Do not speculate. +- DO NOT FLAG: + - Rate limiting enforced at gateway or middleware + - Endpoints that already enforce pagination or explicit query limits + - Recursive code with validated depth bounds + - Resource limits clearly enforced by container or infrastructure controls + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Depth profile: thorough +- Early stop rule: if you inspect 30 files without credible signal, stop and return empty findings. +- Focus on RECON entry points and data flows where unbounded work can be attacker-controlled. +- Explore code paths that can trigger excessive CPU, memory, I/O, or external-service consumption. +- Take multiple turns to build findings incrementally and write final JSON only when complete. diff --git a/go/internal/agents/hunt/testdata/golden/prompt_injection_quick.txt b/go/internal/agents/hunt/testdata/golden/prompt_injection_quick.txt new file mode 100644 index 0000000..b18af63 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_injection_quick.txt @@ -0,0 +1,106 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are an expert injection vulnerability analyst for SEC-AF HUNT phase. + +CONTEXT: +Use the RECON context below to prioritize analysis by real entry points and known data flows. + +Injection-focused recon summary. + +Codebase profile: 56 files, 1234 LOC, languages=python, frameworks=django. + +Entry points likely to receive untrusted input: 2 total, showing top 2: +- http /login (app/api/auth.py:12) +- cli seed (app/cli.py:3) + +High-value sinks: 1 total, showing top 1: +- sql at app/api/users.py:42 (get_user) + +Source-to-sink flow candidates (unsanitized first): 1 total, showing top 1: +- request.json -> cursor.execute; sanitized=False; files=app/api/users.py + +Known sanitization points: none identified in recon. + +TASK: +Systematically hunt for injection vulnerabilities in this codebase. + +WORKFLOW (follow these steps in order): +1. Read the source files referenced in the RECON context above to understand the codebase. +2. Search for SQL queries, command execution, template rendering, and other injection sinks in the source code. +3. Read the identified files to confirm vulnerabilities with actual code evidence. +4. For each confirmed vulnerability, record the file path, line number, and relevant code snippet. +5. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +SCOPE: +- Prioritize source-to-sink paths that originate from documented entry points. +- Trace data flow across files and layers to identify missing validation, sanitization, or safe encoding. +- Cover these classes: + - CWE-78 OS Command Injection + - CWE-79 Cross-Site Scripting (XSS) + - CWE-89 SQL Injection + - CWE-90 LDAP Injection + - CWE-91 XML/XPath Injection + - CWE-94 Code Injection + - CWE-917 Expression Language / Template Injection + +OUTPUT: +- Return JSON that strictly matches HuntResult. +- Populate HuntResult.findings with RawFinding entries focused on injection issues. +- For each finding, include title, description, cwe_id, cwe_name, file_path, start_line, end_line, + code_snippet, estimated_severity, confidence, fingerprint, and related_files. +- Include data_flow when you can trace user-controlled source to vulnerable sink. +- Set hunter_strategy to "injection" for each finding. +- Keep HuntResult metadata coherent (total_raw, deduplicated_count, chain_count, strategies_run, + hunt_duration_seconds). +- Empty arrays are acceptable if no vulnerabilities are confirmed. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +CONSTRAINTS: +- Prefer high confidence only when source-to-sink flow is clearly traced. +- Do not speculate about exploitability without code evidence. +- Do not include markdown, prose outside JSON, or code fences in final output. +- Base all findings on actual file content you read. Do not speculate. +- DO NOT FLAG: + - Parameterized queries (for example cursor.execute("SELECT ... WHERE id = %s", [user_id])) + - ORM operations that safely bind parameters (Django ORM, SQLAlchemy bound params, Prisma) + - Auto-escaped output contexts (Django templates default escaping, React JSX, Vue template escaping) + - Test fixtures, synthetic payload examples, and comments that only demonstrate attack strings + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Depth profile: quick +- Early stop rule: if you inspect 30 files without credible signal, stop and return empty findings. +- Focus on RECON entry points and data flows as primary source-to-sink paths. +- Explore the codebase, trace data flows from sources to sinks, and identify injection points. +- Take multiple turns to build findings incrementally and write final JSON only when complete. diff --git a/go/internal/agents/hunt/testdata/golden/prompt_injection_standard.txt b/go/internal/agents/hunt/testdata/golden/prompt_injection_standard.txt new file mode 100644 index 0000000..9748c4b --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_injection_standard.txt @@ -0,0 +1,203 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are an expert injection vulnerability analyst for SEC-AF HUNT phase. + +CONTEXT: +Use the RECON context below to prioritize analysis by real entry points and known data flows. + +Injection-focused recon summary. + +Codebase profile: 2417 files, 184213 LOC, languages=Python, JavaScript, Go, Ruby, Rust, python, frameworks=Django, next.js, Spring Boot, React , unknown-fw, NEXT, Vue. + +Entry points likely to receive untrusted input: 17 total, showing top 15: +- http handler_0 (app/entry/e0.py:10) +- HTTP /v1/resource/1 (app/entry/e1.py:17) +- cli /v1/resource/2 (app/entry/e2.py:24) +- api handler_3 (app/entry/e3.py:31) +- graphql /v1/resource/4 (app/entry/e4.py:38) +- rpc /v1/resource/5 (app/entry/e5.py:45) +- route handler_6 (app/entry/e6.py:52) +- cron /v1/resource/7 (app/entry/e7.py:59) +- queue /v1/resource/8 (app/entry/e8.py:66) +- http handler_9 (app/entry/e9.py:73) +- api /v1/resource/10 (app/entry/e10.py:80) +- grpc /v1/resource/11 (app/entry/e11.py:87) +- http handler_12 (app/entry/e12.py:94) +- route /v1/resource/13 (app/entry/e13.py:101) +- cli /v1/resource/14 (app/entry/e14.py:108) + +High-value sinks: 18 total, showing top 15: +- sql at app/sink/k0.py:200 +- command at app/sink/k1.py:202 (sink_fn_1) +- template at app/sink/k2.py:204 (sink_fn_2) +- file at app/sink/k3.py:206 (sink_fn_3) +- network at app/sink/k4.py:208 +- deserialization at app/sink/k5.py:210 (sink_fn_5) +- sql at app/sink/k6.py:212 (sink_fn_6) +- command at app/sink/k7.py:214 (sink_fn_7) +- template at app/sink/k8.py:216 +- file at app/sink/k9.py:218 (sink_fn_9) +- network at app/sink/k10.py:220 (sink_fn_10) +- deserialization at app/sink/k11.py:222 (sink_fn_11) +- sql at app/sink/k12.py:224 +- command at app/sink/k13.py:226 (sink_fn_13) +- template at app/sink/k14.py:228 (sink_fn_14) + +Source-to-sink flow candidates (unsanitized first): 13 total, showing top 13: +- request.args['q'] -> cursor.execute; sanitized=False; files=app/search/index.ts, app/db/raw.py, app/util/a.py +- request.json['url'] -> requests.get; sanitized=False; files=app/integrations/webhooks.go, app/net/client.go +- form['password'] -> logger.info; sanitized=False; files=app/auth/service.py, app/obs/telemetry.go +- query['email'] -> smtp.send; sanitized=False; files=app/notify/mailer.rb +- upload.filename -> open; sanitized=False; files=app/media/uploads.py, app/common/cache.py, app/common/guard.py +- env['DEBUG'] -> template.render; sanitized=False; files=app/web/render.py +- request.body -> eval; sanitized=False; files=app/admin/views.py +- websocket.msg -> subprocess.run; sanitized=False; files=app/ws/handler.py, app/ws/exec.py +- cli.argv -> os.system; sanitized=False; files=tools/run.py +- queue.payload -> pickle.loads; sanitized=False; files=app/worker/task.py +- request.args['redirect'] -> HttpResponseRedirect; sanitized=False; files=app/web/redirect.py +- request.headers['X-User-Phone'] -> audit_log; sanitized=False; files=app/obs/audit.py +- graphql.variables -> db.query; sanitized=False; files=app/api/graphql.ts + +Known sanitization points: 12 total, showing top 10: +- app/sanitize/s0.py:5 type=escape protects=unspecified +- app/sanitize/s1.py:9 type=parameterize protects=CWE-80, CWE-90 +- app/sanitize/s2.py:13 type=allowlist protects=CWE-81, CWE-91 +- app/sanitize/s3.py:17 type=encode protects=unspecified +- app/sanitize/s4.py:21 type=escape protects=CWE-83, CWE-93 +- app/sanitize/s5.py:25 type=parameterize protects=CWE-84, CWE-94 +- app/sanitize/s6.py:29 type=allowlist protects=unspecified +- app/sanitize/s7.py:33 type=encode protects=CWE-86, CWE-96 +- app/sanitize/s8.py:37 type=escape protects=CWE-87, CWE-97 +- app/sanitize/s9.py:41 type=parameterize protects=unspecified + +TASK: +Systematically hunt for injection vulnerabilities in this codebase. + +WORKFLOW (follow these steps in order): +1. Read the source files referenced in the RECON context above to understand the codebase. +2. Search for SQL queries, command execution, template rendering, and other injection sinks in the source code. +3. Read the identified files to confirm vulnerabilities with actual code evidence. +4. For each confirmed vulnerability, record the file path, line number, and relevant code snippet. +5. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +SCOPE: +- Prioritize source-to-sink paths that originate from documented entry points. +- Trace data flow across files and layers to identify missing validation, sanitization, or safe encoding. +- Cover these classes: + - CWE-78 OS Command Injection + - CWE-79 Cross-Site Scripting (XSS) + - CWE-89 SQL Injection + - CWE-90 LDAP Injection + - CWE-91 XML/XPath Injection + - CWE-94 Code Injection + - CWE-917 Expression Language / Template Injection + +OUTPUT: +- Return JSON that strictly matches HuntResult. +- Populate HuntResult.findings with RawFinding entries focused on injection issues. +- For each finding, include title, description, cwe_id, cwe_name, file_path, start_line, end_line, + code_snippet, estimated_severity, confidence, fingerprint, and related_files. +- Include data_flow when you can trace user-controlled source to vulnerable sink. +- Set hunter_strategy to "injection" for each finding. +- Keep HuntResult metadata coherent (total_raw, deduplicated_count, chain_count, strategies_run, + hunt_duration_seconds). +- Empty arrays are acceptable if no vulnerabilities are confirmed. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. + +Language: JAVASCRIPT + Key sinks: eval, Function(), innerHTML, document.write, child_process.exec, new Function + Safe patterns (skip these): DOMPurify.sanitize(), textContent assignment, parameterized pg queries + DO NOT FLAG: React JSX expressions (auto-escaped), Angular template bindings (sanitized by default) + Framework notes: Check for React/Vue/Angular-specific XSS patterns. React auto-escapes JSX. Vue v-html is dangerous. + +Language: GO + Key sinks: fmt.Sprintf into SQL, exec.Command with user input, template.HTML(), os.Exec + Safe patterns (skip these): database/sql with ? placeholders, html/template (auto-escapes), exec.Command with separate args + DO NOT FLAG: GORM parameterized queries, html/template default escaping, database/sql prepared statements + Framework notes: Go's html/template auto-escapes. text/template does NOT. Check for text/template serving HTML. + +Language: RUBY + Key sinks: eval, system, exec, send, public_send, ERB.new with user input + Safe patterns (skip these): ActiveRecord parameterized queries, Rack::Utils.escape_html, sanitize helper in Rails + DO NOT FLAG: ActiveRecord where with hash conditions, Rails CSRF protection, Rails html_safe on constants + Framework notes: Rails auto-escapes ERB templates. raw/html_safe bypasses escaping. Check for mass assignment. + +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +Framework: NEXTJS + Security features: React JSX auto-escapes output by default, API routes can share centralized auth middleware, Server/client boundaries reduce accidental secret exposure + DO NOT FLAG: React JSX escaped rendering, getServerSideProps/getServerSession with proper auth checks, Typed route handlers with validated schema guards + Watch for: dangerouslySetInnerHTML, unprotected API routes under /api, secret leakage to client bundles, rewrites/redirects from untrusted user input + Common vulns: XSS through dangerous HTML rendering, IDOR/auth bypass in API handlers, Sensitive env/config exposure in client-side code + +Framework: SPRING + Security features: Spring Security provides auth, CSRF, and filter chain defaults, JPA/Hibernate prepared parameter binding by default, Bean validation can enforce request constraints + DO NOT FLAG: PreparedStatement/JPA named parameter usage, Spring Security CSRF/auth filters clearly active, Thymeleaf auto-escaped output + Watch for: @PreAuthorize missing on sensitive methods, JdbcTemplate/raw Statement with concatenation, csrf().disable() on browser session flows, Actuator endpoints exposed without auth + Common vulns: Authz bypass from weak method-level security, SQL injection in raw JDBC queries, Sensitive management endpoint exposure + +Framework: REACT + Security features: JSX escapes strings before DOM rendering, Component model encourages explicit data flow, Framework discourages direct DOM mutation + DO NOT FLAG: Standard JSX expression rendering, textContent assignment for untrusted text, sanitized HTML via trusted DOMPurify policy + Watch for: dangerouslySetInnerHTML, untrusted URL assignment to href/src, eval/new Function in client code, token/secret exposure in bundles + Common vulns: DOM XSS through unsafe HTML injection, Open redirect via unvalidated navigation targets, Sensitive data exposure in frontend artifacts + +Framework: VUE + Security features: Mustache template interpolation escapes HTML, Component props/events provide explicit boundaries, Router guards can enforce auth flows + DO NOT FLAG: escaped template interpolation {{ value }}, validated route guards protecting private routes, sanitized content rendered through safe components + Watch for: v-html with untrusted data, dynamic component/template compilation, unsafe URL bindings in href/src, client-side auth checks without server enforcement + Common vulns: XSS through v-html and unsafe render paths, Auth bypass from client-only route protection, Open redirect patterns in router navigation + +CONSTRAINTS: +- Prefer high confidence only when source-to-sink flow is clearly traced. +- Do not speculate about exploitability without code evidence. +- Do not include markdown, prose outside JSON, or code fences in final output. +- Base all findings on actual file content you read. Do not speculate. +- DO NOT FLAG: + - Parameterized queries (for example cursor.execute("SELECT ... WHERE id = %s", [user_id])) + - ORM operations that safely bind parameters (Django ORM, SQLAlchemy bound params, Prisma) + - Auto-escaped output contexts (Django templates default escaping, React JSX, Vue template escaping) + - Test fixtures, synthetic payload examples, and comments that only demonstrate attack strings + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Depth profile: standard +- Early stop rule: if you inspect 30 files without credible signal, stop and return empty findings. +- Focus on RECON entry points and data flows as primary source-to-sink paths. +- Explore the codebase, trace data flows from sources to sinks, and identify injection points. +- Take multiple turns to build findings incrementally and write final JSON only when complete. diff --git a/go/internal/agents/hunt/testdata/golden/prompt_injection_thorough.txt b/go/internal/agents/hunt/testdata/golden/prompt_injection_thorough.txt new file mode 100644 index 0000000..752cfcf --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_injection_thorough.txt @@ -0,0 +1,106 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are an expert injection vulnerability analyst for SEC-AF HUNT phase. + +CONTEXT: +Use the RECON context below to prioritize analysis by real entry points and known data flows. + +Injection-focused recon summary. + +Codebase profile: 56 files, 1234 LOC, languages=python, frameworks=django. + +Entry points likely to receive untrusted input: 2 total, showing top 2: +- http /login (app/api/auth.py:12) +- cli seed (app/cli.py:3) + +High-value sinks: 1 total, showing top 1: +- sql at app/api/users.py:42 (get_user) + +Source-to-sink flow candidates (unsanitized first): 1 total, showing top 1: +- request.json -> cursor.execute; sanitized=False; files=app/api/users.py + +Known sanitization points: none identified in recon. + +TASK: +Systematically hunt for injection vulnerabilities in this codebase. + +WORKFLOW (follow these steps in order): +1. Read the source files referenced in the RECON context above to understand the codebase. +2. Search for SQL queries, command execution, template rendering, and other injection sinks in the source code. +3. Read the identified files to confirm vulnerabilities with actual code evidence. +4. For each confirmed vulnerability, record the file path, line number, and relevant code snippet. +5. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +SCOPE: +- Prioritize source-to-sink paths that originate from documented entry points. +- Trace data flow across files and layers to identify missing validation, sanitization, or safe encoding. +- Cover these classes: + - CWE-78 OS Command Injection + - CWE-79 Cross-Site Scripting (XSS) + - CWE-89 SQL Injection + - CWE-90 LDAP Injection + - CWE-91 XML/XPath Injection + - CWE-94 Code Injection + - CWE-917 Expression Language / Template Injection + +OUTPUT: +- Return JSON that strictly matches HuntResult. +- Populate HuntResult.findings with RawFinding entries focused on injection issues. +- For each finding, include title, description, cwe_id, cwe_name, file_path, start_line, end_line, + code_snippet, estimated_severity, confidence, fingerprint, and related_files. +- Include data_flow when you can trace user-controlled source to vulnerable sink. +- Set hunter_strategy to "injection" for each finding. +- Keep HuntResult metadata coherent (total_raw, deduplicated_count, chain_count, strategies_run, + hunt_duration_seconds). +- Empty arrays are acceptable if no vulnerabilities are confirmed. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +CONSTRAINTS: +- Prefer high confidence only when source-to-sink flow is clearly traced. +- Do not speculate about exploitability without code evidence. +- Do not include markdown, prose outside JSON, or code fences in final output. +- Base all findings on actual file content you read. Do not speculate. +- DO NOT FLAG: + - Parameterized queries (for example cursor.execute("SELECT ... WHERE id = %s", [user_id])) + - ORM operations that safely bind parameters (Django ORM, SQLAlchemy bound params, Prisma) + - Auto-escaped output contexts (Django templates default escaping, React JSX, Vue template escaping) + - Test fixtures, synthetic payload examples, and comments that only demonstrate attack strings + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Depth profile: thorough +- Early stop rule: if you inspect 30 files without credible signal, stop and return empty findings. +- Focus on RECON entry points and data flows as primary source-to-sink paths. +- Explore the codebase, trace data flows from sources to sinks, and identify injection points. +- Take multiple turns to build findings incrementally and write final JSON only when complete. diff --git a/go/internal/agents/hunt/testdata/golden/prompt_ssrf_quick.txt b/go/internal/agents/hunt/testdata/golden/prompt_ssrf_quick.txt new file mode 100644 index 0000000..7614e34 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_ssrf_quick.txt @@ -0,0 +1,139 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are an expert SSRF vulnerability analyst for SEC-AF HUNT phase. + +CONTEXT: +Use the RECON context below to prioritize analysis by real entry points and known data flows. + +{ + "app_type": "web_api", + "auth_model": "jwt", + "frameworks": [ + "django" + ], + "languages": [ + "python" + ], + "entry_points": [ + { + "kind": "http", + "identifier": "POST /login", + "file_path": "app/api/auth.py", + "line": 12, + "method": "POST", + "route": "/login", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "seed", + "file_path": "app/cli.py", + "line": 3, + "method": null, + "route": null, + "auth_required": null + } + ], + "data_flows": [ + { + "source": "request.json", + "path": [ + { + "file_path": "app/api/users.py", + "line": 41, + "component": "handler", + "operation": "read id" + } + ], + "sink": "cursor.execute", + "sanitized": false, + "files": [ + "app/api/users.py" + ] + } + ] +} + +TASK: +Systematically hunt for server-side request forgery vulnerabilities in this codebase. + +WORKFLOW (follow these steps in order): +1. Read the source files referenced in the RECON context above to understand the codebase. +2. Search for HTTP clients, URL handling, webhook integrations, and outbound network calls in the source code. +3. Read the identified files to confirm vulnerabilities with actual code evidence. +4. For each confirmed vulnerability, record the file path, line number, and relevant code snippet. +5. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +SCOPE: +- Prioritize source-to-sink paths that originate from documented entry points. +- Trace data flow across files and layers to identify user-controlled URL input reaching outbound network sinks. +- Cover these classes: + - CWE-918 Server-Side Request Forgery (SSRF) + - User-controlled URLs passed to HTTP clients + - URL parsing bypasses (protocol smuggling, IP encoding tricks) + - Cloud metadata endpoint access (169.254.169.254 for AWS, metadata.google.internal for GCP) + - Internal service discovery via SSRF + - Webhook URL validation gaps + - PDF/image renderers fetching external URLs + +OUTPUT: +- Return JSON that strictly matches HuntResult. +- Populate HuntResult.findings with RawFinding entries focused on SSRF issues. +- For each finding, include title, description, cwe_id, cwe_name, file_path, start_line, end_line, + code_snippet, estimated_severity, confidence, fingerprint, and related_files. +- Include data_flow when you can trace user-controlled source to vulnerable sink. +- Set hunter_strategy to "ssrf" for each finding. +- Keep HuntResult metadata coherent (total_raw, deduplicated_count, chain_count, strategies_run, + hunt_duration_seconds). +- Empty arrays are acceptable if no vulnerabilities are confirmed. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +CONSTRAINTS: +- Prefer high confidence only when source-to-sink flow is clearly traced. +- Do not speculate about exploitability without code evidence. +- Do not include markdown, prose outside JSON, or code fences in final output. +- Base all findings on actual file content you read. Do not speculate. +- DO NOT FLAG: + - URLs from trusted config/env variables + - Allowlisted domains with strict validation + - URLs validated against strict regex patterns + - Internal service calls with no user input + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Depth profile: quick +- Early stop rule: if you inspect 30 files without credible signal, stop and return empty findings. +- Focus on RECON entry points and data flows as primary source-to-sink paths. +- Explore the codebase, trace data flows from sources to sinks, and identify SSRF points. +- Take multiple turns to build findings incrementally and write final JSON only when complete. diff --git a/go/internal/agents/hunt/testdata/golden/prompt_ssrf_standard.txt b/go/internal/agents/hunt/testdata/golden/prompt_ssrf_standard.txt new file mode 100644 index 0000000..a49ca0a --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_ssrf_standard.txt @@ -0,0 +1,485 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are an expert SSRF vulnerability analyst for SEC-AF HUNT phase. + +CONTEXT: +Use the RECON context below to prioritize analysis by real entry points and known data flows. + +{ + "app_type": "web_api", + "auth_model": "jwt", + "frameworks": [ + "Django", + "next.js", + "Spring Boot", + " React ", + "unknown-fw", + "NEXT", + "Vue" + ], + "languages": [ + "Python", + "JavaScript", + "Go", + "Ruby", + "Rust", + "python" + ], + "entry_points": [ + { + "kind": "http", + "identifier": "handler_0", + "file_path": "app/entry/e0.py", + "line": 10, + "method": "GET", + "route": null, + "auth_required": true + }, + { + "kind": "HTTP", + "identifier": "handler_1", + "file_path": "app/entry/e1.py", + "line": 17, + "method": "POST", + "route": "/v1/resource/1", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "handler_2", + "file_path": "app/entry/e2.py", + "line": 24, + "method": null, + "route": "/v1/resource/2", + "auth_required": null + }, + { + "kind": "api", + "identifier": "handler_3", + "file_path": "app/entry/e3.py", + "line": 31, + "method": "PUT", + "route": null, + "auth_required": true + }, + { + "kind": "graphql", + "identifier": "handler_4", + "file_path": "app/entry/e4.py", + "line": 38, + "method": "GET", + "route": "/v1/resource/4", + "auth_required": false + }, + { + "kind": "rpc", + "identifier": "handler_5", + "file_path": "app/entry/e5.py", + "line": 45, + "method": "POST", + "route": "/v1/resource/5", + "auth_required": null + }, + { + "kind": "route", + "identifier": "handler_6", + "file_path": "app/entry/e6.py", + "line": 52, + "method": null, + "route": null, + "auth_required": true + }, + { + "kind": "cron", + "identifier": "handler_7", + "file_path": "app/entry/e7.py", + "line": 59, + "method": "PUT", + "route": "/v1/resource/7", + "auth_required": false + }, + { + "kind": "queue", + "identifier": "handler_8", + "file_path": "app/entry/e8.py", + "line": 66, + "method": "GET", + "route": "/v1/resource/8", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_9", + "file_path": "app/entry/e9.py", + "line": 73, + "method": "POST", + "route": null, + "auth_required": true + } + ], + "data_flows": [ + { + "source": "request.args['q']", + "path": [ + { + "file_path": "app/search/index.ts", + "line": 20, + "component": "step_0_a", + "operation": "read" + }, + { + "file_path": "app/util/b.py", + "line": 40, + "component": "step_0_b", + "operation": "write" + } + ], + "sink": "cursor.execute", + "sanitized": false, + "files": [ + "app/search/index.ts", + "app/db/raw.py", + "app/util/a.py", + "app/util/b.py" + ] + }, + { + "source": "request.cookies['session']", + "path": [ + { + "file_path": "app/auth/session_store.py", + "line": 21, + "component": "step_1_a", + "operation": "read" + }, + { + "file_path": "app/auth/session_store.py", + "line": 41, + "component": "step_1_b", + "operation": "write" + } + ], + "sink": "redis.set", + "sanitized": true, + "files": [ + "app/auth/session_store.py" + ] + }, + { + "source": "request.json['url']", + "path": [ + { + "file_path": "app/integrations/webhooks.go", + "line": 22, + "component": "step_2_a", + "operation": "read" + }, + { + "file_path": "app/net/client.go", + "line": 42, + "component": "step_2_b", + "operation": "write" + } + ], + "sink": "requests.get", + "sanitized": false, + "files": [ + "app/integrations/webhooks.go", + "app/net/client.go" + ] + }, + { + "source": "form['password']", + "path": [ + { + "file_path": "app/auth/service.py", + "line": 23, + "component": "step_3_a", + "operation": "read" + }, + { + "file_path": "app/obs/telemetry.go", + "line": 43, + "component": "step_3_b", + "operation": "write" + } + ], + "sink": "logger.info", + "sanitized": false, + "files": [ + "app/auth/service.py", + "app/obs/telemetry.go" + ] + }, + { + "source": "header['Authorization']", + "path": [ + { + "file_path": "app/common/jwt_tools.py", + "line": 24, + "component": "step_4_a", + "operation": "read" + }, + { + "file_path": "app/common/jwt_tools.py", + "line": 44, + "component": "step_4_b", + "operation": "write" + } + ], + "sink": "jwt.decode", + "sanitized": true, + "files": [ + "app/common/jwt_tools.py" + ] + }, + { + "source": "query['email']", + "path": [ + { + "file_path": "app/notify/mailer.rb", + "line": 25, + "component": "step_5_a", + "operation": "read" + }, + { + "file_path": "app/notify/mailer.rb", + "line": 45, + "component": "step_5_b", + "operation": "write" + } + ], + "sink": "smtp.send", + "sanitized": false, + "files": [ + "app/notify/mailer.rb" + ] + }, + { + "source": "body['card_number']", + "path": [ + { + "file_path": "app/billing/payments.go", + "line": 26, + "component": "step_6_a", + "operation": "read" + }, + { + "file_path": "app/billing/core.py", + "line": 46, + "component": "step_6_b", + "operation": "write" + } + ], + "sink": "stripe.Charge.create", + "sanitized": true, + "files": [ + "app/billing/payments.go", + "app/billing/core.py" + ] + }, + { + "source": "path_param['id']", + "path": [ + { + "file_path": "app/api/thing_1.py", + "line": 27, + "component": "step_7_a", + "operation": "read" + }, + { + "file_path": "app/api/thing_1.py", + "line": 47, + "component": "step_7_b", + "operation": "write" + } + ], + "sink": "orm.filter", + "sanitized": true, + "files": [ + "app/api/thing_1.py" + ] + }, + { + "source": "upload.filename", + "path": [ + { + "file_path": "app/media/uploads.py", + "line": 28, + "component": "step_8_a", + "operation": "read" + }, + { + "file_path": "app/x/w.py", + "line": 48, + "component": "step_8_b", + "operation": "write" + } + ], + "sink": "open", + "sanitized": false, + "files": [ + "app/media/uploads.py", + "app/common/cache.py", + "app/common/guard.py", + "app/x/y.py", + "app/x/z.py", + "app/x/w.py" + ] + }, + { + "source": "env['DEBUG']", + "path": [ + { + "file_path": "app/web/render.py", + "line": 29, + "component": "step_9_a", + "operation": "read" + }, + { + "file_path": "app/web/render.py", + "line": 49, + "component": "step_9_b", + "operation": "write" + } + ], + "sink": "template.render", + "sanitized": false, + "files": [ + "app/web/render.py" + ] + } + ] +} + +TASK: +Systematically hunt for server-side request forgery vulnerabilities in this codebase. + +WORKFLOW (follow these steps in order): +1. Read the source files referenced in the RECON context above to understand the codebase. +2. Search for HTTP clients, URL handling, webhook integrations, and outbound network calls in the source code. +3. Read the identified files to confirm vulnerabilities with actual code evidence. +4. For each confirmed vulnerability, record the file path, line number, and relevant code snippet. +5. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +SCOPE: +- Prioritize source-to-sink paths that originate from documented entry points. +- Trace data flow across files and layers to identify user-controlled URL input reaching outbound network sinks. +- Cover these classes: + - CWE-918 Server-Side Request Forgery (SSRF) + - User-controlled URLs passed to HTTP clients + - URL parsing bypasses (protocol smuggling, IP encoding tricks) + - Cloud metadata endpoint access (169.254.169.254 for AWS, metadata.google.internal for GCP) + - Internal service discovery via SSRF + - Webhook URL validation gaps + - PDF/image renderers fetching external URLs + +OUTPUT: +- Return JSON that strictly matches HuntResult. +- Populate HuntResult.findings with RawFinding entries focused on SSRF issues. +- For each finding, include title, description, cwe_id, cwe_name, file_path, start_line, end_line, + code_snippet, estimated_severity, confidence, fingerprint, and related_files. +- Include data_flow when you can trace user-controlled source to vulnerable sink. +- Set hunter_strategy to "ssrf" for each finding. +- Keep HuntResult metadata coherent (total_raw, deduplicated_count, chain_count, strategies_run, + hunt_duration_seconds). +- Empty arrays are acceptable if no vulnerabilities are confirmed. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. + +Language: JAVASCRIPT + Key sinks: eval, Function(), innerHTML, document.write, child_process.exec, new Function + Safe patterns (skip these): DOMPurify.sanitize(), textContent assignment, parameterized pg queries + DO NOT FLAG: React JSX expressions (auto-escaped), Angular template bindings (sanitized by default) + Framework notes: Check for React/Vue/Angular-specific XSS patterns. React auto-escapes JSX. Vue v-html is dangerous. + +Language: GO + Key sinks: fmt.Sprintf into SQL, exec.Command with user input, template.HTML(), os.Exec + Safe patterns (skip these): database/sql with ? placeholders, html/template (auto-escapes), exec.Command with separate args + DO NOT FLAG: GORM parameterized queries, html/template default escaping, database/sql prepared statements + Framework notes: Go's html/template auto-escapes. text/template does NOT. Check for text/template serving HTML. + +Language: RUBY + Key sinks: eval, system, exec, send, public_send, ERB.new with user input + Safe patterns (skip these): ActiveRecord parameterized queries, Rack::Utils.escape_html, sanitize helper in Rails + DO NOT FLAG: ActiveRecord where with hash conditions, Rails CSRF protection, Rails html_safe on constants + Framework notes: Rails auto-escapes ERB templates. raw/html_safe bypasses escaping. Check for mass assignment. + +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +Framework: NEXTJS + Security features: React JSX auto-escapes output by default, API routes can share centralized auth middleware, Server/client boundaries reduce accidental secret exposure + DO NOT FLAG: React JSX escaped rendering, getServerSideProps/getServerSession with proper auth checks, Typed route handlers with validated schema guards + Watch for: dangerouslySetInnerHTML, unprotected API routes under /api, secret leakage to client bundles, rewrites/redirects from untrusted user input + Common vulns: XSS through dangerous HTML rendering, IDOR/auth bypass in API handlers, Sensitive env/config exposure in client-side code + +Framework: SPRING + Security features: Spring Security provides auth, CSRF, and filter chain defaults, JPA/Hibernate prepared parameter binding by default, Bean validation can enforce request constraints + DO NOT FLAG: PreparedStatement/JPA named parameter usage, Spring Security CSRF/auth filters clearly active, Thymeleaf auto-escaped output + Watch for: @PreAuthorize missing on sensitive methods, JdbcTemplate/raw Statement with concatenation, csrf().disable() on browser session flows, Actuator endpoints exposed without auth + Common vulns: Authz bypass from weak method-level security, SQL injection in raw JDBC queries, Sensitive management endpoint exposure + +Framework: REACT + Security features: JSX escapes strings before DOM rendering, Component model encourages explicit data flow, Framework discourages direct DOM mutation + DO NOT FLAG: Standard JSX expression rendering, textContent assignment for untrusted text, sanitized HTML via trusted DOMPurify policy + Watch for: dangerouslySetInnerHTML, untrusted URL assignment to href/src, eval/new Function in client code, token/secret exposure in bundles + Common vulns: DOM XSS through unsafe HTML injection, Open redirect via unvalidated navigation targets, Sensitive data exposure in frontend artifacts + +Framework: VUE + Security features: Mustache template interpolation escapes HTML, Component props/events provide explicit boundaries, Router guards can enforce auth flows + DO NOT FLAG: escaped template interpolation {{ value }}, validated route guards protecting private routes, sanitized content rendered through safe components + Watch for: v-html with untrusted data, dynamic component/template compilation, unsafe URL bindings in href/src, client-side auth checks without server enforcement + Common vulns: XSS through v-html and unsafe render paths, Auth bypass from client-only route protection, Open redirect patterns in router navigation + +CONSTRAINTS: +- Prefer high confidence only when source-to-sink flow is clearly traced. +- Do not speculate about exploitability without code evidence. +- Do not include markdown, prose outside JSON, or code fences in final output. +- Base all findings on actual file content you read. Do not speculate. +- DO NOT FLAG: + - URLs from trusted config/env variables + - Allowlisted domains with strict validation + - URLs validated against strict regex patterns + - Internal service calls with no user input + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Depth profile: standard +- Early stop rule: if you inspect 30 files without credible signal, stop and return empty findings. +- Focus on RECON entry points and data flows as primary source-to-sink paths. +- Explore the codebase, trace data flows from sources to sinks, and identify SSRF points. +- Take multiple turns to build findings incrementally and write final JSON only when complete. diff --git a/go/internal/agents/hunt/testdata/golden/prompt_ssrf_thorough.txt b/go/internal/agents/hunt/testdata/golden/prompt_ssrf_thorough.txt new file mode 100644 index 0000000..5119209 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_ssrf_thorough.txt @@ -0,0 +1,139 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are an expert SSRF vulnerability analyst for SEC-AF HUNT phase. + +CONTEXT: +Use the RECON context below to prioritize analysis by real entry points and known data flows. + +{ + "app_type": "web_api", + "auth_model": "jwt", + "frameworks": [ + "django" + ], + "languages": [ + "python" + ], + "entry_points": [ + { + "kind": "http", + "identifier": "POST /login", + "file_path": "app/api/auth.py", + "line": 12, + "method": "POST", + "route": "/login", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "seed", + "file_path": "app/cli.py", + "line": 3, + "method": null, + "route": null, + "auth_required": null + } + ], + "data_flows": [ + { + "source": "request.json", + "path": [ + { + "file_path": "app/api/users.py", + "line": 41, + "component": "handler", + "operation": "read id" + } + ], + "sink": "cursor.execute", + "sanitized": false, + "files": [ + "app/api/users.py" + ] + } + ] +} + +TASK: +Systematically hunt for server-side request forgery vulnerabilities in this codebase. + +WORKFLOW (follow these steps in order): +1. Read the source files referenced in the RECON context above to understand the codebase. +2. Search for HTTP clients, URL handling, webhook integrations, and outbound network calls in the source code. +3. Read the identified files to confirm vulnerabilities with actual code evidence. +4. For each confirmed vulnerability, record the file path, line number, and relevant code snippet. +5. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +SCOPE: +- Prioritize source-to-sink paths that originate from documented entry points. +- Trace data flow across files and layers to identify user-controlled URL input reaching outbound network sinks. +- Cover these classes: + - CWE-918 Server-Side Request Forgery (SSRF) + - User-controlled URLs passed to HTTP clients + - URL parsing bypasses (protocol smuggling, IP encoding tricks) + - Cloud metadata endpoint access (169.254.169.254 for AWS, metadata.google.internal for GCP) + - Internal service discovery via SSRF + - Webhook URL validation gaps + - PDF/image renderers fetching external URLs + +OUTPUT: +- Return JSON that strictly matches HuntResult. +- Populate HuntResult.findings with RawFinding entries focused on SSRF issues. +- For each finding, include title, description, cwe_id, cwe_name, file_path, start_line, end_line, + code_snippet, estimated_severity, confidence, fingerprint, and related_files. +- Include data_flow when you can trace user-controlled source to vulnerable sink. +- Set hunter_strategy to "ssrf" for each finding. +- Keep HuntResult metadata coherent (total_raw, deduplicated_count, chain_count, strategies_run, + hunt_duration_seconds). +- Empty arrays are acceptable if no vulnerabilities are confirmed. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +CONSTRAINTS: +- Prefer high confidence only when source-to-sink flow is clearly traced. +- Do not speculate about exploitability without code evidence. +- Do not include markdown, prose outside JSON, or code fences in final output. +- Base all findings on actual file content you read. Do not speculate. +- DO NOT FLAG: + - URLs from trusted config/env variables + - Allowlisted domains with strict validation + - URLs validated against strict regex patterns + - Internal service calls with no user input + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Depth profile: thorough +- Early stop rule: if you inspect 30 files without credible signal, stop and return empty findings. +- Focus on RECON entry points and data flows as primary source-to-sink paths. +- Explore the codebase, trace data flows from sources to sinks, and identify SSRF points. +- Take multiple turns to build findings incrementally and write final JSON only when complete. diff --git a/go/internal/agents/hunt/testdata/golden/prompt_supply_chain_standard.txt b/go/internal/agents/hunt/testdata/golden/prompt_supply_chain_standard.txt new file mode 100644 index 0000000..6b2be8e --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_supply_chain_standard.txt @@ -0,0 +1,169 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are SEC-AF's Supply Chain Hunter specializing in dependency risk and package ecosystem abuse. + +CONTEXT: +You are in HUNT Phase and must output a HuntResult JSON object for supply-chain findings. +Use the RECON context summary below as primary evidence. + +RECON_CONTEXT: +Supply-chain-focused recon summary. + +Dependency inventory: direct=7, transitive=143, SBOM entries=14. + +Known CVE exposure (reachable/high severity first): 18 total, showing top 15: +- CVE-2023-0014 in log4j 2.14.0 (fixed=2.17.1, cvss=10.0, epss=0.97, direct=False, reachable=True) +- CVE-2023-0003 in lodash 4.17.19 (fixed=4.17.21, cvss=9.8, epss=0.9, direct=True, reachable=True) +- CVE-2023-0001 in django 4.2.1 (fixed=4.2.5, cvss=9.8, epss=0.5, direct=True, reachable=True) +- CVE-2023-0013 in pyyaml 5.3 (fixed=5.4, cvss=9.8, epss=0.5, direct=False, reachable=True) +- CVE-2023-0008 in nokogiri 1.13.6 (fixed=1.13.10, cvss=8.8, epss=0.3, direct=False, reachable=True) +- CVE-2023-0018 in curl 7.68.0 (fixed=7.88.0, cvss=8.1, epss=None, direct=True, reachable=True) +- CVE-2023-0009 in golang.org/x/net 0.7.0 (fixed=0.17.0, cvss=7.5, epss=0.44, direct=False, reachable=True) +- CVE-2023-0012 in requests 2.31.0 (fixed=2.32.0, cvss=6.5, epss=0.07, direct=True, reachable=True) +- CVE-2023-0005 in axios 0.21.1 (fixed=0.21.2, cvss=5.3, epss=0.02, direct=False, reachable=True) +- CVE-2023-0017 in libxml2 2.9.10 (fixed=unknown, cvss=None, epss=0.25, direct=False, reachable=True) +- CVE-2023-0010 in rails 7.0.4 (fixed=7.0.7, cvss=9.1, epss=0.6, direct=True, reachable=False) +- CVE-2023-0016 in zlib 1.2.11 (fixed=1.2.12, cvss=8.2, epss=0.04, direct=False, reachable=False) +- CVE-2023-0002 in urllib3 1.26.5 (fixed=unknown, cvss=7.5, epss=0.1, direct=False, reachable=False) +- CVE-2023-0015 in openssl 1.1.1k (fixed=1.1.1t, cvss=7.4, epss=0.12, direct=False, reachable=None) +- CVE-2023-0006 in jinja2 3.1.2 (fixed=unknown, cvss=6.1, epss=None, direct=False, reachable=False) + +Outdated dependencies: 12 total, showing top 10: +- pkg-0: 1.0.0 -> 2.0.0 (direct=True) +- pkg-1: 1.1.0 -> 2.1.0 (direct=False) +- pkg-2: 1.2.0 -> 2.2.0 (direct=True) +- pkg-3: 1.3.0 -> 2.3.0 (direct=False) +- pkg-4: 1.4.0 -> 2.4.0 (direct=True) +- pkg-5: 1.5.0 -> 2.5.0 (direct=False) +- pkg-6: 1.6.0 -> 2.6.0 (direct=True) +- pkg-7: 1.7.0 -> 2.7.0 (direct=False) +- pkg-8: 1.8.0 -> 2.8.0 (direct=True) +- pkg-9: 1.9.0 -> 2.9.0 (direct=False) + +Primary dependency ecosystems in this repo: 12 total, showing top 10: +- go: github.com/gin-gonic/gin@1.9.0 +- go: golang.org/x/net@0.7.0 +- npm: axios@0.21.1 +- npm: express@4.17.1 +- npm: lodash@4.17.19 +- npm: minimist@1.2.5 +- pypi: django@4.2.1 +- pypi: jinja2@3.1.2 +- pypi: requests@2.31.0 +- pypi: urllib3@1.26.5 + +TASK: +Identify supply-chain vulnerabilities with emphasis on CWE-1104 (unmaintained third-party components) +and CWE-829 (inclusion of functionality from untrusted control sphere). + +SCOPE: +- Analyze dependency manifests and lockfiles across ecosystems (npm/yarn/pnpm, Python, Go, Rust, etc.). +- Use RECON dependency context (SBOM, known CVEs, outdated packages) as primary evidence. +- Detect known vulnerable versions and include CVE IDs when available. +- Flag unmaintained/abandoned packages and stale dependencies with security impact. +- Flag typosquatting and look-alike package names likely to be malicious. +- Flag untrusted package sources, private-registry confusion, and dependency confusion patterns. +- Flag risky version pinning posture: missing lockfile, wildcard ranges, loose semver constraints. +- Flag lockfile drift/integrity issues where manifest and lock data are inconsistent. + +OUTPUT: +- Return JSON that strictly matches HuntResult. +- Use finding_type "sca" for dependency findings. +- Every finding must include concrete package evidence (package name, version, file path, and rationale). +- Include CWE mapping: CWE-1104 or CWE-829 for each finding. +- Include CVE identifiers in description/code_snippet when available from recon evidence. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. + +Language: JAVASCRIPT + Key sinks: eval, Function(), innerHTML, document.write, child_process.exec, new Function + Safe patterns (skip these): DOMPurify.sanitize(), textContent assignment, parameterized pg queries + DO NOT FLAG: React JSX expressions (auto-escaped), Angular template bindings (sanitized by default) + Framework notes: Check for React/Vue/Angular-specific XSS patterns. React auto-escapes JSX. Vue v-html is dangerous. + +Language: GO + Key sinks: fmt.Sprintf into SQL, exec.Command with user input, template.HTML(), os.Exec + Safe patterns (skip these): database/sql with ? placeholders, html/template (auto-escapes), exec.Command with separate args + DO NOT FLAG: GORM parameterized queries, html/template default escaping, database/sql prepared statements + Framework notes: Go's html/template auto-escapes. text/template does NOT. Check for text/template serving HTML. + +Language: RUBY + Key sinks: eval, system, exec, send, public_send, ERB.new with user input + Safe patterns (skip these): ActiveRecord parameterized queries, Rack::Utils.escape_html, sanitize helper in Rails + DO NOT FLAG: ActiveRecord where with hash conditions, Rails CSRF protection, Rails html_safe on constants + Framework notes: Rails auto-escapes ERB templates. raw/html_safe bypasses escaping. Check for mass assignment. + +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +Framework: NEXTJS + Security features: React JSX auto-escapes output by default, API routes can share centralized auth middleware, Server/client boundaries reduce accidental secret exposure + DO NOT FLAG: React JSX escaped rendering, getServerSideProps/getServerSession with proper auth checks, Typed route handlers with validated schema guards + Watch for: dangerouslySetInnerHTML, unprotected API routes under /api, secret leakage to client bundles, rewrites/redirects from untrusted user input + Common vulns: XSS through dangerous HTML rendering, IDOR/auth bypass in API handlers, Sensitive env/config exposure in client-side code + +Framework: SPRING + Security features: Spring Security provides auth, CSRF, and filter chain defaults, JPA/Hibernate prepared parameter binding by default, Bean validation can enforce request constraints + DO NOT FLAG: PreparedStatement/JPA named parameter usage, Spring Security CSRF/auth filters clearly active, Thymeleaf auto-escaped output + Watch for: @PreAuthorize missing on sensitive methods, JdbcTemplate/raw Statement with concatenation, csrf().disable() on browser session flows, Actuator endpoints exposed without auth + Common vulns: Authz bypass from weak method-level security, SQL injection in raw JDBC queries, Sensitive management endpoint exposure + +Framework: REACT + Security features: JSX escapes strings before DOM rendering, Component model encourages explicit data flow, Framework discourages direct DOM mutation + DO NOT FLAG: Standard JSX expression rendering, textContent assignment for untrusted text, sanitized HTML via trusted DOMPurify policy + Watch for: dangerouslySetInnerHTML, untrusted URL assignment to href/src, eval/new Function in client code, token/secret exposure in bundles + Common vulns: DOM XSS through unsafe HTML injection, Open redirect via unvalidated navigation targets, Sensitive data exposure in frontend artifacts + +Framework: VUE + Security features: Mustache template interpolation escapes HTML, Component props/events provide explicit boundaries, Router guards can enforce auth flows + DO NOT FLAG: escaped template interpolation {{ value }}, validated route guards protecting private routes, sanitized content rendered through safe components + Watch for: v-html with untrusted data, dynamic component/template compilation, unsafe URL bindings in href/src, client-side auth checks without server enforcement + Common vulns: XSS through v-html and unsafe render paths, Auth bypass from client-only route protection, Open redirect patterns in router navigation + +CONSTRAINTS: +- Take multiple turns: inspect relevant files first, then build findings incrementally. +- Do not invent package versions, CVEs, or exploitability claims. +- Prefer high-confidence findings with explicit evidence over speculative findings. +- No markdown/code fences; JSON output only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Hunt strategy: supply_chain (CWE-1104, CWE-829). +- Early stop rule: if you inspect standard manifests/files without credible dependency risk, stop and return empty findings. +- Focus manifests/lockfiles (package.json, requirements.txt, go.mod, Pipfile, poetry.lock, package-lock.json, yarn.lock, pnpm-lock.yaml, Cargo.toml). +- Take multiple turns: inspect manifests/lockfiles, validate dependency risks, then produce final structured findings. +- Write final JSON only when analysis is complete. + diff --git a/go/internal/agents/hunt/testdata/golden/prompt_supply_chain_thorough.txt b/go/internal/agents/hunt/testdata/golden/prompt_supply_chain_thorough.txt new file mode 100644 index 0000000..bc3542a --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_supply_chain_thorough.txt @@ -0,0 +1,88 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are SEC-AF's Supply Chain Hunter specializing in dependency risk and package ecosystem abuse. + +CONTEXT: +You are in HUNT Phase and must output a HuntResult JSON object for supply-chain findings. +Use the RECON context summary below as primary evidence. + +RECON_CONTEXT: +Supply-chain-focused recon summary. + +Dependency inventory: direct=3, transitive=9, SBOM entries=1. + +Known CVE exposure (reachable/high severity first): 1 total, showing top 1: +- CVE-2024-0001 in django 4.2.1 (fixed=4.2.11, cvss=7.5, epss=0.42, direct=True, reachable=True) + +Outdated dependencies: none identified in recon. + +Primary dependency ecosystems in this repo: 1 total, showing top 1: +- pypi: django@4.2.1 + +TASK: +Identify supply-chain vulnerabilities with emphasis on CWE-1104 (unmaintained third-party components) +and CWE-829 (inclusion of functionality from untrusted control sphere). + +SCOPE: +- Analyze dependency manifests and lockfiles across ecosystems (npm/yarn/pnpm, Python, Go, Rust, etc.). +- Use RECON dependency context (SBOM, known CVEs, outdated packages) as primary evidence. +- Detect known vulnerable versions and include CVE IDs when available. +- Flag unmaintained/abandoned packages and stale dependencies with security impact. +- Flag typosquatting and look-alike package names likely to be malicious. +- Flag untrusted package sources, private-registry confusion, and dependency confusion patterns. +- Flag risky version pinning posture: missing lockfile, wildcard ranges, loose semver constraints. +- Flag lockfile drift/integrity issues where manifest and lock data are inconsistent. + +OUTPUT: +- Return JSON that strictly matches HuntResult. +- Use finding_type "sca" for dependency findings. +- Every finding must include concrete package evidence (package name, version, file path, and rationale). +- Include CWE mapping: CWE-1104 or CWE-829 for each finding. +- Include CVE identifiers in description/code_snippet when available from recon evidence. + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +CONSTRAINTS: +- Take multiple turns: inspect relevant files first, then build findings incrementally. +- Do not invent package versions, CVEs, or exploitability claims. +- Prefer high-confidence findings with explicit evidence over speculative findings. +- No markdown/code fences; JSON output only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Hunt strategy: supply_chain (CWE-1104, CWE-829). +- Early stop rule: if you inspect thorough manifests/files without credible dependency risk, stop and return empty findings. +- Focus manifests/lockfiles (package.json, requirements.txt, go.mod, Pipfile, poetry.lock, package-lock.json, yarn.lock, pnpm-lock.yaml, Cargo.toml). +- Take multiple turns: inspect manifests/lockfiles, validate dependency risks, then produce final structured findings. +- Write final JSON only when analysis is complete. + diff --git a/go/internal/agents/hunt/testdata/golden/prompt_xss_standard.txt b/go/internal/agents/hunt/testdata/golden/prompt_xss_standard.txt new file mode 100644 index 0000000..6c5f971 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_xss_standard.txt @@ -0,0 +1,485 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are an expert XSS and client-side injection analyst for SEC-AF HUNT phase. + +CONTEXT: +Use the RECON context below to prioritize analysis by real entry points and known data flows. + +{ + "app_type": "web_api", + "auth_model": "jwt", + "frameworks": [ + "Django", + "next.js", + "Spring Boot", + " React ", + "unknown-fw", + "NEXT", + "Vue" + ], + "languages": [ + "Python", + "JavaScript", + "Go", + "Ruby", + "Rust", + "python" + ], + "entry_points": [ + { + "kind": "http", + "identifier": "handler_0", + "file_path": "app/entry/e0.py", + "line": 10, + "method": "GET", + "route": null, + "auth_required": true + }, + { + "kind": "HTTP", + "identifier": "handler_1", + "file_path": "app/entry/e1.py", + "line": 17, + "method": "POST", + "route": "/v1/resource/1", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "handler_2", + "file_path": "app/entry/e2.py", + "line": 24, + "method": null, + "route": "/v1/resource/2", + "auth_required": null + }, + { + "kind": "api", + "identifier": "handler_3", + "file_path": "app/entry/e3.py", + "line": 31, + "method": "PUT", + "route": null, + "auth_required": true + }, + { + "kind": "graphql", + "identifier": "handler_4", + "file_path": "app/entry/e4.py", + "line": 38, + "method": "GET", + "route": "/v1/resource/4", + "auth_required": false + }, + { + "kind": "rpc", + "identifier": "handler_5", + "file_path": "app/entry/e5.py", + "line": 45, + "method": "POST", + "route": "/v1/resource/5", + "auth_required": null + }, + { + "kind": "route", + "identifier": "handler_6", + "file_path": "app/entry/e6.py", + "line": 52, + "method": null, + "route": null, + "auth_required": true + }, + { + "kind": "cron", + "identifier": "handler_7", + "file_path": "app/entry/e7.py", + "line": 59, + "method": "PUT", + "route": "/v1/resource/7", + "auth_required": false + }, + { + "kind": "queue", + "identifier": "handler_8", + "file_path": "app/entry/e8.py", + "line": 66, + "method": "GET", + "route": "/v1/resource/8", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_9", + "file_path": "app/entry/e9.py", + "line": 73, + "method": "POST", + "route": null, + "auth_required": true + } + ], + "data_flows": [ + { + "source": "request.args['q']", + "path": [ + { + "file_path": "app/search/index.ts", + "line": 20, + "component": "step_0_a", + "operation": "read" + }, + { + "file_path": "app/util/b.py", + "line": 40, + "component": "step_0_b", + "operation": "write" + } + ], + "sink": "cursor.execute", + "sanitized": false, + "files": [ + "app/search/index.ts", + "app/db/raw.py", + "app/util/a.py", + "app/util/b.py" + ] + }, + { + "source": "request.cookies['session']", + "path": [ + { + "file_path": "app/auth/session_store.py", + "line": 21, + "component": "step_1_a", + "operation": "read" + }, + { + "file_path": "app/auth/session_store.py", + "line": 41, + "component": "step_1_b", + "operation": "write" + } + ], + "sink": "redis.set", + "sanitized": true, + "files": [ + "app/auth/session_store.py" + ] + }, + { + "source": "request.json['url']", + "path": [ + { + "file_path": "app/integrations/webhooks.go", + "line": 22, + "component": "step_2_a", + "operation": "read" + }, + { + "file_path": "app/net/client.go", + "line": 42, + "component": "step_2_b", + "operation": "write" + } + ], + "sink": "requests.get", + "sanitized": false, + "files": [ + "app/integrations/webhooks.go", + "app/net/client.go" + ] + }, + { + "source": "form['password']", + "path": [ + { + "file_path": "app/auth/service.py", + "line": 23, + "component": "step_3_a", + "operation": "read" + }, + { + "file_path": "app/obs/telemetry.go", + "line": 43, + "component": "step_3_b", + "operation": "write" + } + ], + "sink": "logger.info", + "sanitized": false, + "files": [ + "app/auth/service.py", + "app/obs/telemetry.go" + ] + }, + { + "source": "header['Authorization']", + "path": [ + { + "file_path": "app/common/jwt_tools.py", + "line": 24, + "component": "step_4_a", + "operation": "read" + }, + { + "file_path": "app/common/jwt_tools.py", + "line": 44, + "component": "step_4_b", + "operation": "write" + } + ], + "sink": "jwt.decode", + "sanitized": true, + "files": [ + "app/common/jwt_tools.py" + ] + }, + { + "source": "query['email']", + "path": [ + { + "file_path": "app/notify/mailer.rb", + "line": 25, + "component": "step_5_a", + "operation": "read" + }, + { + "file_path": "app/notify/mailer.rb", + "line": 45, + "component": "step_5_b", + "operation": "write" + } + ], + "sink": "smtp.send", + "sanitized": false, + "files": [ + "app/notify/mailer.rb" + ] + }, + { + "source": "body['card_number']", + "path": [ + { + "file_path": "app/billing/payments.go", + "line": 26, + "component": "step_6_a", + "operation": "read" + }, + { + "file_path": "app/billing/core.py", + "line": 46, + "component": "step_6_b", + "operation": "write" + } + ], + "sink": "stripe.Charge.create", + "sanitized": true, + "files": [ + "app/billing/payments.go", + "app/billing/core.py" + ] + }, + { + "source": "path_param['id']", + "path": [ + { + "file_path": "app/api/thing_1.py", + "line": 27, + "component": "step_7_a", + "operation": "read" + }, + { + "file_path": "app/api/thing_1.py", + "line": 47, + "component": "step_7_b", + "operation": "write" + } + ], + "sink": "orm.filter", + "sanitized": true, + "files": [ + "app/api/thing_1.py" + ] + }, + { + "source": "upload.filename", + "path": [ + { + "file_path": "app/media/uploads.py", + "line": 28, + "component": "step_8_a", + "operation": "read" + }, + { + "file_path": "app/x/w.py", + "line": 48, + "component": "step_8_b", + "operation": "write" + } + ], + "sink": "open", + "sanitized": false, + "files": [ + "app/media/uploads.py", + "app/common/cache.py", + "app/common/guard.py", + "app/x/y.py", + "app/x/z.py", + "app/x/w.py" + ] + }, + { + "source": "env['DEBUG']", + "path": [ + { + "file_path": "app/web/render.py", + "line": 29, + "component": "step_9_a", + "operation": "read" + }, + { + "file_path": "app/web/render.py", + "line": 49, + "component": "step_9_b", + "operation": "write" + } + ], + "sink": "template.render", + "sanitized": false, + "files": [ + "app/web/render.py" + ] + } + ] +} + +TASK: +Systematically hunt for XSS and client-side injection vulnerabilities in this codebase. + +SCOPE: +- Prioritize source-to-sink paths that originate from documented entry points. +- Trace data flow across files and layers to identify missing validation, sanitization, context-aware escaping, or safe encoding. +- Cover these classes: + - CWE-79 Cross-Site Scripting (XSS) + - CWE-80 Improper Neutralization of Script-Related HTML Tags in a Web Page (Basic XSS) + - CWE-87 Improper Neutralization of Alternate XSS Syntax + - CWE-116 Improper Encoding or Escaping of Output +- Hunt for: + - Stored XSS (database/content store to rendered output) + - Reflected XSS (request input to response output) + - DOM XSS (client-side JS flows into dangerous DOM sinks) + - HTML injection that enables script execution paths + - Template injection in client rendering paths that can lead to executable markup/script + +OUTPUT: +- Return JSON that strictly matches HuntResult. +- Populate HuntResult.findings with RawFinding entries focused on XSS/client-side injection issues. +- For each finding, include title, description, cwe_id, cwe_name, file_path, start_line, end_line, + code_snippet, estimated_severity, confidence, fingerprint, and related_files. +- Include data_flow when you can trace user-controlled source to vulnerable rendering sink. +- Set hunter_strategy to "xss" for each finding. +- Keep HuntResult metadata coherent (total_raw, deduplicated_count, chain_count, strategies_run, + hunt_duration_seconds). + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. + +Language: JAVASCRIPT + Key sinks: eval, Function(), innerHTML, document.write, child_process.exec, new Function + Safe patterns (skip these): DOMPurify.sanitize(), textContent assignment, parameterized pg queries + DO NOT FLAG: React JSX expressions (auto-escaped), Angular template bindings (sanitized by default) + Framework notes: Check for React/Vue/Angular-specific XSS patterns. React auto-escapes JSX. Vue v-html is dangerous. + +Language: GO + Key sinks: fmt.Sprintf into SQL, exec.Command with user input, template.HTML(), os.Exec + Safe patterns (skip these): database/sql with ? placeholders, html/template (auto-escapes), exec.Command with separate args + DO NOT FLAG: GORM parameterized queries, html/template default escaping, database/sql prepared statements + Framework notes: Go's html/template auto-escapes. text/template does NOT. Check for text/template serving HTML. + +Language: RUBY + Key sinks: eval, system, exec, send, public_send, ERB.new with user input + Safe patterns (skip these): ActiveRecord parameterized queries, Rack::Utils.escape_html, sanitize helper in Rails + DO NOT FLAG: ActiveRecord where with hash conditions, Rails CSRF protection, Rails html_safe on constants + Framework notes: Rails auto-escapes ERB templates. raw/html_safe bypasses escaping. Check for mass assignment. + +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +Framework: NEXTJS + Security features: React JSX auto-escapes output by default, API routes can share centralized auth middleware, Server/client boundaries reduce accidental secret exposure + DO NOT FLAG: React JSX escaped rendering, getServerSideProps/getServerSession with proper auth checks, Typed route handlers with validated schema guards + Watch for: dangerouslySetInnerHTML, unprotected API routes under /api, secret leakage to client bundles, rewrites/redirects from untrusted user input + Common vulns: XSS through dangerous HTML rendering, IDOR/auth bypass in API handlers, Sensitive env/config exposure in client-side code + +Framework: SPRING + Security features: Spring Security provides auth, CSRF, and filter chain defaults, JPA/Hibernate prepared parameter binding by default, Bean validation can enforce request constraints + DO NOT FLAG: PreparedStatement/JPA named parameter usage, Spring Security CSRF/auth filters clearly active, Thymeleaf auto-escaped output + Watch for: @PreAuthorize missing on sensitive methods, JdbcTemplate/raw Statement with concatenation, csrf().disable() on browser session flows, Actuator endpoints exposed without auth + Common vulns: Authz bypass from weak method-level security, SQL injection in raw JDBC queries, Sensitive management endpoint exposure + +Framework: REACT + Security features: JSX escapes strings before DOM rendering, Component model encourages explicit data flow, Framework discourages direct DOM mutation + DO NOT FLAG: Standard JSX expression rendering, textContent assignment for untrusted text, sanitized HTML via trusted DOMPurify policy + Watch for: dangerouslySetInnerHTML, untrusted URL assignment to href/src, eval/new Function in client code, token/secret exposure in bundles + Common vulns: DOM XSS through unsafe HTML injection, Open redirect via unvalidated navigation targets, Sensitive data exposure in frontend artifacts + +Framework: VUE + Security features: Mustache template interpolation escapes HTML, Component props/events provide explicit boundaries, Router guards can enforce auth flows + DO NOT FLAG: escaped template interpolation {{ value }}, validated route guards protecting private routes, sanitized content rendered through safe components + Watch for: v-html with untrusted data, dynamic component/template compilation, unsafe URL bindings in href/src, client-side auth checks without server enforcement + Common vulns: XSS through v-html and unsafe render paths, Auth bypass from client-only route protection, Open redirect patterns in router navigation + +CONSTRAINTS: +- Take multiple turns: explore the codebase, trace data flows from sources to rendering sinks, + identify candidate XSS/client-side injection paths, then construct final findings. +- Prefer high confidence only when source-to-sink flow is clearly traced. +- Do not speculate about exploitability without code evidence. +- Do not include markdown, prose outside JSON, or code fences in final output. +- DO NOT FLAG: + - React JSX auto-escaping when no dangerous bypass APIs are used + - Django template default escaping when no unsafe escape bypass is present + - Vue template escaping when no unsafe render/bypass pattern is present + - Angular built-in sanitization when not explicitly bypassed + - Output that is sanitized through DOMPurify + - Properly escaped template variables in server or client rendering + - Test fixtures, synthetic payload examples, and comments that only demonstrate attack strings + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Depth profile: standard +- Early stop rule: if you inspect 30 files without credible signal, stop and return empty findings. +- Focus on RECON entry points and data flows as primary source-to-sink paths. +- Explore the codebase, trace user-controlled data into rendering sinks, and identify XSS/client-side injection points. +- Target CWEs: CWE-79, CWE-80, CWE-87, CWE-116. +- Take multiple turns to build findings incrementally and write final JSON only when complete. diff --git a/go/internal/agents/hunt/testdata/golden/prompt_xss_thorough.txt b/go/internal/agents/hunt/testdata/golden/prompt_xss_thorough.txt new file mode 100644 index 0000000..4387bd2 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/prompt_xss_thorough.txt @@ -0,0 +1,139 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +ROLE: +You are an expert XSS and client-side injection analyst for SEC-AF HUNT phase. + +CONTEXT: +Use the RECON context below to prioritize analysis by real entry points and known data flows. + +{ + "app_type": "web_api", + "auth_model": "jwt", + "frameworks": [ + "django" + ], + "languages": [ + "python" + ], + "entry_points": [ + { + "kind": "http", + "identifier": "POST /login", + "file_path": "app/api/auth.py", + "line": 12, + "method": "POST", + "route": "/login", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "seed", + "file_path": "app/cli.py", + "line": 3, + "method": null, + "route": null, + "auth_required": null + } + ], + "data_flows": [ + { + "source": "request.json", + "path": [ + { + "file_path": "app/api/users.py", + "line": 41, + "component": "handler", + "operation": "read id" + } + ], + "sink": "cursor.execute", + "sanitized": false, + "files": [ + "app/api/users.py" + ] + } + ] +} + +TASK: +Systematically hunt for XSS and client-side injection vulnerabilities in this codebase. + +SCOPE: +- Prioritize source-to-sink paths that originate from documented entry points. +- Trace data flow across files and layers to identify missing validation, sanitization, context-aware escaping, or safe encoding. +- Cover these classes: + - CWE-79 Cross-Site Scripting (XSS) + - CWE-80 Improper Neutralization of Script-Related HTML Tags in a Web Page (Basic XSS) + - CWE-87 Improper Neutralization of Alternate XSS Syntax + - CWE-116 Improper Encoding or Escaping of Output +- Hunt for: + - Stored XSS (database/content store to rendered output) + - Reflected XSS (request input to response output) + - DOM XSS (client-side JS flows into dangerous DOM sinks) + - HTML injection that enables script execution paths + - Template injection in client rendering paths that can lead to executable markup/script + +OUTPUT: +- Return JSON that strictly matches HuntResult. +- Populate HuntResult.findings with RawFinding entries focused on XSS/client-side injection issues. +- For each finding, include title, description, cwe_id, cwe_name, file_path, start_line, end_line, + code_snippet, estimated_severity, confidence, fingerprint, and related_files. +- Include data_flow when you can trace user-controlled source to vulnerable rendering sink. +- Set hunter_strategy to "xss" for each finding. +- Keep HuntResult metadata coherent (total_raw, deduplicated_count, chain_count, strategies_run, + hunt_duration_seconds). + +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +CONSTRAINTS: +- Take multiple turns: explore the codebase, trace data flows from sources to rendering sinks, + identify candidate XSS/client-side injection paths, then construct final findings. +- Prefer high confidence only when source-to-sink flow is clearly traced. +- Do not speculate about exploitability without code evidence. +- Do not include markdown, prose outside JSON, or code fences in final output. +- DO NOT FLAG: + - React JSX auto-escaping when no dangerous bypass APIs are used + - Django template default escaping when no unsafe escape bypass is present + - Vue template escaping when no unsafe render/bypass pattern is present + - Angular built-in sanitization when not explicitly bypassed + - Output that is sanitized through DOMPurify + - Properly escaped template variables in server or client rendering + - Test fixtures, synthetic payload examples, and comments that only demonstrate attack strings + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Depth profile: thorough +- Early stop rule: if you inspect 30 files without credible signal, stop and return empty findings. +- Focus on RECON entry points and data flows as primary source-to-sink paths. +- Explore the codebase, trace user-controlled data into rendering sinks, and identify XSS/client-side injection points. +- Target CWEs: CWE-79, CWE-80, CWE-87, CWE-116. +- Take multiple turns to build findings incrementally and write final JSON only when complete. diff --git a/go/internal/agents/hunt/testdata/golden/quick_strategies.json b/go/internal/agents/hunt/testdata/golden/quick_strategies.json new file mode 100644 index 0000000..de7c8ee --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/quick_strategies.json @@ -0,0 +1,7 @@ +[ + "injection", + "dos", + "ssrf", + "auth", + "data_exposure" +] diff --git a/go/internal/agents/hunt/testdata/golden/recon_context_block_business_logic.txt b/go/internal/agents/hunt/testdata/golden/recon_context_block_business_logic.txt new file mode 100644 index 0000000..428b154 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/recon_context_block_business_logic.txt @@ -0,0 +1,796 @@ +{ + "app_type": "web_api", + "frameworks": [ + "Django", + "next.js", + "Spring Boot", + " React ", + "unknown-fw", + "NEXT", + "Vue" + ], + "languages": [ + "Python", + "JavaScript", + "Go", + "Ruby", + "Rust", + "python" + ], + "auth_model": "jwt", + "auth_details": "HS256 JWTs issued by app/auth/service.py, refresh tokens in Redis", + "entry_points": [ + { + "kind": "http", + "identifier": "handler_0", + "file_path": "app/entry/e0.py", + "line": 10, + "method": "GET", + "route": null, + "auth_required": true + }, + { + "kind": "HTTP", + "identifier": "handler_1", + "file_path": "app/entry/e1.py", + "line": 17, + "method": "POST", + "route": "/v1/resource/1", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "handler_2", + "file_path": "app/entry/e2.py", + "line": 24, + "method": null, + "route": "/v1/resource/2", + "auth_required": null + }, + { + "kind": "api", + "identifier": "handler_3", + "file_path": "app/entry/e3.py", + "line": 31, + "method": "PUT", + "route": null, + "auth_required": true + }, + { + "kind": "graphql", + "identifier": "handler_4", + "file_path": "app/entry/e4.py", + "line": 38, + "method": "GET", + "route": "/v1/resource/4", + "auth_required": false + }, + { + "kind": "rpc", + "identifier": "handler_5", + "file_path": "app/entry/e5.py", + "line": 45, + "method": "POST", + "route": "/v1/resource/5", + "auth_required": null + }, + { + "kind": "route", + "identifier": "handler_6", + "file_path": "app/entry/e6.py", + "line": 52, + "method": null, + "route": null, + "auth_required": true + }, + { + "kind": "cron", + "identifier": "handler_7", + "file_path": "app/entry/e7.py", + "line": 59, + "method": "PUT", + "route": "/v1/resource/7", + "auth_required": false + }, + { + "kind": "queue", + "identifier": "handler_8", + "file_path": "app/entry/e8.py", + "line": 66, + "method": "GET", + "route": "/v1/resource/8", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_9", + "file_path": "app/entry/e9.py", + "line": 73, + "method": "POST", + "route": null, + "auth_required": true + }, + { + "kind": "api", + "identifier": "handler_10", + "file_path": "app/entry/e10.py", + "line": 80, + "method": null, + "route": "/v1/resource/10", + "auth_required": false + }, + { + "kind": "grpc", + "identifier": "handler_11", + "file_path": "app/entry/e11.py", + "line": 87, + "method": "PUT", + "route": "/v1/resource/11", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_12", + "file_path": "app/entry/e12.py", + "line": 94, + "method": "GET", + "route": null, + "auth_required": true + }, + { + "kind": "route", + "identifier": "handler_13", + "file_path": "app/entry/e13.py", + "line": 101, + "method": "POST", + "route": "/v1/resource/13", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "handler_14", + "file_path": "app/entry/e14.py", + "line": 108, + "method": null, + "route": "/v1/resource/14", + "auth_required": null + } + ], + "api_surface": [ + { + "method": "GET", + "path": "/api/v1/thing/0", + "handler": "ThingController.action0", + "file_path": "app/api/thing_0.py", + "line": 100, + "auth_required": true, + "rate_limited": null + }, + { + "method": "POST", + "path": "/api/v1/thing/1", + "handler": "ThingController.action1", + "file_path": "app/api/thing_1.py", + "line": 103, + "auth_required": false, + "rate_limited": false + }, + { + "method": "PUT", + "path": "/api/v1/thing/2", + "handler": "ThingController.action2", + "file_path": "app/api/thing_2.py", + "line": 106, + "auth_required": null, + "rate_limited": true + }, + { + "method": "DELETE", + "path": "/api/v1/thing/3", + "handler": "ThingController.action3", + "file_path": "app/api/thing_3.py", + "line": 109, + "auth_required": false, + "rate_limited": false + }, + { + "method": "PATCH", + "path": "/api/v1/thing/4", + "handler": "ThingController.action4", + "file_path": "app/api/thing_4.py", + "line": 112, + "auth_required": true, + "rate_limited": null + }, + { + "method": "GET", + "path": "/api/v1/thing/5", + "handler": "ThingController.action5", + "file_path": "app/api/thing_5.py", + "line": 115, + "auth_required": null, + "rate_limited": true + }, + { + "method": "POST", + "path": "/api/v1/thing/6", + "handler": "ThingController.action6", + "file_path": "app/api/thing_6.py", + "line": 118, + "auth_required": true, + "rate_limited": null + }, + { + "method": "PUT", + "path": "/api/v1/thing/7", + "handler": "ThingController.action7", + "file_path": "app/api/thing_7.py", + "line": 121, + "auth_required": false, + "rate_limited": false + }, + { + "method": "DELETE", + "path": "/api/v1/thing/8", + "handler": "ThingController.action8", + "file_path": "app/api/thing_8.py", + "line": 124, + "auth_required": null, + "rate_limited": true + }, + { + "method": "PATCH", + "path": "/api/v1/thing/9", + "handler": "ThingController.action9", + "file_path": "app/api/thing_9.py", + "line": 127, + "auth_required": false, + "rate_limited": false + }, + { + "method": "GET", + "path": "/api/v1/thing/10", + "handler": "ThingController.action10", + "file_path": "app/api/thing_10.py", + "line": 130, + "auth_required": true, + "rate_limited": null + }, + { + "method": "POST", + "path": "/api/v1/thing/11", + "handler": "ThingController.action11", + "file_path": "app/api/thing_11.py", + "line": 133, + "auth_required": null, + "rate_limited": true + }, + { + "method": "PUT", + "path": "/api/v1/thing/12", + "handler": "ThingController.action12", + "file_path": "app/api/thing_12.py", + "line": 136, + "auth_required": true, + "rate_limited": null + }, + { + "method": "DELETE", + "path": "/api/v1/thing/13", + "handler": "ThingController.action13", + "file_path": "app/api/thing_13.py", + "line": 139, + "auth_required": false, + "rate_limited": false + }, + { + "method": "PATCH", + "path": "/api/v1/thing/14", + "handler": "ThingController.action14", + "file_path": "app/api/thing_14.py", + "line": 142, + "auth_required": null, + "rate_limited": true + }, + { + "method": "GET", + "path": "/api/v1/thing/15", + "handler": "ThingController.action15", + "file_path": "app/api/thing_15.py", + "line": 145, + "auth_required": false, + "rate_limited": false + }, + { + "method": "POST", + "path": "/api/v1/thing/16", + "handler": "ThingController.action16", + "file_path": "app/api/thing_16.py", + "line": 148, + "auth_required": true, + "rate_limited": null + }, + { + "method": "PUT", + "path": "/api/v1/thing/17", + "handler": "ThingController.action17", + "file_path": "app/api/thing_17.py", + "line": 151, + "auth_required": null, + "rate_limited": true + }, + { + "method": "DELETE", + "path": "/api/v1/thing/18", + "handler": "ThingController.action18", + "file_path": "app/api/thing_18.py", + "line": 154, + "auth_required": true, + "rate_limited": null + }, + { + "method": "PATCH", + "path": "/api/v1/thing/19", + "handler": "ThingController.action19", + "file_path": "app/api/thing_19.py", + "line": 157, + "auth_required": false, + "rate_limited": false + } + ], + "data_flows": [ + { + "source": "request.args['q']", + "path": [ + { + "file_path": "app/search/index.ts", + "line": 20, + "component": "step_0_a", + "operation": "read" + }, + { + "file_path": "app/util/b.py", + "line": 40, + "component": "step_0_b", + "operation": "write" + } + ], + "sink": "cursor.execute", + "sanitized": false, + "files": [ + "app/search/index.ts", + "app/db/raw.py", + "app/util/a.py", + "app/util/b.py" + ] + }, + { + "source": "request.cookies['session']", + "path": [ + { + "file_path": "app/auth/session_store.py", + "line": 21, + "component": "step_1_a", + "operation": "read" + }, + { + "file_path": "app/auth/session_store.py", + "line": 41, + "component": "step_1_b", + "operation": "write" + } + ], + "sink": "redis.set", + "sanitized": true, + "files": [ + "app/auth/session_store.py" + ] + }, + { + "source": "request.json['url']", + "path": [ + { + "file_path": "app/integrations/webhooks.go", + "line": 22, + "component": "step_2_a", + "operation": "read" + }, + { + "file_path": "app/net/client.go", + "line": 42, + "component": "step_2_b", + "operation": "write" + } + ], + "sink": "requests.get", + "sanitized": false, + "files": [ + "app/integrations/webhooks.go", + "app/net/client.go" + ] + }, + { + "source": "form['password']", + "path": [ + { + "file_path": "app/auth/service.py", + "line": 23, + "component": "step_3_a", + "operation": "read" + }, + { + "file_path": "app/obs/telemetry.go", + "line": 43, + "component": "step_3_b", + "operation": "write" + } + ], + "sink": "logger.info", + "sanitized": false, + "files": [ + "app/auth/service.py", + "app/obs/telemetry.go" + ] + }, + { + "source": "header['Authorization']", + "path": [ + { + "file_path": "app/common/jwt_tools.py", + "line": 24, + "component": "step_4_a", + "operation": "read" + }, + { + "file_path": "app/common/jwt_tools.py", + "line": 44, + "component": "step_4_b", + "operation": "write" + } + ], + "sink": "jwt.decode", + "sanitized": true, + "files": [ + "app/common/jwt_tools.py" + ] + }, + { + "source": "query['email']", + "path": [ + { + "file_path": "app/notify/mailer.rb", + "line": 25, + "component": "step_5_a", + "operation": "read" + }, + { + "file_path": "app/notify/mailer.rb", + "line": 45, + "component": "step_5_b", + "operation": "write" + } + ], + "sink": "smtp.send", + "sanitized": false, + "files": [ + "app/notify/mailer.rb" + ] + }, + { + "source": "body['card_number']", + "path": [ + { + "file_path": "app/billing/payments.go", + "line": 26, + "component": "step_6_a", + "operation": "read" + }, + { + "file_path": "app/billing/core.py", + "line": 46, + "component": "step_6_b", + "operation": "write" + } + ], + "sink": "stripe.Charge.create", + "sanitized": true, + "files": [ + "app/billing/payments.go", + "app/billing/core.py" + ] + }, + { + "source": "path_param['id']", + "path": [ + { + "file_path": "app/api/thing_1.py", + "line": 27, + "component": "step_7_a", + "operation": "read" + }, + { + "file_path": "app/api/thing_1.py", + "line": 47, + "component": "step_7_b", + "operation": "write" + } + ], + "sink": "orm.filter", + "sanitized": true, + "files": [ + "app/api/thing_1.py" + ] + }, + { + "source": "upload.filename", + "path": [ + { + "file_path": "app/media/uploads.py", + "line": 28, + "component": "step_8_a", + "operation": "read" + }, + { + "file_path": "app/x/w.py", + "line": 48, + "component": "step_8_b", + "operation": "write" + } + ], + "sink": "open", + "sanitized": false, + "files": [ + "app/media/uploads.py", + "app/common/cache.py", + "app/common/guard.py", + "app/x/y.py", + "app/x/z.py", + "app/x/w.py" + ] + }, + { + "source": "env['DEBUG']", + "path": [ + { + "file_path": "app/web/render.py", + "line": 29, + "component": "step_9_a", + "operation": "read" + }, + { + "file_path": "app/web/render.py", + "line": 49, + "component": "step_9_b", + "operation": "write" + } + ], + "sink": "template.render", + "sanitized": false, + "files": [ + "app/web/render.py" + ] + }, + { + "source": "request.body", + "path": [ + { + "file_path": "app/admin/views.py", + "line": 30, + "component": "step_10_a", + "operation": "read" + }, + { + "file_path": "app/admin/views.py", + "line": 50, + "component": "step_10_b", + "operation": "write" + } + ], + "sink": "eval", + "sanitized": false, + "files": [ + "app/admin/views.py" + ] + }, + { + "source": "websocket.msg", + "path": [ + { + "file_path": "app/ws/handler.py", + "line": 31, + "component": "step_11_a", + "operation": "read" + }, + { + "file_path": "app/ws/exec.py", + "line": 51, + "component": "step_11_b", + "operation": "write" + } + ], + "sink": "subprocess.run", + "sanitized": false, + "files": [ + "app/ws/handler.py", + "app/ws/exec.py" + ] + }, + { + "source": "cli.argv", + "path": [ + { + "file_path": "tools/run.py", + "line": 32, + "component": "step_12_a", + "operation": "read" + }, + { + "file_path": "tools/run.py", + "line": 52, + "component": "step_12_b", + "operation": "write" + } + ], + "sink": "os.system", + "sanitized": false, + "files": [ + "tools/run.py" + ] + }, + { + "source": "queue.payload", + "path": [ + { + "file_path": "app/worker/task.py", + "line": 33, + "component": "step_13_a", + "operation": "read" + }, + { + "file_path": "app/worker/task.py", + "line": 53, + "component": "step_13_b", + "operation": "write" + } + ], + "sink": "pickle.loads", + "sanitized": false, + "files": [ + "app/worker/task.py" + ] + }, + { + "source": "request.args['redirect']", + "path": [ + { + "file_path": "app/web/redirect.py", + "line": 34, + "component": "step_14_a", + "operation": "read" + }, + { + "file_path": "app/web/redirect.py", + "line": 54, + "component": "step_14_b", + "operation": "write" + } + ], + "sink": "HttpResponseRedirect", + "sanitized": false, + "files": [ + "app/web/redirect.py" + ] + }, + { + "source": "session['role']", + "path": [ + { + "file_path": "app/auth/permissions.py", + "line": 35, + "component": "step_15_a", + "operation": "read" + }, + { + "file_path": "app/auth/rbac.py", + "line": 55, + "component": "step_15_b", + "operation": "write" + } + ], + "sink": "permission_check", + "sanitized": true, + "files": [ + "app/auth/permissions.py", + "app/auth/rbac.py" + ] + }, + { + "source": "request.files['avatar']", + "path": [ + { + "file_path": "app/media/uploads.py", + "line": 36, + "component": "step_16_a", + "operation": "read" + }, + { + "file_path": "app/media/uploads.py", + "line": 56, + "component": "step_16_b", + "operation": "write" + } + ], + "sink": "s3.put_object", + "sanitized": true, + "files": [ + "app/media/uploads.py" + ] + }, + { + "source": "token", + "path": [ + { + "file_path": "app/common/cache.py", + "line": 37, + "component": "step_17_a", + "operation": "read" + }, + { + "file_path": "app/common/cache.py", + "line": 57, + "component": "step_17_b", + "operation": "write" + } + ], + "sink": "cache.set", + "sanitized": true, + "files": [ + "app/common/cache.py" + ] + }, + { + "source": "request.headers['X-User-Phone']", + "path": [ + { + "file_path": "app/obs/audit.py", + "line": 38, + "component": "step_18_a", + "operation": "read" + }, + { + "file_path": "app/obs/audit.py", + "line": 58, + "component": "step_18_b", + "operation": "write" + } + ], + "sink": "audit_log", + "sanitized": false, + "files": [ + "app/obs/audit.py" + ] + }, + { + "source": "graphql.variables", + "path": [ + { + "file_path": "app/api/graphql.ts", + "line": 39, + "component": "step_19_a", + "operation": "read" + }, + { + "file_path": "app/api/graphql.ts", + "line": 59, + "component": "step_19_b", + "operation": "write" + } + ], + "sink": "db.query", + "sanitized": false, + "files": [ + "app/api/graphql.ts" + ] + } + ] +} \ No newline at end of file diff --git a/go/internal/agents/hunt/testdata/golden/recon_context_block_business_logic_empty.txt b/go/internal/agents/hunt/testdata/golden/recon_context_block_business_logic_empty.txt new file mode 100644 index 0000000..21f92b5 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/recon_context_block_business_logic_empty.txt @@ -0,0 +1,10 @@ +{ + "app_type": null, + "frameworks": [], + "languages": [], + "auth_model": "session", + "auth_details": "cookie", + "entry_points": [], + "api_surface": [], + "data_flows": [] +} \ No newline at end of file diff --git a/go/internal/agents/hunt/testdata/golden/recon_context_block_dos.txt b/go/internal/agents/hunt/testdata/golden/recon_context_block_dos.txt new file mode 100644 index 0000000..a5c108b --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/recon_context_block_dos.txt @@ -0,0 +1,346 @@ +{ + "app_type": "web_api", + "auth_model": "jwt", + "frameworks": [ + "Django", + "next.js", + "Spring Boot", + " React ", + "unknown-fw", + "NEXT", + "Vue" + ], + "languages": [ + "Python", + "JavaScript", + "Go", + "Ruby", + "Rust", + "python" + ], + "entry_points": [ + { + "kind": "http", + "identifier": "handler_0", + "file_path": "app/entry/e0.py", + "line": 10, + "method": "GET", + "route": null, + "auth_required": true + }, + { + "kind": "HTTP", + "identifier": "handler_1", + "file_path": "app/entry/e1.py", + "line": 17, + "method": "POST", + "route": "/v1/resource/1", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "handler_2", + "file_path": "app/entry/e2.py", + "line": 24, + "method": null, + "route": "/v1/resource/2", + "auth_required": null + }, + { + "kind": "api", + "identifier": "handler_3", + "file_path": "app/entry/e3.py", + "line": 31, + "method": "PUT", + "route": null, + "auth_required": true + }, + { + "kind": "graphql", + "identifier": "handler_4", + "file_path": "app/entry/e4.py", + "line": 38, + "method": "GET", + "route": "/v1/resource/4", + "auth_required": false + }, + { + "kind": "rpc", + "identifier": "handler_5", + "file_path": "app/entry/e5.py", + "line": 45, + "method": "POST", + "route": "/v1/resource/5", + "auth_required": null + }, + { + "kind": "route", + "identifier": "handler_6", + "file_path": "app/entry/e6.py", + "line": 52, + "method": null, + "route": null, + "auth_required": true + }, + { + "kind": "cron", + "identifier": "handler_7", + "file_path": "app/entry/e7.py", + "line": 59, + "method": "PUT", + "route": "/v1/resource/7", + "auth_required": false + }, + { + "kind": "queue", + "identifier": "handler_8", + "file_path": "app/entry/e8.py", + "line": 66, + "method": "GET", + "route": "/v1/resource/8", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_9", + "file_path": "app/entry/e9.py", + "line": 73, + "method": "POST", + "route": null, + "auth_required": true + } + ], + "data_flows": [ + { + "source": "request.args['q']", + "path": [ + { + "file_path": "app/search/index.ts", + "line": 20, + "component": "step_0_a", + "operation": "read" + }, + { + "file_path": "app/util/b.py", + "line": 40, + "component": "step_0_b", + "operation": "write" + } + ], + "sink": "cursor.execute", + "sanitized": false, + "files": [ + "app/search/index.ts", + "app/db/raw.py", + "app/util/a.py", + "app/util/b.py" + ] + }, + { + "source": "request.cookies['session']", + "path": [ + { + "file_path": "app/auth/session_store.py", + "line": 21, + "component": "step_1_a", + "operation": "read" + }, + { + "file_path": "app/auth/session_store.py", + "line": 41, + "component": "step_1_b", + "operation": "write" + } + ], + "sink": "redis.set", + "sanitized": true, + "files": [ + "app/auth/session_store.py" + ] + }, + { + "source": "request.json['url']", + "path": [ + { + "file_path": "app/integrations/webhooks.go", + "line": 22, + "component": "step_2_a", + "operation": "read" + }, + { + "file_path": "app/net/client.go", + "line": 42, + "component": "step_2_b", + "operation": "write" + } + ], + "sink": "requests.get", + "sanitized": false, + "files": [ + "app/integrations/webhooks.go", + "app/net/client.go" + ] + }, + { + "source": "form['password']", + "path": [ + { + "file_path": "app/auth/service.py", + "line": 23, + "component": "step_3_a", + "operation": "read" + }, + { + "file_path": "app/obs/telemetry.go", + "line": 43, + "component": "step_3_b", + "operation": "write" + } + ], + "sink": "logger.info", + "sanitized": false, + "files": [ + "app/auth/service.py", + "app/obs/telemetry.go" + ] + }, + { + "source": "header['Authorization']", + "path": [ + { + "file_path": "app/common/jwt_tools.py", + "line": 24, + "component": "step_4_a", + "operation": "read" + }, + { + "file_path": "app/common/jwt_tools.py", + "line": 44, + "component": "step_4_b", + "operation": "write" + } + ], + "sink": "jwt.decode", + "sanitized": true, + "files": [ + "app/common/jwt_tools.py" + ] + }, + { + "source": "query['email']", + "path": [ + { + "file_path": "app/notify/mailer.rb", + "line": 25, + "component": "step_5_a", + "operation": "read" + }, + { + "file_path": "app/notify/mailer.rb", + "line": 45, + "component": "step_5_b", + "operation": "write" + } + ], + "sink": "smtp.send", + "sanitized": false, + "files": [ + "app/notify/mailer.rb" + ] + }, + { + "source": "body['card_number']", + "path": [ + { + "file_path": "app/billing/payments.go", + "line": 26, + "component": "step_6_a", + "operation": "read" + }, + { + "file_path": "app/billing/core.py", + "line": 46, + "component": "step_6_b", + "operation": "write" + } + ], + "sink": "stripe.Charge.create", + "sanitized": true, + "files": [ + "app/billing/payments.go", + "app/billing/core.py" + ] + }, + { + "source": "path_param['id']", + "path": [ + { + "file_path": "app/api/thing_1.py", + "line": 27, + "component": "step_7_a", + "operation": "read" + }, + { + "file_path": "app/api/thing_1.py", + "line": 47, + "component": "step_7_b", + "operation": "write" + } + ], + "sink": "orm.filter", + "sanitized": true, + "files": [ + "app/api/thing_1.py" + ] + }, + { + "source": "upload.filename", + "path": [ + { + "file_path": "app/media/uploads.py", + "line": 28, + "component": "step_8_a", + "operation": "read" + }, + { + "file_path": "app/x/w.py", + "line": 48, + "component": "step_8_b", + "operation": "write" + } + ], + "sink": "open", + "sanitized": false, + "files": [ + "app/media/uploads.py", + "app/common/cache.py", + "app/common/guard.py", + "app/x/y.py", + "app/x/z.py", + "app/x/w.py" + ] + }, + { + "source": "env['DEBUG']", + "path": [ + { + "file_path": "app/web/render.py", + "line": 29, + "component": "step_9_a", + "operation": "read" + }, + { + "file_path": "app/web/render.py", + "line": 49, + "component": "step_9_b", + "operation": "write" + } + ], + "sink": "template.render", + "sanitized": false, + "files": [ + "app/web/render.py" + ] + } + ] +} \ No newline at end of file diff --git a/go/internal/agents/hunt/testdata/golden/recon_context_block_dos_empty.txt b/go/internal/agents/hunt/testdata/golden/recon_context_block_dos_empty.txt new file mode 100644 index 0000000..bf01954 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/recon_context_block_dos_empty.txt @@ -0,0 +1,8 @@ +{ + "app_type": null, + "auth_model": "session", + "frameworks": [], + "languages": [], + "entry_points": [], + "data_flows": [] +} \ No newline at end of file diff --git a/go/internal/agents/hunt/testdata/golden/recon_context_block_ssrf.txt b/go/internal/agents/hunt/testdata/golden/recon_context_block_ssrf.txt new file mode 100644 index 0000000..a5c108b --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/recon_context_block_ssrf.txt @@ -0,0 +1,346 @@ +{ + "app_type": "web_api", + "auth_model": "jwt", + "frameworks": [ + "Django", + "next.js", + "Spring Boot", + " React ", + "unknown-fw", + "NEXT", + "Vue" + ], + "languages": [ + "Python", + "JavaScript", + "Go", + "Ruby", + "Rust", + "python" + ], + "entry_points": [ + { + "kind": "http", + "identifier": "handler_0", + "file_path": "app/entry/e0.py", + "line": 10, + "method": "GET", + "route": null, + "auth_required": true + }, + { + "kind": "HTTP", + "identifier": "handler_1", + "file_path": "app/entry/e1.py", + "line": 17, + "method": "POST", + "route": "/v1/resource/1", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "handler_2", + "file_path": "app/entry/e2.py", + "line": 24, + "method": null, + "route": "/v1/resource/2", + "auth_required": null + }, + { + "kind": "api", + "identifier": "handler_3", + "file_path": "app/entry/e3.py", + "line": 31, + "method": "PUT", + "route": null, + "auth_required": true + }, + { + "kind": "graphql", + "identifier": "handler_4", + "file_path": "app/entry/e4.py", + "line": 38, + "method": "GET", + "route": "/v1/resource/4", + "auth_required": false + }, + { + "kind": "rpc", + "identifier": "handler_5", + "file_path": "app/entry/e5.py", + "line": 45, + "method": "POST", + "route": "/v1/resource/5", + "auth_required": null + }, + { + "kind": "route", + "identifier": "handler_6", + "file_path": "app/entry/e6.py", + "line": 52, + "method": null, + "route": null, + "auth_required": true + }, + { + "kind": "cron", + "identifier": "handler_7", + "file_path": "app/entry/e7.py", + "line": 59, + "method": "PUT", + "route": "/v1/resource/7", + "auth_required": false + }, + { + "kind": "queue", + "identifier": "handler_8", + "file_path": "app/entry/e8.py", + "line": 66, + "method": "GET", + "route": "/v1/resource/8", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_9", + "file_path": "app/entry/e9.py", + "line": 73, + "method": "POST", + "route": null, + "auth_required": true + } + ], + "data_flows": [ + { + "source": "request.args['q']", + "path": [ + { + "file_path": "app/search/index.ts", + "line": 20, + "component": "step_0_a", + "operation": "read" + }, + { + "file_path": "app/util/b.py", + "line": 40, + "component": "step_0_b", + "operation": "write" + } + ], + "sink": "cursor.execute", + "sanitized": false, + "files": [ + "app/search/index.ts", + "app/db/raw.py", + "app/util/a.py", + "app/util/b.py" + ] + }, + { + "source": "request.cookies['session']", + "path": [ + { + "file_path": "app/auth/session_store.py", + "line": 21, + "component": "step_1_a", + "operation": "read" + }, + { + "file_path": "app/auth/session_store.py", + "line": 41, + "component": "step_1_b", + "operation": "write" + } + ], + "sink": "redis.set", + "sanitized": true, + "files": [ + "app/auth/session_store.py" + ] + }, + { + "source": "request.json['url']", + "path": [ + { + "file_path": "app/integrations/webhooks.go", + "line": 22, + "component": "step_2_a", + "operation": "read" + }, + { + "file_path": "app/net/client.go", + "line": 42, + "component": "step_2_b", + "operation": "write" + } + ], + "sink": "requests.get", + "sanitized": false, + "files": [ + "app/integrations/webhooks.go", + "app/net/client.go" + ] + }, + { + "source": "form['password']", + "path": [ + { + "file_path": "app/auth/service.py", + "line": 23, + "component": "step_3_a", + "operation": "read" + }, + { + "file_path": "app/obs/telemetry.go", + "line": 43, + "component": "step_3_b", + "operation": "write" + } + ], + "sink": "logger.info", + "sanitized": false, + "files": [ + "app/auth/service.py", + "app/obs/telemetry.go" + ] + }, + { + "source": "header['Authorization']", + "path": [ + { + "file_path": "app/common/jwt_tools.py", + "line": 24, + "component": "step_4_a", + "operation": "read" + }, + { + "file_path": "app/common/jwt_tools.py", + "line": 44, + "component": "step_4_b", + "operation": "write" + } + ], + "sink": "jwt.decode", + "sanitized": true, + "files": [ + "app/common/jwt_tools.py" + ] + }, + { + "source": "query['email']", + "path": [ + { + "file_path": "app/notify/mailer.rb", + "line": 25, + "component": "step_5_a", + "operation": "read" + }, + { + "file_path": "app/notify/mailer.rb", + "line": 45, + "component": "step_5_b", + "operation": "write" + } + ], + "sink": "smtp.send", + "sanitized": false, + "files": [ + "app/notify/mailer.rb" + ] + }, + { + "source": "body['card_number']", + "path": [ + { + "file_path": "app/billing/payments.go", + "line": 26, + "component": "step_6_a", + "operation": "read" + }, + { + "file_path": "app/billing/core.py", + "line": 46, + "component": "step_6_b", + "operation": "write" + } + ], + "sink": "stripe.Charge.create", + "sanitized": true, + "files": [ + "app/billing/payments.go", + "app/billing/core.py" + ] + }, + { + "source": "path_param['id']", + "path": [ + { + "file_path": "app/api/thing_1.py", + "line": 27, + "component": "step_7_a", + "operation": "read" + }, + { + "file_path": "app/api/thing_1.py", + "line": 47, + "component": "step_7_b", + "operation": "write" + } + ], + "sink": "orm.filter", + "sanitized": true, + "files": [ + "app/api/thing_1.py" + ] + }, + { + "source": "upload.filename", + "path": [ + { + "file_path": "app/media/uploads.py", + "line": 28, + "component": "step_8_a", + "operation": "read" + }, + { + "file_path": "app/x/w.py", + "line": 48, + "component": "step_8_b", + "operation": "write" + } + ], + "sink": "open", + "sanitized": false, + "files": [ + "app/media/uploads.py", + "app/common/cache.py", + "app/common/guard.py", + "app/x/y.py", + "app/x/z.py", + "app/x/w.py" + ] + }, + { + "source": "env['DEBUG']", + "path": [ + { + "file_path": "app/web/render.py", + "line": 29, + "component": "step_9_a", + "operation": "read" + }, + { + "file_path": "app/web/render.py", + "line": 49, + "component": "step_9_b", + "operation": "write" + } + ], + "sink": "template.render", + "sanitized": false, + "files": [ + "app/web/render.py" + ] + } + ] +} \ No newline at end of file diff --git a/go/internal/agents/hunt/testdata/golden/recon_context_block_xss.txt b/go/internal/agents/hunt/testdata/golden/recon_context_block_xss.txt new file mode 100644 index 0000000..a5c108b --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/recon_context_block_xss.txt @@ -0,0 +1,346 @@ +{ + "app_type": "web_api", + "auth_model": "jwt", + "frameworks": [ + "Django", + "next.js", + "Spring Boot", + " React ", + "unknown-fw", + "NEXT", + "Vue" + ], + "languages": [ + "Python", + "JavaScript", + "Go", + "Ruby", + "Rust", + "python" + ], + "entry_points": [ + { + "kind": "http", + "identifier": "handler_0", + "file_path": "app/entry/e0.py", + "line": 10, + "method": "GET", + "route": null, + "auth_required": true + }, + { + "kind": "HTTP", + "identifier": "handler_1", + "file_path": "app/entry/e1.py", + "line": 17, + "method": "POST", + "route": "/v1/resource/1", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "handler_2", + "file_path": "app/entry/e2.py", + "line": 24, + "method": null, + "route": "/v1/resource/2", + "auth_required": null + }, + { + "kind": "api", + "identifier": "handler_3", + "file_path": "app/entry/e3.py", + "line": 31, + "method": "PUT", + "route": null, + "auth_required": true + }, + { + "kind": "graphql", + "identifier": "handler_4", + "file_path": "app/entry/e4.py", + "line": 38, + "method": "GET", + "route": "/v1/resource/4", + "auth_required": false + }, + { + "kind": "rpc", + "identifier": "handler_5", + "file_path": "app/entry/e5.py", + "line": 45, + "method": "POST", + "route": "/v1/resource/5", + "auth_required": null + }, + { + "kind": "route", + "identifier": "handler_6", + "file_path": "app/entry/e6.py", + "line": 52, + "method": null, + "route": null, + "auth_required": true + }, + { + "kind": "cron", + "identifier": "handler_7", + "file_path": "app/entry/e7.py", + "line": 59, + "method": "PUT", + "route": "/v1/resource/7", + "auth_required": false + }, + { + "kind": "queue", + "identifier": "handler_8", + "file_path": "app/entry/e8.py", + "line": 66, + "method": "GET", + "route": "/v1/resource/8", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_9", + "file_path": "app/entry/e9.py", + "line": 73, + "method": "POST", + "route": null, + "auth_required": true + } + ], + "data_flows": [ + { + "source": "request.args['q']", + "path": [ + { + "file_path": "app/search/index.ts", + "line": 20, + "component": "step_0_a", + "operation": "read" + }, + { + "file_path": "app/util/b.py", + "line": 40, + "component": "step_0_b", + "operation": "write" + } + ], + "sink": "cursor.execute", + "sanitized": false, + "files": [ + "app/search/index.ts", + "app/db/raw.py", + "app/util/a.py", + "app/util/b.py" + ] + }, + { + "source": "request.cookies['session']", + "path": [ + { + "file_path": "app/auth/session_store.py", + "line": 21, + "component": "step_1_a", + "operation": "read" + }, + { + "file_path": "app/auth/session_store.py", + "line": 41, + "component": "step_1_b", + "operation": "write" + } + ], + "sink": "redis.set", + "sanitized": true, + "files": [ + "app/auth/session_store.py" + ] + }, + { + "source": "request.json['url']", + "path": [ + { + "file_path": "app/integrations/webhooks.go", + "line": 22, + "component": "step_2_a", + "operation": "read" + }, + { + "file_path": "app/net/client.go", + "line": 42, + "component": "step_2_b", + "operation": "write" + } + ], + "sink": "requests.get", + "sanitized": false, + "files": [ + "app/integrations/webhooks.go", + "app/net/client.go" + ] + }, + { + "source": "form['password']", + "path": [ + { + "file_path": "app/auth/service.py", + "line": 23, + "component": "step_3_a", + "operation": "read" + }, + { + "file_path": "app/obs/telemetry.go", + "line": 43, + "component": "step_3_b", + "operation": "write" + } + ], + "sink": "logger.info", + "sanitized": false, + "files": [ + "app/auth/service.py", + "app/obs/telemetry.go" + ] + }, + { + "source": "header['Authorization']", + "path": [ + { + "file_path": "app/common/jwt_tools.py", + "line": 24, + "component": "step_4_a", + "operation": "read" + }, + { + "file_path": "app/common/jwt_tools.py", + "line": 44, + "component": "step_4_b", + "operation": "write" + } + ], + "sink": "jwt.decode", + "sanitized": true, + "files": [ + "app/common/jwt_tools.py" + ] + }, + { + "source": "query['email']", + "path": [ + { + "file_path": "app/notify/mailer.rb", + "line": 25, + "component": "step_5_a", + "operation": "read" + }, + { + "file_path": "app/notify/mailer.rb", + "line": 45, + "component": "step_5_b", + "operation": "write" + } + ], + "sink": "smtp.send", + "sanitized": false, + "files": [ + "app/notify/mailer.rb" + ] + }, + { + "source": "body['card_number']", + "path": [ + { + "file_path": "app/billing/payments.go", + "line": 26, + "component": "step_6_a", + "operation": "read" + }, + { + "file_path": "app/billing/core.py", + "line": 46, + "component": "step_6_b", + "operation": "write" + } + ], + "sink": "stripe.Charge.create", + "sanitized": true, + "files": [ + "app/billing/payments.go", + "app/billing/core.py" + ] + }, + { + "source": "path_param['id']", + "path": [ + { + "file_path": "app/api/thing_1.py", + "line": 27, + "component": "step_7_a", + "operation": "read" + }, + { + "file_path": "app/api/thing_1.py", + "line": 47, + "component": "step_7_b", + "operation": "write" + } + ], + "sink": "orm.filter", + "sanitized": true, + "files": [ + "app/api/thing_1.py" + ] + }, + { + "source": "upload.filename", + "path": [ + { + "file_path": "app/media/uploads.py", + "line": 28, + "component": "step_8_a", + "operation": "read" + }, + { + "file_path": "app/x/w.py", + "line": 48, + "component": "step_8_b", + "operation": "write" + } + ], + "sink": "open", + "sanitized": false, + "files": [ + "app/media/uploads.py", + "app/common/cache.py", + "app/common/guard.py", + "app/x/y.py", + "app/x/z.py", + "app/x/w.py" + ] + }, + { + "source": "env['DEBUG']", + "path": [ + { + "file_path": "app/web/render.py", + "line": 29, + "component": "step_9_a", + "operation": "read" + }, + { + "file_path": "app/web/render.py", + "line": 49, + "component": "step_9_b", + "operation": "write" + } + ], + "sink": "template.render", + "sanitized": false, + "files": [ + "app/web/render.py" + ] + } + ] +} \ No newline at end of file diff --git a/go/internal/agents/hunt/testdata/golden/scan_locations_input.json b/go/internal/agents/hunt/testdata/golden/scan_locations_input.json new file mode 100644 index 0000000..4c2e802 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/scan_locations_input.json @@ -0,0 +1,3 @@ +{ + "hunter_prompt": "HUNTER PROMPT BODY\nwith {{braces}} and & caf\u00e9 \u2192\n" +} diff --git a/go/internal/agents/hunt/testdata/golden/scan_locations_prompt.txt b/go/internal/agents/hunt/testdata/golden/scan_locations_prompt.txt new file mode 100644 index 0000000..828e313 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/scan_locations_prompt.txt @@ -0,0 +1,22 @@ +ROLE: +You are Step 1 LocationScanner for SEC-AF HUNT phase. + +TASK: +Find only plausible vulnerable code locations. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate the locations array with entries only. +- Each location must include file_path, start_line, code_snippet, and pattern_type. +- Keep results focused and deduplicated by location. + +CONSTRAINTS: +- Scan broadly and prioritize source-to-sink candidates. +- Do not enrich with severity, confidence, exploitability, or remediation details. +- If no credible locations exist, return an empty locations list. +- Do not include markdown or prose outside JSON. + +HUNTER CONTEXT: +HUNTER PROMPT BODY +with {{braces}} and & café → + diff --git a/go/internal/agents/hunt/testdata/golden/select_strategies.json b/go/internal/agents/hunt/testdata/golden/select_strategies.json new file mode 100644 index 0000000..d699735 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/select_strategies.json @@ -0,0 +1,94 @@ +{ + "quick": [ + "injection", + "dos", + "ssrf", + "auth", + "data_exposure" + ], + "QUICK": [ + "injection", + "dos", + "ssrf", + "auth", + "data_exposure" + ], + " quick": [ + "injection", + "xss", + "dos", + "ssrf", + "auth", + "crypto", + "business_logic", + "data_exposure", + "supply_chain", + "config_secrets", + "api_security" + ], + "standard": [ + "injection", + "xss", + "dos", + "ssrf", + "auth", + "crypto", + "business_logic", + "data_exposure", + "supply_chain", + "config_secrets", + "api_security" + ], + "thorough": [ + "injection", + "xss", + "dos", + "ssrf", + "auth", + "crypto", + "business_logic", + "data_exposure", + "supply_chain", + "config_secrets", + "api_security" + ], + "Thorough": [ + "injection", + "xss", + "dos", + "ssrf", + "auth", + "crypto", + "business_logic", + "data_exposure", + "supply_chain", + "config_secrets", + "api_security" + ], + "bogus": [ + "injection", + "xss", + "dos", + "ssrf", + "auth", + "crypto", + "business_logic", + "data_exposure", + "supply_chain", + "config_secrets", + "api_security" + ], + "": [ + "injection", + "xss", + "dos", + "ssrf", + "auth", + "crypto", + "business_logic", + "data_exposure", + "supply_chain", + "config_secrets", + "api_security" + ] +} diff --git a/go/internal/agents/hunt/testdata/golden/strategy_runner_order.json b/go/internal/agents/hunt/testdata/golden/strategy_runner_order.json new file mode 100644 index 0000000..3f55a88 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/strategy_runner_order.json @@ -0,0 +1,13 @@ +[ + "injection", + "xss", + "dos", + "ssrf", + "auth", + "crypto", + "business_logic", + "data_exposure", + "supply_chain", + "config_secrets", + "api_security" +] diff --git a/go/internal/agents/hunt/testdata/recon_fixture.json b/go/internal/agents/hunt/testdata/recon_fixture.json new file mode 100644 index 0000000..bf916bb --- /dev/null +++ b/go/internal/agents/hunt/testdata/recon_fixture.json @@ -0,0 +1,2189 @@ +{ + "architecture": { + "app_type": "web_api", + "modules": [ + { + "name": "auth_service", + "path": "app/auth/service.py", + "language": "python", + "description": "Session and JWT issuance", + "dependencies": [ + "jwt", + "redis" + ] + }, + { + "name": "rbac", + "path": "app/auth/rbac.py", + "language": "python", + "description": null, + "dependencies": [] + }, + { + "name": "billing", + "path": "app/billing/core.py", + "language": "python", + "description": "Invoice state machine — handles refunds", + "dependencies": [ + "stripe" + ] + }, + { + "name": "csrf_guard", + "path": "app/web/middleware/csrf.js", + "language": "javascript", + "description": "CSRF middleware", + "dependencies": [] + }, + { + "name": "payments", + "path": "app/billing/payments.go", + "language": "go", + "description": "Charge orchestration", + "dependencies": [ + "stripe-go" + ] + }, + { + "name": "reporting", + "path": "app/reports/render.py", + "language": "python", + "description": "PDF and CSV export", + "dependencies": [ + "weasyprint" + ] + }, + { + "name": "search", + "path": "app/search/index.ts", + "language": "typescript", + "description": "Elasticsearch query builder", + "dependencies": [ + "@elastic/elasticsearch" + ] + }, + { + "name": "session_store", + "path": "app/auth/session_store.py", + "language": "python", + "description": "Redis-backed sessions", + "dependencies": [ + "redis" + ] + }, + { + "name": "notifications", + "path": "app/notify/mailer.rb", + "language": "ruby", + "description": null, + "dependencies": [ + "mail" + ] + }, + { + "name": "permission_matrix", + "path": "app/auth/permissions.py", + "language": "python", + "description": "Role → permission table", + "dependencies": [] + }, + { + "name": "uploads", + "path": "app/media/uploads.py", + "language": "python", + "description": "S3 multipart upload helper", + "dependencies": [ + "boto3" + ] + }, + { + "name": "webhooks", + "path": "app/integrations/webhooks.go", + "language": "go", + "description": "Outbound webhook dispatcher", + "dependencies": [] + }, + { + "name": "graph_api", + "path": "app/api/graphql.ts", + "language": "typescript", + "description": "GraphQL resolvers — naïve depth limit", + "dependencies": [ + "graphql" + ] + }, + { + "name": "admin_panel", + "path": "app/admin/views.py", + "language": "python", + "description": "Django admin overrides", + "dependencies": [ + "django" + ] + }, + { + "name": "guard_utils", + "path": "app/common/guard.py", + "language": "python", + "description": "Assorted guard helpers", + "dependencies": [] + }, + { + "name": "cache", + "path": "app/common/cache.py", + "language": "python", + "description": "Memoization wrappers", + "dependencies": [] + }, + { + "name": "migrations", + "path": "db/migrate/2024_add_roles.rb", + "language": "ruby", + "description": "Adds role column", + "dependencies": [] + }, + { + "name": "jwt_tools", + "path": "app/common/jwt_tools.py", + "language": "python", + "description": null, + "dependencies": [ + "pyjwt" + ] + }, + { + "name": "telemetry", + "path": "app/obs/telemetry.go", + "language": "go", + "description": "OTel exporter", + "dependencies": [] + } + ], + "entry_points": [ + { + "kind": "http", + "identifier": "handler_0", + "file_path": "app/entry/e0.py", + "line": 10, + "method": "GET", + "route": null, + "auth_required": true + }, + { + "kind": "HTTP", + "identifier": "handler_1", + "file_path": "app/entry/e1.py", + "line": 17, + "method": "POST", + "route": "/v1/resource/1", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "handler_2", + "file_path": "app/entry/e2.py", + "line": 24, + "method": null, + "route": "/v1/resource/2", + "auth_required": null + }, + { + "kind": "api", + "identifier": "handler_3", + "file_path": "app/entry/e3.py", + "line": 31, + "method": "PUT", + "route": null, + "auth_required": true + }, + { + "kind": "graphql", + "identifier": "handler_4", + "file_path": "app/entry/e4.py", + "line": 38, + "method": "GET", + "route": "/v1/resource/4", + "auth_required": false + }, + { + "kind": "rpc", + "identifier": "handler_5", + "file_path": "app/entry/e5.py", + "line": 45, + "method": "POST", + "route": "/v1/resource/5", + "auth_required": null + }, + { + "kind": "route", + "identifier": "handler_6", + "file_path": "app/entry/e6.py", + "line": 52, + "method": null, + "route": null, + "auth_required": true + }, + { + "kind": "cron", + "identifier": "handler_7", + "file_path": "app/entry/e7.py", + "line": 59, + "method": "PUT", + "route": "/v1/resource/7", + "auth_required": false + }, + { + "kind": "queue", + "identifier": "handler_8", + "file_path": "app/entry/e8.py", + "line": 66, + "method": "GET", + "route": "/v1/resource/8", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_9", + "file_path": "app/entry/e9.py", + "line": 73, + "method": "POST", + "route": null, + "auth_required": true + }, + { + "kind": "api", + "identifier": "handler_10", + "file_path": "app/entry/e10.py", + "line": 80, + "method": null, + "route": "/v1/resource/10", + "auth_required": false + }, + { + "kind": "grpc", + "identifier": "handler_11", + "file_path": "app/entry/e11.py", + "line": 87, + "method": "PUT", + "route": "/v1/resource/11", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_12", + "file_path": "app/entry/e12.py", + "line": 94, + "method": "GET", + "route": null, + "auth_required": true + }, + { + "kind": "route", + "identifier": "handler_13", + "file_path": "app/entry/e13.py", + "line": 101, + "method": "POST", + "route": "/v1/resource/13", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "handler_14", + "file_path": "app/entry/e14.py", + "line": 108, + "method": null, + "route": "/v1/resource/14", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_15", + "file_path": "app/entry/e15.py", + "line": 115, + "method": "PUT", + "route": null, + "auth_required": true + }, + { + "kind": "websocket", + "identifier": "handler_16", + "file_path": "app/entry/e16.py", + "line": 122, + "method": "GET", + "route": "/v1/resource/16", + "auth_required": false + } + ], + "trust_boundaries": [ + { + "name": "boundary_0", + "source_zone": "internet", + "target_zone": "app", + "description": "Transition 0", + "enforcement": [] + }, + { + "name": "boundary_1", + "source_zone": "dmz", + "target_zone": "db", + "description": "Transition 1", + "enforcement": [ + "waf_1", + "mtls_1" + ] + }, + { + "name": "boundary_2", + "source_zone": "vpc", + "target_zone": "cache", + "description": "Transition 2", + "enforcement": [ + "waf_2", + "mtls_2" + ] + }, + { + "name": "boundary_3", + "source_zone": "worker", + "target_zone": "queue", + "description": "Transition 3", + "enforcement": [ + "waf_3", + "mtls_3" + ] + }, + { + "name": "boundary_4", + "source_zone": "internet", + "target_zone": "app", + "description": "Transition 4", + "enforcement": [] + }, + { + "name": "boundary_5", + "source_zone": "dmz", + "target_zone": "db", + "description": "Transition 5", + "enforcement": [ + "waf_5", + "mtls_5" + ] + }, + { + "name": "boundary_6", + "source_zone": "vpc", + "target_zone": "cache", + "description": "Transition 6", + "enforcement": [ + "waf_6", + "mtls_6" + ] + }, + { + "name": "boundary_7", + "source_zone": "worker", + "target_zone": "queue", + "description": "Transition 7", + "enforcement": [ + "waf_7", + "mtls_7" + ] + }, + { + "name": "boundary_8", + "source_zone": "internet", + "target_zone": "app", + "description": "Transition 8", + "enforcement": [] + }, + { + "name": "boundary_9", + "source_zone": "dmz", + "target_zone": "db", + "description": "Transition 9", + "enforcement": [ + "waf_9", + "mtls_9" + ] + }, + { + "name": "boundary_10", + "source_zone": "vpc", + "target_zone": "cache", + "description": "Transition 10", + "enforcement": [ + "waf_10", + "mtls_10" + ] + }, + { + "name": "boundary_11", + "source_zone": "worker", + "target_zone": "queue", + "description": "Transition 11", + "enforcement": [ + "waf_11", + "mtls_11" + ] + } + ], + "services": [ + { + "name": "postgres", + "service_type": "database", + "endpoint": "postgres://db:5432", + "purpose": "primary store", + "auth_mechanism": "password" + }, + { + "name": "redis", + "service_type": "cache", + "endpoint": null, + "purpose": "sessions", + "auth_mechanism": null + }, + { + "name": "stripe", + "service_type": "payments", + "endpoint": "https://api.stripe.com", + "purpose": null, + "auth_mechanism": "api_key" + }, + { + "name": "s3", + "service_type": "object_store", + "endpoint": "https://s3.amazonaws.com", + "purpose": "uploads", + "auth_mechanism": "iam" + }, + { + "name": "smtp", + "service_type": "mail", + "endpoint": null, + "purpose": "transactional email", + "auth_mechanism": null + } + ], + "api_surface": [ + { + "method": "GET", + "path": "/api/v1/thing/0", + "handler": "ThingController.action0", + "file_path": "app/api/thing_0.py", + "line": 100, + "auth_required": true, + "rate_limited": null + }, + { + "method": "POST", + "path": "/api/v1/thing/1", + "handler": "ThingController.action1", + "file_path": "app/api/thing_1.py", + "line": 103, + "auth_required": false, + "rate_limited": false + }, + { + "method": "PUT", + "path": "/api/v1/thing/2", + "handler": "ThingController.action2", + "file_path": "app/api/thing_2.py", + "line": 106, + "auth_required": null, + "rate_limited": true + }, + { + "method": "DELETE", + "path": "/api/v1/thing/3", + "handler": "ThingController.action3", + "file_path": "app/api/thing_3.py", + "line": 109, + "auth_required": false, + "rate_limited": false + }, + { + "method": "PATCH", + "path": "/api/v1/thing/4", + "handler": "ThingController.action4", + "file_path": "app/api/thing_4.py", + "line": 112, + "auth_required": true, + "rate_limited": null + }, + { + "method": "GET", + "path": "/api/v1/thing/5", + "handler": "ThingController.action5", + "file_path": "app/api/thing_5.py", + "line": 115, + "auth_required": null, + "rate_limited": true + }, + { + "method": "POST", + "path": "/api/v1/thing/6", + "handler": "ThingController.action6", + "file_path": "app/api/thing_6.py", + "line": 118, + "auth_required": true, + "rate_limited": null + }, + { + "method": "PUT", + "path": "/api/v1/thing/7", + "handler": "ThingController.action7", + "file_path": "app/api/thing_7.py", + "line": 121, + "auth_required": false, + "rate_limited": false + }, + { + "method": "DELETE", + "path": "/api/v1/thing/8", + "handler": "ThingController.action8", + "file_path": "app/api/thing_8.py", + "line": 124, + "auth_required": null, + "rate_limited": true + }, + { + "method": "PATCH", + "path": "/api/v1/thing/9", + "handler": "ThingController.action9", + "file_path": "app/api/thing_9.py", + "line": 127, + "auth_required": false, + "rate_limited": false + }, + { + "method": "GET", + "path": "/api/v1/thing/10", + "handler": "ThingController.action10", + "file_path": "app/api/thing_10.py", + "line": 130, + "auth_required": true, + "rate_limited": null + }, + { + "method": "POST", + "path": "/api/v1/thing/11", + "handler": "ThingController.action11", + "file_path": "app/api/thing_11.py", + "line": 133, + "auth_required": null, + "rate_limited": true + }, + { + "method": "PUT", + "path": "/api/v1/thing/12", + "handler": "ThingController.action12", + "file_path": "app/api/thing_12.py", + "line": 136, + "auth_required": true, + "rate_limited": null + }, + { + "method": "DELETE", + "path": "/api/v1/thing/13", + "handler": "ThingController.action13", + "file_path": "app/api/thing_13.py", + "line": 139, + "auth_required": false, + "rate_limited": false + }, + { + "method": "PATCH", + "path": "/api/v1/thing/14", + "handler": "ThingController.action14", + "file_path": "app/api/thing_14.py", + "line": 142, + "auth_required": null, + "rate_limited": true + }, + { + "method": "GET", + "path": "/api/v1/thing/15", + "handler": "ThingController.action15", + "file_path": "app/api/thing_15.py", + "line": 145, + "auth_required": false, + "rate_limited": false + }, + { + "method": "POST", + "path": "/api/v1/thing/16", + "handler": "ThingController.action16", + "file_path": "app/api/thing_16.py", + "line": 148, + "auth_required": true, + "rate_limited": null + }, + { + "method": "PUT", + "path": "/api/v1/thing/17", + "handler": "ThingController.action17", + "file_path": "app/api/thing_17.py", + "line": 151, + "auth_required": null, + "rate_limited": true + }, + { + "method": "DELETE", + "path": "/api/v1/thing/18", + "handler": "ThingController.action18", + "file_path": "app/api/thing_18.py", + "line": 154, + "auth_required": true, + "rate_limited": null + }, + { + "method": "PATCH", + "path": "/api/v1/thing/19", + "handler": "ThingController.action19", + "file_path": "app/api/thing_19.py", + "line": 157, + "auth_required": false, + "rate_limited": false + } + ] + }, + "data_flows": { + "flows": [ + { + "source": "request.args['q']", + "path": [ + { + "file_path": "app/search/index.ts", + "line": 20, + "component": "step_0_a", + "operation": "read" + }, + { + "file_path": "app/util/b.py", + "line": 40, + "component": "step_0_b", + "operation": "write" + } + ], + "sink": "cursor.execute", + "sanitized": false, + "files": [ + "app/search/index.ts", + "app/db/raw.py", + "app/util/a.py", + "app/util/b.py" + ] + }, + { + "source": "request.cookies['session']", + "path": [ + { + "file_path": "app/auth/session_store.py", + "line": 21, + "component": "step_1_a", + "operation": "read" + }, + { + "file_path": "app/auth/session_store.py", + "line": 41, + "component": "step_1_b", + "operation": "write" + } + ], + "sink": "redis.set", + "sanitized": true, + "files": [ + "app/auth/session_store.py" + ] + }, + { + "source": "request.json['url']", + "path": [ + { + "file_path": "app/integrations/webhooks.go", + "line": 22, + "component": "step_2_a", + "operation": "read" + }, + { + "file_path": "app/net/client.go", + "line": 42, + "component": "step_2_b", + "operation": "write" + } + ], + "sink": "requests.get", + "sanitized": false, + "files": [ + "app/integrations/webhooks.go", + "app/net/client.go" + ] + }, + { + "source": "form['password']", + "path": [ + { + "file_path": "app/auth/service.py", + "line": 23, + "component": "step_3_a", + "operation": "read" + }, + { + "file_path": "app/obs/telemetry.go", + "line": 43, + "component": "step_3_b", + "operation": "write" + } + ], + "sink": "logger.info", + "sanitized": false, + "files": [ + "app/auth/service.py", + "app/obs/telemetry.go" + ] + }, + { + "source": "header['Authorization']", + "path": [ + { + "file_path": "app/common/jwt_tools.py", + "line": 24, + "component": "step_4_a", + "operation": "read" + }, + { + "file_path": "app/common/jwt_tools.py", + "line": 44, + "component": "step_4_b", + "operation": "write" + } + ], + "sink": "jwt.decode", + "sanitized": true, + "files": [ + "app/common/jwt_tools.py" + ] + }, + { + "source": "query['email']", + "path": [ + { + "file_path": "app/notify/mailer.rb", + "line": 25, + "component": "step_5_a", + "operation": "read" + }, + { + "file_path": "app/notify/mailer.rb", + "line": 45, + "component": "step_5_b", + "operation": "write" + } + ], + "sink": "smtp.send", + "sanitized": false, + "files": [ + "app/notify/mailer.rb" + ] + }, + { + "source": "body['card_number']", + "path": [ + { + "file_path": "app/billing/payments.go", + "line": 26, + "component": "step_6_a", + "operation": "read" + }, + { + "file_path": "app/billing/core.py", + "line": 46, + "component": "step_6_b", + "operation": "write" + } + ], + "sink": "stripe.Charge.create", + "sanitized": true, + "files": [ + "app/billing/payments.go", + "app/billing/core.py" + ] + }, + { + "source": "path_param['id']", + "path": [ + { + "file_path": "app/api/thing_1.py", + "line": 27, + "component": "step_7_a", + "operation": "read" + }, + { + "file_path": "app/api/thing_1.py", + "line": 47, + "component": "step_7_b", + "operation": "write" + } + ], + "sink": "orm.filter", + "sanitized": true, + "files": [ + "app/api/thing_1.py" + ] + }, + { + "source": "upload.filename", + "path": [ + { + "file_path": "app/media/uploads.py", + "line": 28, + "component": "step_8_a", + "operation": "read" + }, + { + "file_path": "app/x/w.py", + "line": 48, + "component": "step_8_b", + "operation": "write" + } + ], + "sink": "open", + "sanitized": false, + "files": [ + "app/media/uploads.py", + "app/common/cache.py", + "app/common/guard.py", + "app/x/y.py", + "app/x/z.py", + "app/x/w.py" + ] + }, + { + "source": "env['DEBUG']", + "path": [ + { + "file_path": "app/web/render.py", + "line": 29, + "component": "step_9_a", + "operation": "read" + }, + { + "file_path": "app/web/render.py", + "line": 49, + "component": "step_9_b", + "operation": "write" + } + ], + "sink": "template.render", + "sanitized": false, + "files": [ + "app/web/render.py" + ] + }, + { + "source": "request.body", + "path": [ + { + "file_path": "app/admin/views.py", + "line": 30, + "component": "step_10_a", + "operation": "read" + }, + { + "file_path": "app/admin/views.py", + "line": 50, + "component": "step_10_b", + "operation": "write" + } + ], + "sink": "eval", + "sanitized": false, + "files": [ + "app/admin/views.py" + ] + }, + { + "source": "websocket.msg", + "path": [ + { + "file_path": "app/ws/handler.py", + "line": 31, + "component": "step_11_a", + "operation": "read" + }, + { + "file_path": "app/ws/exec.py", + "line": 51, + "component": "step_11_b", + "operation": "write" + } + ], + "sink": "subprocess.run", + "sanitized": false, + "files": [ + "app/ws/handler.py", + "app/ws/exec.py" + ] + }, + { + "source": "cli.argv", + "path": [ + { + "file_path": "tools/run.py", + "line": 32, + "component": "step_12_a", + "operation": "read" + }, + { + "file_path": "tools/run.py", + "line": 52, + "component": "step_12_b", + "operation": "write" + } + ], + "sink": "os.system", + "sanitized": false, + "files": [ + "tools/run.py" + ] + }, + { + "source": "queue.payload", + "path": [ + { + "file_path": "app/worker/task.py", + "line": 33, + "component": "step_13_a", + "operation": "read" + }, + { + "file_path": "app/worker/task.py", + "line": 53, + "component": "step_13_b", + "operation": "write" + } + ], + "sink": "pickle.loads", + "sanitized": false, + "files": [ + "app/worker/task.py" + ] + }, + { + "source": "request.args['redirect']", + "path": [ + { + "file_path": "app/web/redirect.py", + "line": 34, + "component": "step_14_a", + "operation": "read" + }, + { + "file_path": "app/web/redirect.py", + "line": 54, + "component": "step_14_b", + "operation": "write" + } + ], + "sink": "HttpResponseRedirect", + "sanitized": false, + "files": [ + "app/web/redirect.py" + ] + }, + { + "source": "session['role']", + "path": [ + { + "file_path": "app/auth/permissions.py", + "line": 35, + "component": "step_15_a", + "operation": "read" + }, + { + "file_path": "app/auth/rbac.py", + "line": 55, + "component": "step_15_b", + "operation": "write" + } + ], + "sink": "permission_check", + "sanitized": true, + "files": [ + "app/auth/permissions.py", + "app/auth/rbac.py" + ] + }, + { + "source": "request.files['avatar']", + "path": [ + { + "file_path": "app/media/uploads.py", + "line": 36, + "component": "step_16_a", + "operation": "read" + }, + { + "file_path": "app/media/uploads.py", + "line": 56, + "component": "step_16_b", + "operation": "write" + } + ], + "sink": "s3.put_object", + "sanitized": true, + "files": [ + "app/media/uploads.py" + ] + }, + { + "source": "token", + "path": [ + { + "file_path": "app/common/cache.py", + "line": 37, + "component": "step_17_a", + "operation": "read" + }, + { + "file_path": "app/common/cache.py", + "line": 57, + "component": "step_17_b", + "operation": "write" + } + ], + "sink": "cache.set", + "sanitized": true, + "files": [ + "app/common/cache.py" + ] + }, + { + "source": "request.headers['X-User-Phone']", + "path": [ + { + "file_path": "app/obs/audit.py", + "line": 38, + "component": "step_18_a", + "operation": "read" + }, + { + "file_path": "app/obs/audit.py", + "line": 58, + "component": "step_18_b", + "operation": "write" + } + ], + "sink": "audit_log", + "sanitized": false, + "files": [ + "app/obs/audit.py" + ] + }, + { + "source": "graphql.variables", + "path": [ + { + "file_path": "app/api/graphql.ts", + "line": 39, + "component": "step_19_a", + "operation": "read" + }, + { + "file_path": "app/api/graphql.ts", + "line": 59, + "component": "step_19_b", + "operation": "write" + } + ], + "sink": "db.query", + "sanitized": false, + "files": [ + "app/api/graphql.ts" + ] + } + ], + "sanitization_points": [ + { + "file_path": "app/sanitize/s0.py", + "line": 5, + "function_name": null, + "sanitization_type": "escape", + "protects_against": [] + }, + { + "file_path": "app/sanitize/s1.py", + "line": 9, + "function_name": "clean_1", + "sanitization_type": "parameterize", + "protects_against": [ + "CWE-80", + "CWE-90" + ] + }, + { + "file_path": "app/sanitize/s2.py", + "line": 13, + "function_name": "clean_2", + "sanitization_type": "allowlist", + "protects_against": [ + "CWE-81", + "CWE-91" + ] + }, + { + "file_path": "app/sanitize/s3.py", + "line": 17, + "function_name": "clean_3", + "sanitization_type": "encode", + "protects_against": [] + }, + { + "file_path": "app/sanitize/s4.py", + "line": 21, + "function_name": "clean_4", + "sanitization_type": "escape", + "protects_against": [ + "CWE-83", + "CWE-93" + ] + }, + { + "file_path": "app/sanitize/s5.py", + "line": 25, + "function_name": null, + "sanitization_type": "parameterize", + "protects_against": [ + "CWE-84", + "CWE-94" + ] + }, + { + "file_path": "app/sanitize/s6.py", + "line": 29, + "function_name": "clean_6", + "sanitization_type": "allowlist", + "protects_against": [] + }, + { + "file_path": "app/sanitize/s7.py", + "line": 33, + "function_name": "clean_7", + "sanitization_type": "encode", + "protects_against": [ + "CWE-86", + "CWE-96" + ] + }, + { + "file_path": "app/sanitize/s8.py", + "line": 37, + "function_name": "clean_8", + "sanitization_type": "escape", + "protects_against": [ + "CWE-87", + "CWE-97" + ] + }, + { + "file_path": "app/sanitize/s9.py", + "line": 41, + "function_name": "clean_9", + "sanitization_type": "parameterize", + "protects_against": [] + }, + { + "file_path": "app/sanitize/s10.py", + "line": 45, + "function_name": null, + "sanitization_type": "allowlist", + "protects_against": [ + "CWE-89", + "CWE-99" + ] + }, + { + "file_path": "app/sanitize/s11.py", + "line": 49, + "function_name": "clean_11", + "sanitization_type": "encode", + "protects_against": [ + "CWE-90", + "CWE-100" + ] + } + ], + "sinks": [ + { + "sink_type": "sql", + "file_path": "app/sink/k0.py", + "line": 200, + "function_name": null, + "exploitability_notes": null + }, + { + "sink_type": "command", + "file_path": "app/sink/k1.py", + "line": 202, + "function_name": "sink_fn_1", + "exploitability_notes": "note 1" + }, + { + "sink_type": "template", + "file_path": "app/sink/k2.py", + "line": 204, + "function_name": "sink_fn_2", + "exploitability_notes": "note 2" + }, + { + "sink_type": "file", + "file_path": "app/sink/k3.py", + "line": 206, + "function_name": "sink_fn_3", + "exploitability_notes": null + }, + { + "sink_type": "network", + "file_path": "app/sink/k4.py", + "line": 208, + "function_name": null, + "exploitability_notes": "note 4" + }, + { + "sink_type": "deserialization", + "file_path": "app/sink/k5.py", + "line": 210, + "function_name": "sink_fn_5", + "exploitability_notes": "note 5" + }, + { + "sink_type": "sql", + "file_path": "app/sink/k6.py", + "line": 212, + "function_name": "sink_fn_6", + "exploitability_notes": null + }, + { + "sink_type": "command", + "file_path": "app/sink/k7.py", + "line": 214, + "function_name": "sink_fn_7", + "exploitability_notes": "note 7" + }, + { + "sink_type": "template", + "file_path": "app/sink/k8.py", + "line": 216, + "function_name": null, + "exploitability_notes": "note 8" + }, + { + "sink_type": "file", + "file_path": "app/sink/k9.py", + "line": 218, + "function_name": "sink_fn_9", + "exploitability_notes": null + }, + { + "sink_type": "network", + "file_path": "app/sink/k10.py", + "line": 220, + "function_name": "sink_fn_10", + "exploitability_notes": "note 10" + }, + { + "sink_type": "deserialization", + "file_path": "app/sink/k11.py", + "line": 222, + "function_name": "sink_fn_11", + "exploitability_notes": "note 11" + }, + { + "sink_type": "sql", + "file_path": "app/sink/k12.py", + "line": 224, + "function_name": null, + "exploitability_notes": null + }, + { + "sink_type": "command", + "file_path": "app/sink/k13.py", + "line": 226, + "function_name": "sink_fn_13", + "exploitability_notes": "note 13" + }, + { + "sink_type": "template", + "file_path": "app/sink/k14.py", + "line": 228, + "function_name": "sink_fn_14", + "exploitability_notes": "note 14" + }, + { + "sink_type": "file", + "file_path": "app/sink/k15.py", + "line": 230, + "function_name": "sink_fn_15", + "exploitability_notes": null + }, + { + "sink_type": "network", + "file_path": "app/sink/k16.py", + "line": 232, + "function_name": null, + "exploitability_notes": "note 16" + }, + { + "sink_type": "deserialization", + "file_path": "app/sink/k17.py", + "line": 234, + "function_name": "sink_fn_17", + "exploitability_notes": "note 17" + } + ] + }, + "dependencies": { + "sbom": [ + { + "name": "django", + "version": "4.2.1", + "ecosystem": "pypi", + "direct": true, + "license": "BSD-3-Clause" + }, + { + "name": "requests", + "version": "2.31.0", + "ecosystem": "pypi", + "direct": true, + "license": "Apache-2.0" + }, + { + "name": "urllib3", + "version": "1.26.5", + "ecosystem": "pypi", + "direct": false, + "license": "MIT" + }, + { + "name": "jinja2", + "version": "3.1.2", + "ecosystem": "pypi", + "direct": false, + "license": null + }, + { + "name": "lodash", + "version": "4.17.19", + "ecosystem": "npm", + "direct": true, + "license": "MIT" + }, + { + "name": "express", + "version": "4.17.1", + "ecosystem": "npm", + "direct": true, + "license": "MIT" + }, + { + "name": "minimist", + "version": "1.2.5", + "ecosystem": "npm", + "direct": false, + "license": "MIT" + }, + { + "name": "axios", + "version": "0.21.1", + "ecosystem": "npm", + "direct": false, + "license": "MIT" + }, + { + "name": "github.com/gin-gonic/gin", + "version": "1.9.0", + "ecosystem": "go", + "direct": true, + "license": "MIT" + }, + { + "name": "golang.org/x/net", + "version": "0.7.0", + "ecosystem": "go", + "direct": false, + "license": "BSD-3-Clause" + }, + { + "name": "rails", + "version": "7.0.4", + "ecosystem": "rubygems", + "direct": true, + "license": "MIT" + }, + { + "name": "nokogiri", + "version": "1.13.6", + "ecosystem": "rubygems", + "direct": false, + "license": "MIT" + }, + { + "name": "django", + "version": "4.2.1", + "ecosystem": "pypi", + "direct": true, + "license": "BSD-3-Clause" + }, + { + "name": "requests", + "version": "2.31.0", + "ecosystem": "pypi", + "direct": true, + "license": "Apache-2.0" + } + ], + "known_cves": [ + { + "cve_id": "CVE-2023-0001", + "package": "django", + "installed_version": "4.2.1", + "fixed_version": "4.2.5", + "cvss_v4_score": 9.8, + "epss_score": 0.5, + "direct": true, + "reachable": true + }, + { + "cve_id": "CVE-2023-0002", + "package": "urllib3", + "installed_version": "1.26.5", + "fixed_version": null, + "cvss_v4_score": 7.5, + "epss_score": 0.1, + "direct": false, + "reachable": false + }, + { + "cve_id": "CVE-2023-0003", + "package": "lodash", + "installed_version": "4.17.19", + "fixed_version": "4.17.21", + "cvss_v4_score": 9.8, + "epss_score": 0.9, + "direct": true, + "reachable": true + }, + { + "cve_id": "CVE-2023-0004", + "package": "minimist", + "installed_version": "1.2.5", + "fixed_version": "1.2.6", + "cvss_v4_score": null, + "epss_score": null, + "direct": false, + "reachable": null + }, + { + "cve_id": "CVE-2023-0005", + "package": "axios", + "installed_version": "0.21.1", + "fixed_version": "0.21.2", + "cvss_v4_score": 5.3, + "epss_score": 0.02, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0006", + "package": "jinja2", + "installed_version": "3.1.2", + "fixed_version": null, + "cvss_v4_score": 6.1, + "epss_score": null, + "direct": false, + "reachable": false + }, + { + "cve_id": "CVE-2023-0007", + "package": "express", + "installed_version": "4.17.1", + "fixed_version": "4.18.0", + "cvss_v4_score": 4.3, + "epss_score": 0.005, + "direct": true, + "reachable": null + }, + { + "cve_id": "CVE-2023-0008", + "package": "nokogiri", + "installed_version": "1.13.6", + "fixed_version": "1.13.10", + "cvss_v4_score": 8.8, + "epss_score": 0.3, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0009", + "package": "golang.org/x/net", + "installed_version": "0.7.0", + "fixed_version": "0.17.0", + "cvss_v4_score": 7.5, + "epss_score": 0.44, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0010", + "package": "rails", + "installed_version": "7.0.4", + "fixed_version": "7.0.7", + "cvss_v4_score": 9.1, + "epss_score": 0.6, + "direct": true, + "reachable": false + }, + { + "cve_id": "CVE-2023-0011", + "package": "gin", + "installed_version": "1.9.0", + "fixed_version": null, + "cvss_v4_score": 3.7, + "epss_score": 0.001, + "direct": true, + "reachable": null + }, + { + "cve_id": "CVE-2023-0012", + "package": "requests", + "installed_version": "2.31.0", + "fixed_version": "2.32.0", + "cvss_v4_score": 6.5, + "epss_score": 0.07, + "direct": true, + "reachable": true + }, + { + "cve_id": "CVE-2023-0013", + "package": "pyyaml", + "installed_version": "5.3", + "fixed_version": "5.4", + "cvss_v4_score": 9.8, + "epss_score": 0.5, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0014", + "package": "log4j", + "installed_version": "2.14.0", + "fixed_version": "2.17.1", + "cvss_v4_score": 10.0, + "epss_score": 0.97, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0015", + "package": "openssl", + "installed_version": "1.1.1k", + "fixed_version": "1.1.1t", + "cvss_v4_score": 7.4, + "epss_score": 0.12, + "direct": false, + "reachable": null + }, + { + "cve_id": "CVE-2023-0016", + "package": "zlib", + "installed_version": "1.2.11", + "fixed_version": "1.2.12", + "cvss_v4_score": 8.2, + "epss_score": 0.04, + "direct": false, + "reachable": false + }, + { + "cve_id": "CVE-2023-0017", + "package": "libxml2", + "installed_version": "2.9.10", + "fixed_version": null, + "cvss_v4_score": null, + "epss_score": 0.25, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0018", + "package": "curl", + "installed_version": "7.68.0", + "fixed_version": "7.88.0", + "cvss_v4_score": 8.1, + "epss_score": null, + "direct": true, + "reachable": true + } + ], + "outdated": [ + { + "package": "pkg-0", + "current_version": "1.0.0", + "latest_version": "2.0.0", + "direct": true + }, + { + "package": "pkg-1", + "current_version": "1.1.0", + "latest_version": "2.1.0", + "direct": false + }, + { + "package": "pkg-2", + "current_version": "1.2.0", + "latest_version": "2.2.0", + "direct": true + }, + { + "package": "pkg-3", + "current_version": "1.3.0", + "latest_version": "2.3.0", + "direct": false + }, + { + "package": "pkg-4", + "current_version": "1.4.0", + "latest_version": "2.4.0", + "direct": true + }, + { + "package": "pkg-5", + "current_version": "1.5.0", + "latest_version": "2.5.0", + "direct": false + }, + { + "package": "pkg-6", + "current_version": "1.6.0", + "latest_version": "2.6.0", + "direct": true + }, + { + "package": "pkg-7", + "current_version": "1.7.0", + "latest_version": "2.7.0", + "direct": false + }, + { + "package": "pkg-8", + "current_version": "1.8.0", + "latest_version": "2.8.0", + "direct": true + }, + { + "package": "pkg-9", + "current_version": "1.9.0", + "latest_version": "2.9.0", + "direct": false + }, + { + "package": "pkg-10", + "current_version": "1.10.0", + "latest_version": "2.10.0", + "direct": true + }, + { + "package": "pkg-11", + "current_version": "1.11.0", + "latest_version": "2.11.0", + "direct": false + } + ], + "direct_count": 7, + "transitive_count": 143 + }, + "config": { + "secrets": [ + { + "id": "secret-00", + "secret_type": "aws_access_key", + "file_path": "config/env_0.yaml", + "line": 3, + "match": "AKIA****0000", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-01", + "secret_type": "github_token", + "file_path": "config/env_1.yaml", + "line": 4, + "match": "AKIA****0001", + "confidence": "medium", + "is_test_value": true + }, + { + "id": "secret-02", + "secret_type": "private_key", + "file_path": "config/env_2.yaml", + "line": 5, + "match": "AKIA****0002", + "confidence": "low", + "is_test_value": null + }, + { + "id": "secret-03", + "secret_type": "slack_webhook", + "file_path": "config/env_3.yaml", + "line": 6, + "match": "AKIA****0003", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-04", + "secret_type": "generic_api_key", + "file_path": "config/env_4.yaml", + "line": 7, + "match": "AKIA****0004", + "confidence": "medium", + "is_test_value": true + }, + { + "id": "secret-05", + "secret_type": "aws_access_key", + "file_path": "config/env_5.yaml", + "line": 8, + "match": "AKIA****0005", + "confidence": "low", + "is_test_value": null + }, + { + "id": "secret-06", + "secret_type": "github_token", + "file_path": "config/env_6.yaml", + "line": 9, + "match": "AKIA****0006", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-07", + "secret_type": "private_key", + "file_path": "config/env_7.yaml", + "line": 10, + "match": "AKIA****0007", + "confidence": "medium", + "is_test_value": true + }, + { + "id": "secret-08", + "secret_type": "slack_webhook", + "file_path": "config/env_8.yaml", + "line": 11, + "match": "AKIA****0008", + "confidence": "low", + "is_test_value": null + }, + { + "id": "secret-09", + "secret_type": "generic_api_key", + "file_path": "config/env_9.yaml", + "line": 12, + "match": "AKIA****0009", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-10", + "secret_type": "aws_access_key", + "file_path": "config/env_10.yaml", + "line": 13, + "match": "AKIA****0010", + "confidence": "medium", + "is_test_value": true + }, + { + "id": "secret-11", + "secret_type": "github_token", + "file_path": "config/env_11.yaml", + "line": 14, + "match": "AKIA****0011", + "confidence": "low", + "is_test_value": null + }, + { + "id": "secret-12", + "secret_type": "private_key", + "file_path": "config/env_12.yaml", + "line": 15, + "match": "AKIA****0012", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-13", + "secret_type": "slack_webhook", + "file_path": "config/env_13.yaml", + "line": 16, + "match": "AKIA****0013", + "confidence": "medium", + "is_test_value": true + }, + { + "id": "secret-14", + "secret_type": "generic_api_key", + "file_path": "config/env_14.yaml", + "line": 17, + "match": "AKIA****0014", + "confidence": "low", + "is_test_value": null + }, + { + "id": "secret-15", + "secret_type": "aws_access_key", + "file_path": "config/env_15.yaml", + "line": 18, + "match": "AKIA****0015", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-16", + "secret_type": "github_token", + "file_path": "config/env_16.yaml", + "line": 19, + "match": "AKIA****0016", + "confidence": "medium", + "is_test_value": true + } + ], + "misconfigs": [ + { + "id": "misconfig-00", + "category": "logging", + "file_path": "config/app_0.ini", + "line": null, + "key": "LOG_LEVEL", + "value": "DEBUG", + "risk": "high", + "remediation": "Debug logging in production" + }, + { + "id": "misconfig-01", + "category": "tls", + "file_path": "config/app_1.ini", + "line": 13, + "key": "SSL_VERIFY", + "value": "false", + "risk": "critical", + "remediation": "TLS verification disabled" + }, + { + "id": "misconfig-02", + "category": "cors", + "file_path": "config/app_2.ini", + "line": 14, + "key": "ALLOW_ORIGIN", + "value": "*", + "risk": "high", + "remediation": null + }, + { + "id": "misconfig-03", + "category": "headers", + "file_path": "config/app_3.ini", + "line": 0, + "key": "X_FRAME_OPTIONS", + "value": null, + "risk": "medium", + "remediation": "Missing clickjacking header" + }, + { + "id": "misconfig-04", + "category": "debug", + "file_path": "config/app_4.ini", + "line": 16, + "key": "DJANGO_DEBUG", + "value": "True", + "risk": "critical", + "remediation": "Debug mode enabled" + }, + { + "id": "misconfig-05", + "category": "storage", + "file_path": "config/app_5.ini", + "line": null, + "key": "BUCKET_ACL", + "value": "public-read", + "risk": "critical", + "remediation": "Public bucket" + }, + { + "id": "misconfig-06", + "category": "auth", + "file_path": "config/app_6.ini", + "line": 18, + "key": "SESSION_TIMEOUT", + "value": "999999", + "risk": "medium", + "remediation": null + }, + { + "id": "misconfig-07", + "category": "http", + "file_path": "config/app_7.ini", + "line": 19, + "key": "REDIRECT_HTTPS", + "value": "false", + "risk": "high", + "remediation": "Plain HTTP allowed" + }, + { + "id": "misconfig-08", + "category": "secrets", + "file_path": "config/app_8.ini", + "line": 20, + "key": null, + "value": "inline", + "risk": "medium", + "remediation": "Inline secret" + }, + { + "id": "misconfig-09", + "category": "trace", + "file_path": "config/app_9.ini", + "line": 21, + "key": "OTEL_TRACE_ALL", + "value": "true", + "risk": "low", + "remediation": "Verbose tracing" + }, + { + "id": "misconfig-10", + "category": "network", + "file_path": "config/app_10.ini", + "line": null, + "key": "BIND_ADDR", + "value": "0.0.0.0", + "risk": "medium", + "remediation": null + }, + { + "id": "misconfig-11", + "category": "errors", + "file_path": "config/app_11.ini", + "line": 23, + "key": "SHOW_STACKTRACE", + "value": "true", + "risk": "high", + "remediation": "Error stacktraces exposed" + }, + { + "id": "misconfig-12", + "category": "cache", + "file_path": "config/app_12.ini", + "line": 24, + "key": "CACHE_TTL", + "value": "0", + "risk": "low", + "remediation": null + }, + { + "id": "misconfig-13", + "category": "db", + "file_path": "config/app_13.ini", + "line": 25, + "key": "SSLMODE", + "value": "disable", + "risk": "critical", + "remediation": "Database TLS off" + }, + { + "id": "misconfig-14", + "category": "queue", + "file_path": "config/app_14.ini", + "line": 26, + "key": "PREFETCH", + "value": "1000", + "risk": "low", + "remediation": null + }, + { + "id": "misconfig-15", + "category": "exposure", + "file_path": "config/app_15.ini", + "line": null, + "key": "ADMIN_PATH", + "value": "/admin", + "risk": "medium", + "remediation": "Admin surface exposed" + } + ] + }, + "security_context": { + "auth_model": "jwt", + "auth_details": "HS256 JWTs issued by app/auth/service.py, refresh tokens in Redis", + "crypto_usage": [ + { + "algorithm": "AES", + "key_size": 256, + "mode": "GCM", + "usage_context": "at-rest encryption", + "is_weak": false + }, + { + "algorithm": "MD5", + "key_size": null, + "mode": null, + "usage_context": "password hashing", + "is_weak": true + }, + { + "algorithm": "RSA", + "key_size": 1024, + "mode": null, + "usage_context": "token signing", + "is_weak": true + }, + { + "algorithm": "SHA-256", + "key_size": null, + "mode": null, + "usage_context": null, + "is_weak": false + }, + { + "algorithm": "DES", + "key_size": 56, + "mode": "CBC", + "usage_context": "legacy export", + "is_weak": true + }, + { + "algorithm": "HMAC-SHA1", + "key_size": 160, + "mode": null, + "usage_context": "webhook signature", + "is_weak": true + }, + { + "algorithm": "ChaCha20", + "key_size": 256, + "mode": "Poly1305", + "usage_context": "transport", + "is_weak": false + }, + { + "algorithm": "bcrypt", + "key_size": null, + "mode": null, + "usage_context": "password hashing", + "is_weak": false + }, + { + "algorithm": "RC4", + "key_size": 128, + "mode": null, + "usage_context": null, + "is_weak": true + }, + { + "algorithm": "ECDSA", + "key_size": 256, + "mode": null, + "usage_context": "JWT signing", + "is_weak": null + }, + { + "algorithm": "PBKDF2", + "key_size": null, + "mode": null, + "usage_context": "key derivation", + "is_weak": false + }, + { + "algorithm": "SHA-1", + "key_size": null, + "mode": null, + "usage_context": "checksum", + "is_weak": true + }, + { + "algorithm": "AES", + "key_size": 128, + "mode": "ECB", + "usage_context": "legacy blob", + "is_weak": true + }, + { + "algorithm": "Ed25519", + "key_size": 256, + "mode": null, + "usage_context": "package signing", + "is_weak": false + }, + { + "algorithm": "3DES", + "key_size": 168, + "mode": "CBC", + "usage_context": "legacy tape", + "is_weak": true + }, + { + "algorithm": "Argon2id", + "key_size": null, + "mode": null, + "usage_context": "password hashing", + "is_weak": null + } + ], + "framework_security": [ + "django.middleware.csrf.CsrfViewMiddleware", + "", + "helmet defaults", + "SecurityMiddleware", + "rack-protection", + "spring-security filter chain" + ], + "security_headers": [ + "Content-Security-Policy: default-src 'self'", + "X-Content-Type-Options: nosniff", + "Strict-Transport-Security: max-age=31536000", + "X-Frame-Options: DENY", + "Referrer-Policy: no-referrer", + "", + "Permissions-Policy: geolocation=()" + ], + "deployment_signals": [ + "kubernetes ingress with TLS termination", + "docker-compose exposes 5432", + "no WAF in front of /api", + "secrets mounted from vault", + "readiness probe on /healthz", + "single replica for the worker", + "TLS 1.2 minimum", + "internal service mesh mTLS" + ] + }, + "languages": [ + "Python", + "JavaScript", + "Go", + "Ruby", + "Rust", + "python" + ], + "frameworks": [ + "Django", + "next.js", + "Spring Boot", + " React ", + "unknown-fw", + "NEXT", + "Vue" + ], + "lines_of_code": 184213, + "file_count": 2417, + "recon_duration_seconds": 42.5 +} diff --git a/go/internal/agents/hunt/testdata/recon_small.json b/go/internal/agents/hunt/testdata/recon_small.json new file mode 100644 index 0000000..33f644c --- /dev/null +++ b/go/internal/agents/hunt/testdata/recon_small.json @@ -0,0 +1,195 @@ +{ + "architecture": { + "app_type": "web_api", + "modules": [ + { + "name": "api", + "path": "app/api/", + "language": "Python", + "description": "HTTP layer", + "dependencies": [ + "db" + ] + } + ], + "entry_points": [ + { + "kind": "http", + "identifier": "POST /login", + "file_path": "app/api/auth.py", + "line": 12, + "method": "POST", + "route": "/login", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "seed", + "file_path": "app/cli.py", + "line": 3, + "method": null, + "route": null, + "auth_required": null + } + ], + "trust_boundaries": [ + { + "name": "edge", + "source_zone": "internet", + "target_zone": "app", + "description": "TLS terminator", + "enforcement": [ + "waf" + ] + } + ], + "services": [ + { + "name": "postgres", + "service_type": "database", + "endpoint": "db:5432", + "purpose": "primary store", + "auth_mechanism": "password" + } + ], + "api_surface": [ + { + "method": "POST", + "path": "/login", + "handler": "login", + "file_path": "app/api/auth.py", + "line": 12, + "auth_required": false, + "rate_limited": false + }, + { + "method": "GET", + "path": "/users/{id}", + "handler": "get_user", + "file_path": "app/api/users.py", + "line": 40, + "auth_required": true, + "rate_limited": true + } + ] + }, + "data_flows": { + "flows": [ + { + "source": "request.json", + "path": [ + { + "file_path": "app/api/users.py", + "line": 41, + "component": "handler", + "operation": "read id" + } + ], + "sink": "cursor.execute", + "sanitized": false, + "files": [ + "app/api/users.py" + ] + } + ], + "sanitization_points": [], + "sinks": [ + { + "sink_type": "sql", + "file_path": "app/api/users.py", + "line": 42, + "function_name": "get_user", + "exploitability_notes": "f-string query" + } + ] + }, + "dependencies": { + "sbom": [ + { + "name": "django", + "version": "4.2.1", + "ecosystem": "pypi", + "direct": true, + "license": "BSD-3" + } + ], + "known_cves": [ + { + "cve_id": "CVE-2024-0001", + "package": "django", + "installed_version": "4.2.1", + "fixed_version": "4.2.11", + "cvss_v4_score": 7.5, + "epss_score": 0.42, + "direct": true, + "reachable": true + } + ], + "outdated": [], + "direct_count": 3, + "transitive_count": 9 + }, + "config": { + "secrets": [ + { + "id": "", + "secret_type": "api_key", + "file_path": ".env", + "line": 2, + "match": "AKIA...", + "confidence": "high", + "is_test_value": false + } + ], + "misconfigs": [ + { + "id": "", + "category": "debug", + "file_path": "settings.py", + "line": 9, + "key": "DEBUG", + "value": "True", + "risk": "high", + "remediation": "disable in production" + } + ] + }, + "security_context": { + "auth_model": "jwt", + "auth_details": "HS256 access tokens", + "crypto_usage": [ + { + "algorithm": "MD5", + "key_size": null, + "mode": null, + "usage_context": "password hashing", + "is_weak": true + }, + { + "algorithm": "SHA256", + "key_size": null, + "mode": null, + "usage_context": "etag cache key", + "is_weak": false + } + ], + "framework_security": [ + "django-csrf" + ], + "security_headers": [ + "Content-Security-Policy" + ], + "deployment_signals": [ + "docker" + ] + }, + "languages": [ + "python" + ], + "frameworks": [ + "django" + ], + "lines_of_code": 1234, + "file_count": 56, + "recon_duration_seconds": 0.0 +} diff --git a/go/internal/agents/hunt/xss.go b/go/internal/agents/hunt/xss.go new file mode 100644 index 0000000..1ab049f --- /dev/null +++ b/go/internal/agents/hunt/xss.go @@ -0,0 +1,62 @@ +package hunt + +// Ports src/sec_af/agents/hunt/xss.py. + +import ( + "context" + "strconv" + "strings" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/recontext" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +const xssPromptPath = "hunt/xss.txt" + +// xssScanPrompt builds the exact prompt run_xss_hunter sends. +// +// Python parity: xss.py is the only one of the three inline-JSON hunters whose +// CONTEXT block carries an explicit "- Target CWEs:" line. +func xssScanPrompt(repoPath string, recon schemas.ReconResult, depth, earlyStop string) (scanPrompt, reconContext string) { + reconContext = entryFlowContextBlock(recon) + template := prompts.MustLoad(xssPromptPath) + template = strings.ReplaceAll(template, "{{RECON_CONTEXT_JSON}}", reconContext) + template = strings.ReplaceAll(template, "{{LANGUAGE_HINTS}}", recontext.LanguageHintsForContext(recon)) + template = strings.ReplaceAll(template, "{{FRAMEWORK_HINTS}}", recontext.FrameworkHintsForContext(recon)) + + scanPrompt = template + + "\n\nCONTEXT:\n" + + "- Repository path: " + repoPath + "\n" + + "- Depth profile: " + depth + "\n" + + "- Early stop rule: if you inspect " + earlyStop + + " files without credible signal, stop and return empty findings.\n" + + "- Focus on RECON entry points and data flows as primary source-to-sink paths.\n" + + "- Explore the codebase, trace user-controlled data into rendering sinks, and identify XSS/client-side injection points.\n" + + "- Target CWEs: CWE-79, CWE-80, CWE-87, CWE-116.\n" + + "- Take multiple turns to build findings incrementally and write final JSON only when complete." + return scanPrompt, reconContext +} + +func runXSSHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, depth, earlyStop string, +) (schemas.HuntResult, error) { + scanPrompt, reconContext := xssScanPrompt(repoPath, recon, depth, earlyStop) + return runHunterBody(ctx, app, repoPath, hunterSpec{ + ScanPrompt: scanPrompt, + ReconContext: reconContext, + FindingType: "sast", + Strategy: "xss", + EmptyStrategiesRun: nil, // Python: bare HuntResult() + }) +} + +// RunXSSHunter ports xss.py run_xss_hunter. +func RunXSSHunter( + ctx context.Context, app appx.Harnesser, repoPath string, + recon schemas.ReconResult, depth string, maxFilesWithoutSignal int, +) (schemas.HuntResult, error) { + return runXSSHunter(ctx, app, repoPath, recon, depth, strconv.Itoa(maxFilesWithoutSignal)) +} diff --git a/go/internal/agents/prove/agents_test.go b/go/internal/agents/prove/agents_test.go new file mode 100644 index 0000000..940eca7 --- /dev/null +++ b/go/internal/agents/prove/agents_test.go @@ -0,0 +1,320 @@ +package prove + +// Tests for the six schema-driven prove sub-agents (tracer, sanitization, +// exploit, cross-service, DAST, dependency reachability) that share the +// `tempfile.mkdtemp -> app.harness -> extract_harness_result -> rmtree` shape. +// +// Validation contract: +// - each agent runs with Cwd set to a fresh PRIVATE temp dir named +// `secaf--*` and ProjectDir set to the repository, and the temp +// dir is removed whether the run succeeds or fails; +// - each agent asks for the pydantic schema of its destination model; +// - a harness failure surfaces as ` harness error: `; +// - a run with no parsed output surfaces as +// ` did not return a valid `. + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// runAgent invokes one sub-agent against a scripted fake and returns the fake. +type agentCase struct { + name string + tempPrefix string + extractName string + modelName string + canned func() (json.RawMessage, error) + run func(context.Context, appx.Harnesser) error +} + +func proveAgentCases() []agentCase { + repo := fixtureRepo + return []agentCase{ + { + name: "tracer", tempPrefix: "secaf-prove-tracer-", + extractName: "DataFlowTracer", modelName: "DataFlowTrace", + canned: func() (json.RawMessage, error) { return json.Marshal(traceRich()) }, + run: func(ctx context.Context, app appx.Harnesser) error { + _, err := RunTracer(ctx, app, repo, findingRich(), "quick") + return err + }, + }, + { + name: "sanitization", tempPrefix: "secaf-prove-sanitization-", + extractName: "SanitizationAnalyzer", modelName: "SanitizationResult", + canned: func() (json.RawMessage, error) { return json.Marshal(sanitizationRich()) }, + run: func(ctx context.Context, app appx.Harnesser) error { + _, err := RunSanitizationAnalyzer(ctx, app, repo, findingRich(), traceRich(), "quick") + return err + }, + }, + { + name: "exploit", tempPrefix: "secaf-prove-exploit-", + extractName: "ExploitHypothesizer", modelName: "ExploitHypothesis", + canned: func() (json.RawMessage, error) { return json.Marshal(exploitRich()) }, + run: func(ctx context.Context, app appx.Harnesser) error { + _, err := RunExploitHypothesizer(ctx, app, repo, findingRich(), traceRich(), sanitizationRich(), "quick") + return err + }, + }, + { + name: "cross_service", tempPrefix: "secaf-prove-cross-service-", + extractName: "CrossServiceAnalyzer", modelName: "CrossServiceFinding", + canned: func() (json.RawMessage, error) { + return json.Marshal(schemas.CrossServiceFinding{ + ChainDescription: "d", ServicesInvolved: []string{"a"}, EntryPoint: "e", Impact: "i", + }) + }, + run: func(ctx context.Context, app appx.Harnesser) error { + _, err := RunCrossServiceAnalyzer(ctx, app, repo, []string{"a"}, "summary", "quick") + return err + }, + }, + { + name: "dast", tempPrefix: "secaf-prove-dast-", + extractName: "DastVerifier", modelName: "DastVerificationResult", + canned: func() (json.RawMessage, error) { + return json.Marshal(schemas.DastVerificationResult{ + PayloadSent: "p", ResponseSummary: "r", ExploitConfirmed: true, SafetyNotes: "s", + }) + }, + run: func(ctx context.Context, app appx.Harnesser) error { + _, err := RunDastVerifier(ctx, app, repo, findingRich(), "payload", "quick") + return err + }, + }, + { + name: "dep_reachability", tempPrefix: "secaf-prove-dep-reachability-", + extractName: "DependencyReachabilityAnalyzer", modelName: "ReachabilityProof", + canned: func() (json.RawMessage, error) { + return json.Marshal(schemas.ReachabilityProof{ + VulnerableFunction: "f", CallChain: []string{"a"}, Reachable: true, Direct: false, + }) + }, + run: func(ctx context.Context, app appx.Harnesser) error { + _, err := RunDepReachability(ctx, app, repo, map[string]any{"cve": "CVE-1"}, "quick") + return err + }, + }, + } +} + +// TestAgentTempDirsAreIsolatedAndRemoved pins +// `tempfile.mkdtemp(prefix=f"secaf-{agent_name}-")` / +// `shutil.rmtree(harness_cwd, ignore_errors=True)`, plus project_dir=repo_path. +func TestAgentTempDirsAreIsolatedAndRemoved(t *testing.T) { + for _, tc := range proveAgentCases() { + app := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return tc.canned() + })} + if err := tc.run(context.Background(), app); err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if len(app.Harnesses) != 1 { + t.Fatalf("%s: want 1 harness call, got %d", tc.name, len(app.Harnesses)) + } + h := app.Harnesses[0] + if h.Opts.Cwd == "" { + t.Fatalf("%s: ran with no Cwd", tc.name) + } + if base := filepath.Base(h.Opts.Cwd); !strings.HasPrefix(base, tc.tempPrefix) { + t.Errorf("%s: Cwd base = %q, want prefix %q", tc.name, base, tc.tempPrefix) + } + if h.Opts.Cwd == fixtureRepo { + t.Errorf("%s: the harness must run in a scratch dir, not the repository", tc.name) + } + if h.Opts.ProjectDir != fixtureRepo { + t.Errorf("%s: ProjectDir = %q, want %q", tc.name, h.Opts.ProjectDir, fixtureRepo) + } + if _, err := os.Stat(h.Opts.Cwd); !os.IsNotExist(err) { + t.Errorf("%s: temp dir %q still exists after the agent returned (err=%v)", tc.name, h.Opts.Cwd, err) + } + } +} + +// TestAgentTempDirRemovedOnFailure pins the `finally:` — the scratch dir goes +// away even when extract_harness_result raises. +func TestAgentTempDirRemovedOnFailure(t *testing.T) { + for _, tc := range proveAgentCases() { + app := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return nil, errors.New("provider exploded") + })} + if err := tc.run(context.Background(), app); err == nil { + t.Fatalf("%s: want an error", tc.name) + } + cwd := app.Harnesses[0].Opts.Cwd + if _, err := os.Stat(cwd); !os.IsNotExist(err) { + t.Errorf("%s: temp dir %q survived a failure (err=%v)", tc.name, cwd, err) + } + } +} + +// TestAgentHarnessErrorMessages pins extract_harness_result's two error +// spellings, including the AGENT NAME each module passes (which differs from +// the temp-dir name). +func TestAgentHarnessErrorMessages(t *testing.T) { + for _, tc := range proveAgentCases() { + app := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return nil, errors.New("provider exploded") + })} + err := tc.run(context.Background(), app) + want := tc.extractName + " harness error: provider exploded" + if err == nil || err.Error() != want { + t.Errorf("%s: error = %v, want %q", tc.name, err, want) + } + + // A run that produced no parsed value is the TypeError branch. + app = &appx.Fake{HarnessFn: func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return &harness.Result{Result: "not json"}, nil + }} + err = tc.run(context.Background(), app) + want = tc.extractName + " did not return a valid " + tc.modelName + if err == nil || err.Error() != want { + t.Errorf("%s: error = %v, want %q", tc.name, err, want) + } + } +} + +// TestAgentSchemasComeFromPydanticFixtures pins that each agent asks for the +// committed pydantic schema of its destination model rather than a Go +// reflection of it — an invopop schema would mark every field required and +// reject valid Python output. +func TestAgentSchemasComeFromPydanticFixtures(t *testing.T) { + for _, tc := range proveAgentCases() { + app := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return tc.canned() + })} + if err := tc.run(context.Background(), app); err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + schema := app.Harnesses[0].Schema + if schema == nil { + t.Fatalf("%s: no schema was passed", tc.name) + } + if title, _ := schema["title"].(string); title != tc.modelName { + t.Errorf("%s: schema title = %v, want %q (the pydantic fixture)", tc.name, schema["title"], tc.modelName) + } + } +} + +// TestFindingDataFlowRendering pins `_finding_data_flow`'s two branches and its +// literal key order (a Go map would sort them). +func TestFindingDataFlowRendering(t *testing.T) { + if got := findingDataFlow(findingBare()); got != "[]" { + t.Errorf("no data flow must render as []; got %q", got) + } + empty := findingRich() + empty.DataFlow = []schemas.ReconDataFlowStep{} + if got := findingDataFlow(empty); got != "[]" { + t.Errorf("an EMPTY data flow is falsy in Python too; got %q", got) + } + got := findingDataFlow(findingRich()) + want := `[ + { + "file_path": "src/routes.py", + "line": 10, + "component": "handler", + "operation": "read request.args" + }, + { + "file_path": "src/users.py", + "line": 42, + "component": "db", + "operation": "execute" + } +]` + if got != want { + t.Errorf("findingDataFlow =\n%s\nwant\n%s", got, want) + } +} + +// TestRelatedFilesJSONNilIsEmptyList pins that a nil Go slice renders as +// Python's `[]` — RawFinding.related_files can never be None. +func TestRelatedFilesJSONNilIsEmptyList(t *testing.T) { + if got := relatedFilesJSON(nil); got != "[]" { + t.Errorf("relatedFilesJSON(nil) = %q, want []", got) + } +} + +// TestTraceContextRendering pins the shared `_trace_context` helper, including +// its "no concrete trace steps" placeholder and yes/no rendering. +func TestTraceContextRendering(t *testing.T) { + got := traceContext(traceBare()) + want := "Source: unknown\nSink: unknown\nSink reached: no\nTrace steps:\n- (no concrete trace steps)" + if got != want { + t.Errorf("traceContext(empty) =\n%q\nwant\n%q", got, want) + } + got = traceContext(traceRich()) + want = "Source: request.args['id']\nSink: cursor.execute(query)\nSink reached: yes\n" + + "Trace steps:\n- src/routes.py:10 read request.args\n- src/users.py:42 execute" + if got != want { + t.Errorf("traceContext(rich) =\n%q\nwant\n%q", got, want) + } +} + +// TestSanitizationContextTriState pins that `sufficient` distinguishes None +// (unknown) from False (no) — the reason SanitizationResult.Sufficient is a +// pointer. +func TestSanitizationContextTriState(t *testing.T) { + for _, tc := range []struct { + sufficient *bool + want string + }{ + {nil, "Sanitization sufficient: unknown"}, + {boolp(false), "Sanitization sufficient: no"}, + {boolp(true), "Sanitization sufficient: yes"}, + } { + got := sanitizationContext(schemas.SanitizationResult{Sufficient: tc.sufficient}) + if !strings.Contains(got, tc.want) { + t.Errorf("sanitizationContext(%v) =\n%s\nwant it to contain %q", tc.sufficient, got, tc.want) + } + } + // `type` / `bypass_method` use Python's `or`, so BOTH None and "" -> "none". + for _, value := range []*string{nil, str("")} { + got := sanitizationContext(schemas.SanitizationResult{Type: value, BypassMethod: value}) + if !strings.Contains(got, "Sanitization type: none") || !strings.Contains(got, "Bypass method: none") { + t.Errorf("a falsy optional must render as 'none'; got\n%s", got) + } + } +} + +// TestVerdictContextRendersPythonBools pins verdict.py's raw f-string +// interpolation: booleans print as True/False and a None `sufficient` prints as +// None, unlike the yes/no/unknown mapping every other block uses. +func TestVerdictContextRendersPythonBools(t *testing.T) { + got := verdictBuildContext(traceRich(), sanitizationRich(), exploitRich()) + for _, want := range []string{ + "- sink_reached: True", + "- found: True", + "- sufficient: False", + } { + if !strings.Contains(got, want) { + t.Errorf("verdict context missing %q; got\n%s", want, got) + } + } + got = verdictBuildContext(traceBare(), sanitizationBare(), exploitBare()) + for _, want := range []string{ + "- sink_reached: False", + "- steps:\n- (none)", + "- found: False", + "- type: none", + "- sufficient: None", + "- bypass_method: none", + "- payload: none", + } { + if !strings.Contains(got, want) { + t.Errorf("verdict context missing %q; got\n%s", want, got) + } + } +} diff --git a/go/internal/agents/prove/assembler.go b/go/internal/agents/prove/assembler.go new file mode 100644 index 0000000..c60990f --- /dev/null +++ b/go/internal/agents/prove/assembler.go @@ -0,0 +1,181 @@ +package prove + +// Ports src/sec_af/agents/prove/assembler.py — the pure function that folds the +// four sub-agent outputs into one schemas.VerifiedFinding. + +import ( + "strconv" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// verdictMap ports assembler.py `_VERDICT_MAP`. Anything not in it falls back +// to INCONCLUSIVE, which is how a model that invents a verdict word (e.g. +// "unverified") is absorbed rather than raising. +var verdictMap = map[string]schemas.Verdict{ + "confirmed": schemas.VerdictConfirmed, + "likely": schemas.VerdictLikely, + "inconclusive": schemas.VerdictInconclusive, + "not_exploitable": schemas.VerdictNotExploitable, +} + +// toEvidenceLevel ports `_to_evidence_level`: +// +// bounded = max(1, min(6, level)) +// return EvidenceLevel(bounded) +// +// The clamp is what makes the IntEnum construction total: VerdictDecision +// declares evidence_level as a bare int, so a model answering 0 or 9 must not +// blow up. +func toEvidenceLevel(level int) schemas.EvidenceLevel { + bounded := level + if bounded > 6 { + bounded = 6 + } + if bounded < 1 { + bounded = 1 + } + return schemas.EvidenceLevel(bounded) +} + +// toDataFlowSteps ports `_to_data_flow_steps`: +// +// for index, step in enumerate(trace.steps, start=1): +// rows.append(DataFlowStep(file=f"trace_step_{index}", line=index, +// description=step, tainted=True)) +// +// The synthetic file/line are deliberate: the tracer returns free-text steps, +// and the evidence artifact wants a positional handle for each one. +// +// The result is always a non-nil slice, matching Python's `rows: list = []` — +// so `Proof.data_flow_trace` marshals as `[]`, never `null`. +func toDataFlowSteps(trace schemas.DataFlowTrace) []schemas.DataFlowStep { + rows := make([]schemas.DataFlowStep, 0, len(trace.Steps)) + for i, step := range trace.Steps { + index := i + 1 + rows = append(rows, schemas.DataFlowStep{ + File: "trace_step_" + strconv.Itoa(index), + Line: index, + Description: step, + Tainted: true, + }) + } + return rows +} + +// reproductionSteps ports `_reproduction_steps`: nothing to reproduce when the +// verdict is NOT_EXPLOITABLE, otherwise a fixed two-step recipe whose second +// step carries the exploit hypothesis/payload/outcome verbatim. +// +// Python parity: `command=exploit.payload` passes the Optional straight +// through, so a None payload yields a step with `command: null`. +func reproductionSteps(verdict schemas.Verdict, exploit schemas.ExploitHypothesis) []schemas.ReproductionStep { + if verdict == schemas.VerdictNotExploitable { + return []schemas.ReproductionStep{} + } + step1Desc := "Trace attacker-controlled input from source to sink in target code path." + step1Out := "Input reaches a sensitive sink." + return []schemas.ReproductionStep{ + {Step: 1, Description: step1Desc, Command: nil, ExpectedOutput: &step1Out}, + {Step: 2, Description: exploit.Hypothesis, Command: exploit.Payload, ExpectedOutput: &exploit.ExpectedOutcome}, + } +} + +// AssembleVerifiedFinding ports assembler.py assemble_verified_finding. +// +// Python parity notes: +// +// - `bypass_possible=bool(sanitization.bypass_method)` is Python truthiness on +// an `str | None`: None AND the empty string are both False. The field is +// `bool | None` in the schema but assembler always supplies a real bool, so +// the Go pointer is always non-nil here. +// - `data_flow_trace` and `data_flow_evidence.steps` are the SAME list object +// in Python; Go copies the slice header into both, which is equivalent for +// everything downstream (nobody mutates it). +// - `tags=[]`, `exploitability_score=0.0` and `sarif_security_severity=0.0` +// are literal here — `_apply_metadata` overwrites the two scores afterwards. +// - `related_locations` / `compliance` are not passed, so they take pydantic's +// `default_factory=list`; Go seeds them explicitly so model_dump() parity +// holds (`[]`, not `null`). +func AssembleVerifiedFinding( + finding schemas.RawFinding, + dataFlowTrace schemas.DataFlowTrace, + sanitization schemas.SanitizationResult, + exploit schemas.ExploitHypothesis, + verdictDecision schemas.VerdictDecision, +) schemas.VerifiedFinding { + verdict, ok := verdictMap[verdictDecision.Verdict] + if !ok { + verdict = schemas.VerdictInconclusive + } + evidenceLevel := toEvidenceLevel(verdictDecision.EvidenceLevel) + dataFlowSteps := toDataFlowSteps(dataFlowTrace) + + source := dataFlowTrace.Source + sink := dataFlowTrace.Sink + bypassPossible := sanitization.BypassMethod != nil && *sanitization.BypassMethod != "" + + proof := schemas.Proof{ + ExploitHypothesis: exploit.Hypothesis, + VerificationMethod: "composite_subagent_chain:" + string(finding.FindingType), + EvidenceLevel: evidenceLevel, + DataFlowTrace: dataFlowSteps, + DataFlowEvidence: &schemas.DataFlowEvidence{ + Steps: dataFlowSteps, + Source: &source, + Sink: &sink, + SinkReached: dataFlowTrace.SinkReached, + }, + SanitizationAnalysis: &schemas.SanitizationAnalysis{ + SanitizationFound: sanitization.Found, + SanitizationType: sanitization.Type, + SanitizationSufficient: sanitization.Sufficient, + BypassPossible: &bypassPossible, + BypassMethod: sanitization.BypassMethod, + }, + ExploitPayload: exploit.Payload, + ExpectedOutcome: &exploit.ExpectedOutcome, + } + + return schemas.VerifiedFinding{ + ID: finding.ID, + Fingerprint: finding.Fingerprint, + Title: finding.Title, + Description: finding.Description, + FindingType: finding.FindingType, + CweID: finding.CweID, + CweName: finding.CweName, + OwaspCategory: finding.OwaspCategory, + Verdict: verdict, + EvidenceLevel: evidenceLevel, + Rationale: verdictDecision.Rationale, + Severity: finding.EstimatedSeverity, + Tags: []string{}, + ExploitabilityScore: 0.0, + Proof: &proof, + Location: locationOf(finding), + RelatedLocations: []schemas.Location{}, + Compliance: []schemas.ComplianceMapping{}, + ReproductionSteps: reproductionSteps(verdict, exploit), + SarifRuleID: sarifRuleID(finding.FindingType, finding.CweName), + SarifSecuritySeverity: 0.0, + DropReason: nil, + } +} + +// locationOf builds the Location both assembler.py and verifier.py construct +// from a RawFinding. `start_column` / `end_column` are left at pydantic's None. +// +// Python parity: `code_snippet` is a REQUIRED str on RawFinding but an +// Optional on Location, so an empty snippet crosses over as "" rather than +// null — the pointer is always non-nil. +func locationOf(finding schemas.RawFinding) schemas.Location { + snippet := finding.CodeSnippet + return schemas.Location{ + FilePath: finding.FilePath, + StartLine: finding.StartLine, + EndLine: finding.EndLine, + FunctionName: finding.FunctionName, + CodeSnippet: &snippet, + } +} diff --git a/go/internal/agents/prove/assembler_test.go b/go/internal/agents/prove/assembler_test.go new file mode 100644 index 0000000..0d05c40 --- /dev/null +++ b/go/internal/agents/prove/assembler_test.go @@ -0,0 +1,231 @@ +package prove + +// Tests for assembler.go and the two other pure helpers verifier.py exposes +// (Fallback, sarifRuleID), all pinned against the Python goldens. +// +// Validation contract (behaviour, not implementation): +// - assembling four sub-agent outputs yields exactly the VerifiedFinding +// Python's assemble_verified_finding produces, field for field; +// - an unrecognised verdict word degrades to "inconclusive"; +// - an out-of-range evidence level is clamped into 1..6; +// - NOT_EXPLOITABLE suppresses the reproduction steps, every other verdict +// emits the two-step recipe carrying the exploit payload; +// - Fallback demotes to inconclusive/static_match with a zero score, adds +// "low_confidence" only when a drop reason is given, and appends the +// original verdict to the rationale only when one is given. + +import ( + "reflect" + "testing" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +func TestAssembleVerifiedFindingGolden(t *testing.T) { + var want map[string]any + goldenJSON(t, "assemble", &want) + + rich, bare := findingRich(), findingBare() + cases := map[string]schemas.VerifiedFinding{ + "confirmed": AssembleVerifiedFinding(rich, traceRich(), sanitizationRich(), exploitRich(), + verdictDecision("confirmed", 5)), + "not_exploitable": AssembleVerifiedFinding(rich, traceRich(), sanitizationRich(), exploitRich(), + verdictDecision("not_exploitable", 1)), + "unknown_verdict": AssembleVerifiedFinding(bare, traceBare(), sanitizationBare(), exploitBare(), + verdictDecision("unverified", 9)), + "clamped_low": AssembleVerifiedFinding(bare, traceBare(), sanitizationBare(), exploitBare(), + verdictDecision("likely", 0)), + } + if len(cases) != len(want) { + t.Fatalf("case count drift: go has %d, golden has %d", len(cases), len(want)) + } + for name, got := range cases { + if !reflect.DeepEqual(jsonTree(t, got), want[name]) { + t.Errorf("assemble[%s] mismatch:\n got: %#v\nwant: %#v", name, jsonTree(t, got), want[name]) + } + } +} + +// TestAssembleVerdictMapping pins the _VERDICT_MAP lookup and its default. +func TestAssembleVerdictMapping(t *testing.T) { + for in, want := range map[string]schemas.Verdict{ + "confirmed": schemas.VerdictConfirmed, + "likely": schemas.VerdictLikely, + "inconclusive": schemas.VerdictInconclusive, + "not_exploitable": schemas.VerdictNotExploitable, + "unverified": schemas.VerdictInconclusive, // unknown -> default + "": schemas.VerdictInconclusive, + "CONFIRMED": schemas.VerdictInconclusive, // the map is case-SENSITIVE + } { + got := AssembleVerifiedFinding(findingBare(), traceBare(), sanitizationBare(), exploitBare(), + verdictDecision(in, 3)).Verdict + if got != want { + t.Errorf("verdict %q -> %q, want %q", in, got, want) + } + } +} + +// TestToEvidenceLevelClamp pins `max(1, min(6, level))`. +func TestToEvidenceLevelClamp(t *testing.T) { + for in, want := range map[int]schemas.EvidenceLevel{ + -5: 1, 0: 1, 1: 1, 3: 3, 6: 6, 7: 6, 99: 6, + } { + if got := toEvidenceLevel(in); got != want { + t.Errorf("toEvidenceLevel(%d) = %d, want %d", in, got, want) + } + } +} + +// TestToDataFlowStepsNumbering pins the 1-based enumerate and the synthetic +// file/line handles. +func TestToDataFlowStepsNumbering(t *testing.T) { + got := toDataFlowSteps(schemas.DataFlowTrace{Steps: []string{"a", "b", "c"}}) + want := []schemas.DataFlowStep{ + {File: "trace_step_1", Line: 1, Description: "a", Tainted: true}, + {File: "trace_step_2", Line: 2, Description: "b", Tainted: true}, + {File: "trace_step_3", Line: 3, Description: "c", Tainted: true}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("toDataFlowSteps = %+v, want %+v", got, want) + } + // An empty trace yields an empty (non-nil) list, so Proof.data_flow_trace + // marshals as [] rather than null. + if empty := toDataFlowSteps(schemas.DataFlowTrace{}); empty == nil || len(empty) != 0 { + t.Errorf("empty trace must give a non-nil empty slice, got %#v", empty) + } +} + +// TestReproductionStepsCarryExploit pins _reproduction_steps' two branches. +func TestReproductionStepsCarryExploit(t *testing.T) { + if got := reproductionSteps(schemas.VerdictNotExploitable, exploitRich()); len(got) != 0 { + t.Errorf("NOT_EXPLOITABLE must suppress reproduction steps, got %d", len(got)) + } + got := reproductionSteps(schemas.VerdictLikely, exploitRich()) + if len(got) != 2 { + t.Fatalf("want 2 reproduction steps, got %d", len(got)) + } + if got[1].Description != exploitRich().Hypothesis { + t.Errorf("step 2 description = %q, want the exploit hypothesis", got[1].Description) + } + if got[1].Command == nil || *got[1].Command != "1 OR 1=1" { + t.Errorf("step 2 command must be the exploit payload, got %v", got[1].Command) + } + // A None payload passes straight through as null. + noPayload := reproductionSteps(schemas.VerdictLikely, exploitBare()) + if noPayload[1].Command != nil { + t.Errorf("a nil payload must stay nil, got %v", noPayload[1].Command) + } +} + +// TestBypassPossibleTruthiness pins `bool(sanitization.bypass_method)`. +func TestBypassPossibleTruthiness(t *testing.T) { + for _, tc := range []struct { + name string + method *string + want bool + }{ + {"nil", nil, false}, + {"empty", str(""), false}, + {"set", str("x"), true}, + } { + got := AssembleVerifiedFinding(findingBare(), traceBare(), + schemas.SanitizationResult{BypassMethod: tc.method}, exploitBare(), + verdictDecision("likely", 2)) + if got.Proof == nil || got.Proof.SanitizationAnalysis == nil { + t.Fatalf("%s: proof/sanitization analysis missing", tc.name) + } + bp := got.Proof.SanitizationAnalysis.BypassPossible + if bp == nil || *bp != tc.want { + t.Errorf("%s: bypass_possible = %v, want %v", tc.name, bp, tc.want) + } + } +} + +func TestFallbackGolden(t *testing.T) { + var want map[string]any + goldenJSON(t, "fallback", &want) + + rich, bare := findingRich(), findingBare() + cases := map[string]schemas.VerifiedFinding{ + "plain": Fallback(rich, "harness blew up", nil, nil), + "with_drop_reason": Fallback(rich, "boom", StrPtr("verifier_error"), nil), + "demoted": Fallback(bare, + "Verifier returned unverified verdict; demoted for manual review", + StrPtr("verdict_unverified"), StrPtr("unverified")), + "original_verdict_only": Fallback(bare, "why", nil, StrPtr("unverified")), + } + if len(cases) != len(want) { + t.Fatalf("case count drift: go has %d, golden has %d", len(cases), len(want)) + } + for name, got := range cases { + if !reflect.DeepEqual(jsonTree(t, got), want[name]) { + t.Errorf("fallback[%s] mismatch:\n got: %#v\nwant: %#v", name, jsonTree(t, got), want[name]) + } + } +} + +// TestFallbackDemotionContract restates what tests/test_prove_phase_demotion.py +// asserts about a demoted finding, at the level this package owns. +func TestFallbackDemotionContract(t *testing.T) { + got := Fallback(findingRich(), "Verifier returned unverified verdict; demoted for manual review", + StrPtr("verdict_unverified"), StrPtr("unverified")) + + if got.Verdict != schemas.VerdictInconclusive { + t.Errorf("verdict = %q, want inconclusive", got.Verdict) + } + if got.EvidenceLevel != schemas.EvidenceLevelStaticMatch { + t.Errorf("evidence_level = %d, want 1 (STATIC_MATCH)", got.EvidenceLevel) + } + if got.DropReason == nil || *got.DropReason != "verdict_unverified" { + t.Errorf("drop_reason = %v, want verdict_unverified", got.DropReason) + } + if len(got.Tags) != 1 || got.Tags[0] != "low_confidence" { + t.Errorf("tags = %v, want [low_confidence]", got.Tags) + } + wantRationale := "Verification incomplete: Verifier returned unverified verdict; " + + "demoted for manual review (original verdict: unverified)" + if got.Rationale != wantRationale { + t.Errorf("rationale = %q, want %q", got.Rationale, wantRationale) + } + if got.ExploitabilityScore != 0 || got.SarifSecuritySeverity != 0 { + t.Errorf("a demoted finding must score 0, got %v/%v", got.ExploitabilityScore, got.SarifSecuritySeverity) + } + if got.Proof != nil { + t.Error("a demoted finding carries no proof") + } +} + +// TestFallbackEmptyOptionalsAreFalsy pins that "" behaves like None for both +// keyword arguments, which is what Python's truthiness tests do. +func TestFallbackEmptyOptionalsAreFalsy(t *testing.T) { + got := Fallback(findingBare(), "reason", StrPtr(""), StrPtr("")) + if len(got.Tags) != 0 { + t.Errorf("an empty drop_reason is falsy, so tags must stay empty; got %v", got.Tags) + } + if got.Rationale != "Verification incomplete: reason" { + t.Errorf("an empty original_verdict must not be appended; got %q", got.Rationale) + } + // The field itself still carries the pointer Python would store. + if got.DropReason == nil || *got.DropReason != "" { + t.Errorf("drop_reason field must hold the value passed, got %v", got.DropReason) + } +} + +func TestSarifRuleIDSlug(t *testing.T) { + for _, tc := range []struct { + cweName string + want string + }{ + {"SQL Injection", "sec-af/sast/sql-injection"}, + {"Improper Neutralization/Escaping of Special Elements", + "sec-af/sast/improper-neutralization-escaping-of-special-elements"}, + {"Broken Access/Control Check", "sec-af/sast/broken-access-control-check"}, + {"", "sec-af/sast/"}, + // Only SPACE and SLASH are replaced — an underscore survives. + {"Weak_Crypto", "sec-af/sast/weak_crypto"}, + } { + if got := sarifRuleID(schemas.FindingTypeSast, tc.cweName); got != tc.want { + t.Errorf("sarifRuleID(%q) = %q, want %q", tc.cweName, got, tc.want) + } + } +} diff --git a/go/internal/agents/prove/chain_builder.go b/go/internal/agents/prove/chain_builder.go new file mode 100644 index 0000000..e193766 --- /dev/null +++ b/go/internal/agents/prove/chain_builder.go @@ -0,0 +1,447 @@ +package prove + +// Ports src/sec_af/agents/prove/chain_builder.py — the ONE prove agent that +// calls the harness WITHOUT a schema and parses the raw text itself. + +import ( + "context" + "encoding/json" + "errors" + "sort" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// chainBuilderPromptPath mirrors chain_builder.py's module-level PROMPT_PATH. +const chainBuilderPromptPath = "prove/chain_builder.txt" + +// ErrChainTagsNotASet reproduces the AttributeError Python raises inside +// `_apply_validated_chain`. +// +// PYTHON PARITY — THIS IS A REAL BUG IN THE PYTHON SOURCE, REPRODUCED ON +// PURPOSE. `_apply_validated_chain` finishes each matched finding with +// +// finding.tags.add("attack_chain") +// +// but `VerifiedFinding.tags` is declared `list[str]`, and a list has no `.add`. +// The call therefore raises `AttributeError: 'list' object has no attribute +// 'add'` the moment a validated chain names a finding that is actually present +// — and, unlike the harness call above it, `_apply_validated_chain` runs +// OUTSIDE run_chain_builder's `try/except`, so the exception escapes +// run_chain_builder and run_prove to the orchestrator. +// +// Verified against the real package (the repro script printed +// `f"{type(e).__name__} {e}"`, hence the class name in the transcript): +// +// $ PYTHONPATH=src ~/.agentfield/packages/sec-af/venv/bin/python -c '...' +// apply raised: AttributeError 'list' object has no attribute 'add' +// +// The MESSAGE, however, is `str(exc)` alone — `'list' object has no attribute +// 'add'`, with no `AttributeError: ` prefix — and that is what this error text +// must be, because app.py:229-230 interpolates it into both the note +// (`f"Audit pipeline failed: {exc}"`) and the 500 body +// (`f"audit execution failed: {exc}"`), which internal/node/audit.go builds +// from err.Error(). The sibling reproductions spell it the same way +// (orch.AttributeError, phases' select_strategy gate). +// +// The partial mutations performed before the raise (chain_id, chain_step and +// enables on the FIRST matching finding) are observable and are reproduced too. +// Do not "fix" this without fixing the Python side first; the two nodes must +// fail identically. +var ErrChainTagsNotASet = errors.New("'list' object has no attribute 'add'") + +// chainBuilderBuildPrompt ports chain_builder.py `_build_prompt`. +// +// findings_by_id = {finding.id: finding.model_dump() for finding in findings} +// chains_payload = [chain.model_dump() for chain in potential_chains] +// prompt = template.replace("{{DEPTH}}", depth) +// prompt = prompt.replace("{{CHAINS_JSON}}", json.dumps(chains_payload, indent=2)) +// prompt = prompt.replace("{{FINDINGS_JSON}}", json.dumps(findings_by_id, indent=2)) +// +// Python parity notes: +// +// - {{DEPTH}} is substituted FIRST, so a literal "{{CHAINS_JSON}}" in the +// depth string would be replaced afterwards (and a "{{DEPTH}}" inside the +// JSON payloads would not). +// - `findings_by_id` is a dict comprehension: duplicate ids keep the FIRST +// key position but the LAST value, and json.dumps renders insertion order. +// A Go map cannot express that, so the payload is built as a pyfmt.Ordered +// with the same first-seen ordering — see findingsByID. +func chainBuilderBuildPrompt( + template string, + potentialChains []schemas.PotentialChain, + findings []schemas.VerifiedFinding, + depth string, +) string { + _, ordered := findingsByID(findings) + + payload := make(pyfmt.Ordered, len(ordered)) + for i, entry := range ordered { + payload[i] = pyfmt.KV{Key: entry.id, Value: *entry.finding} + } + + chainsPayload := potentialChains + if chainsPayload == nil { + chainsPayload = []schemas.PotentialChain{} + } + + prompt := template + prompt = strings.ReplaceAll(prompt, "{{DEPTH}}", depth) + prompt = strings.ReplaceAll(prompt, "{{CHAINS_JSON}}", pyfmt.Dumps(chainsPayload, 2)) + prompt = strings.ReplaceAll(prompt, "{{FINDINGS_JSON}}", pyfmt.Dumps(payload, 2)) + return prompt +} + +// ChainBuilderPrompt builds the exact prompt RunChainBuilder sends. There is no +// trailing CONTEXT block — chain_builder.py sends the substituted template +// alone. Exported for the golden test. +func ChainBuilderPrompt(potentialChains []schemas.PotentialChain, findings []schemas.VerifiedFinding, depth string) string { + return chainBuilderBuildPrompt(prompts.MustLoad(chainBuilderPromptPath), potentialChains, findings, depth) +} + +// indexedFinding pairs an id with the (mutable) finding that id resolves to. +type indexedFinding struct { + id string + finding *schemas.VerifiedFinding +} + +// findingsByID reproduces `{finding.id: finding for finding in findings}`. +// +// Python dict semantics for a duplicate key: the key keeps its ORIGINAL +// position and the value is OVERWRITTEN. Both matter — the prompt payload and +// run_chain_builder's return value are both built by iterating this mapping, so +// a duplicate id shortens the returned list and keeps the LAST finding at the +// FIRST occurrence's position. +// +// The returned pointers alias entries of a private copy of `findings`, so +// mutating them cannot disturb the caller's slice until the copy is handed +// back. +func findingsByID(findings []schemas.VerifiedFinding) (map[string]*schemas.VerifiedFinding, []indexedFinding) { + store := make([]schemas.VerifiedFinding, len(findings)) + copy(store, findings) + + byID := make(map[string]*schemas.VerifiedFinding, len(store)) + ordered := make([]indexedFinding, 0, len(store)) + pos := make(map[string]int, len(store)) + for i := range store { + id := store[i].ID + if at, seen := pos[id]; seen { + ordered[at].finding = &store[i] + byID[id] = &store[i] + continue + } + pos[id] = len(ordered) + ordered = append(ordered, indexedFinding{id: id, finding: &store[i]}) + byID[id] = &store[i] + } + return byID, ordered +} + +// chainAnalysisPayload is chain_builder.py's ChainAnalysisPayload TypedDict. +// The nested chain/step entries stay untyped (map[string]any) because Python's +// `_parse_payload` validates ONLY that `payload["chains"]` is a list — every +// key access below it is a bare subscript that raises on a malformed shape, and +// the port reproduces that. +type chainAnalysisPayload struct { + chains []any +} + +// parseChainPayload ports `_parse_payload`. +// +// parsed = getattr(result, "parsed", None) +// if isinstance(parsed, dict): payload = parsed +// elif isinstance(result, dict): payload = result +// elif isinstance(parsed, str): payload = json.loads(parsed) # None -> None +// else: +// text = getattr(result, "text", None) +// payload = json.loads(text) if isinstance(text, str) else return None +// chains = payload.get("chains") +// return None if not isinstance(chains, list) else payload +// +// Go collapses the duck-typed ladder because the SDK's harness.Result is a +// concrete struct: `app.Harness(..., nil, nil, ...)` never populates Parsed +// (there is no schema and no destination), and `result` is never a bare map, so +// Python takes the `text` branch every time — `json.loads(result.text)`, i.e. +// json.Unmarshal of Result.Result. +// +// Python parity on the failure modes, all of which end at `payload = None` +// because run_chain_builder wraps this call in `except Exception`: +// +// - empty or non-JSON text -> JSONDecodeError -> caught here, nil returned; +// - JSON that is not an object (e.g. `[1,2]`) -> Python's `payload.get` +// raises AttributeError, caught by run_chain_builder -> nil; +// - `chains` missing or not a list -> None. +func parseChainPayload(res *harness.Result) *chainAnalysisPayload { + if res == nil { + return nil + } + var decoded any + if err := json.Unmarshal([]byte(res.Result), &decoded); err != nil { + return nil + } + obj, isObject := decoded.(map[string]any) + if !isObject { + // Python: AttributeError from `.get` on a list/str/number, swallowed + // by run_chain_builder's except. + return nil + } + chains, isList := obj["chains"].([]any) + if !isList { + return nil + } + return &chainAnalysisPayload{chains: chains} +} + +// applyValidatedChain ports `_apply_validated_chain`: +// +// if not chain["validated"] or not chain["steps"]: +// return +// ordered = sorted(chain["steps"], key=lambda step: step["step_number"]) +// ordered_ids = [step["finding_id"] for step in ordered] +// for index, step in enumerate(ordered): +// finding = findings_by_id.get(step["finding_id"]) +// if finding is None: continue +// finding.chain_id = chain["chain_id"] +// finding.chain_step = step["step_number"] +// if index + 1 < len(ordered_ids): +// finding.enables = [ordered_ids[index + 1]] +// finding.tags.add("attack_chain") # <- AttributeError, see ErrChainTagsNotASet +// +// The error is returned rather than raised; RunChainBuilder propagates it, as +// Python's uncaught AttributeError does. +// +// Python parity details: +// +// - `not chain["validated"]` / `not chain["steps"]` are TRUTHINESS tests, so +// a chain with validated=false, validated missing-but-null, or an empty +// steps list is a no-op. +// - `sorted` is STABLE, so equal step_numbers keep their input order. +// - the loop `continue`s past unknown finding ids, so a chain naming only +// absent findings mutates nothing AND raises nothing — including when the +// chain has no `chain_id` at all, because that key is only read once a +// step has matched (see the check inside the loop). +// - a malformed step (no "step_number" / "finding_id", or a non-numeric +// step_number) raises KeyError/TypeError in Python; the Go port returns a +// descriptive error from the same place. +// +// ERROR TEXT. `_apply_validated_chain` runs OUTSIDE run_chain_builder's +// try/except, so whatever it raises escapes to app.py:229-230, which +// interpolates `str(exc)` — NOT `repr(exc)` — into both the note +// (`f"Audit pipeline failed: {exc}"`) and the 500 body +// (`f"audit execution failed: {exc}"`). `str(exc)` never carries the exception +// CLASS NAME, so neither may these sentinels; the same rule already governs +// ErrChainTagsNotASet above. For the five MISSING-KEY cases the Go text is then +// byte-exact with Python, because `str(KeyError('validated'))` is `'validated'` +// — quotes included (VERIFIED on the pinned interpreter, all five keys). The +// TypeError cases keep a descriptive English text (Python's own wording depends +// on where in `sorted()` the subscript fails, e.g. +// `string indices must be integers, not 'str'`); they are a deliberate +// substitution, but they carry no class-name prefix either. +func applyValidatedChain(byID map[string]*schemas.VerifiedFinding, chain map[string]any) error { + validated, ok := chain["validated"] + if !ok { + return errors.New("'validated'") + } + rawSteps, ok := chain["steps"] + if !ok { + return errors.New("'steps'") + } + if !pyTruthy(validated) || !pyTruthy(rawSteps) { + return nil + } + steps, isList := rawSteps.([]any) + if !isList { + return errors.New("chain['steps'] is not a list") + } + + // One slice of (step_number, step) pairs so sorting keeps the key and the + // row together — Python's `sorted(steps, key=...)` moves whole elements. + type orderedStep struct { + number float64 + step map[string]any + } + ordered := make([]orderedStep, len(steps)) + for i, s := range steps { + step, isMap := s.(map[string]any) + if !isMap { + return errors.New("chain step is not an object") + } + n, hasNumber := step["step_number"] + if !hasNumber { + return errors.New("'step_number'") + } + f, isNumber := toFloat(n) + if !isNumber { + return errors.New("chain step 'step_number' is not a number") + } + ordered[i] = orderedStep{number: f, step: step} + } + // Python's sorted() is stable, so equal step_numbers keep input order. + sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].number < ordered[j].number }) + + orderedIDs := make([]string, len(ordered)) + for i, entry := range ordered { + id, hasID := entry.step["finding_id"] + if !hasID { + return errors.New("'finding_id'") + } + s, isStr := id.(string) + if !isStr { + return errors.New("chain step 'finding_id' is not a string") + } + orderedIDs[i] = s + } + + for index, entry := range ordered { + finding := byID[orderedIDs[index]] + if finding == nil { + continue + } + // Python parity: `chain["chain_id"]` is subscripted INSIDE the loop + // (chain_builder.py:99), AFTER `if finding is None: continue`. A chain + // that names only unknown finding ids therefore never touches the key — + // a chain missing `chain_id` entirely is a silent no-op in Python, not + // a KeyError. Hoisting this check above the loop (as an earlier draft + // did) failed the whole prove phase on a payload Python ignores. + // VERIFIED on the pinned interpreter: _apply_validated_chain({}, { + // "validated": True, + // "steps": [{"step_number": 1, "finding_id": "hallucinated"}]}) -> None. + chainID, hasChainID := chain["chain_id"] + if !hasChainID { + return errors.New("'chain_id'") + } + chainIDStr, isChainIDStr := chainID.(string) + if !isChainIDStr { + return errors.New("chain['chain_id'] is not a string") + } + id := chainIDStr + // Python assigns `step["step_number"]` verbatim into an `int | None` + // field; pydantic accepts an integral float there, so the Go port + // truncates the decoded JSON number the same way. + stepNumber := int(entry.number) + finding.ChainID = &id + finding.ChainStep = &stepNumber + if index+1 < len(orderedIDs) { + finding.Enables = []string{orderedIDs[index+1]} + } + return ErrChainTagsNotASet + } + return nil +} + +// RunChainBuilder ports chain_builder.py run_chain_builder. +// +// if not potential_chains or not findings: +// return findings +// prompt = _build_prompt(...) +// try: +// result = await app.harness(prompt=prompt, cwd=repo_path) +// payload = _parse_payload(result) +// except Exception: +// payload = None +// if payload is None: +// return findings +// findings_by_id = {f.id: f for f in findings} +// for chain in payload["chains"]: +// _apply_validated_chain(findings_by_id, chain) +// return list(findings_by_id.values()) +// +// Python parity notes: +// +// - the harness call passes NO schema and cwd=repo_path (NOT a scratch temp +// dir and no project_dir) — the only prove agent that lets the coding agent +// work directly inside the repository. +// - the try/except swallows EVERY exception from the harness call and from +// parsing, so a transport error, a provider failure and unparseable output +// are all "no chains" and the input findings come back untouched. +// - `_apply_validated_chain` runs OUTSIDE that guard, so its AttributeError +// escapes — see ErrChainTagsNotASet. On that error the partially-mutated +// findings are returned alongside it so a caller that logs and continues +// sees exactly what Python's traceback would have left behind. +func RunChainBuilder( + ctx context.Context, + app appx.Harnesser, + repoPath string, + potentialChains []schemas.PotentialChain, + findings []schemas.VerifiedFinding, + depth string, +) ([]schemas.VerifiedFinding, error) { + if len(potentialChains) == 0 || len(findings) == 0 { + return findings, nil + } + + prompt := ChainBuilderPrompt(potentialChains, findings, depth) + + var payload *chainAnalysisPayload + res, err := app.Harness(ctx, prompt, nil, nil, harness.Options{Cwd: repoPath}) + if err == nil { + payload = parseChainPayload(res) + } + if payload == nil { + return findings, nil + } + + byID, ordered := findingsByID(findings) + out := func() []schemas.VerifiedFinding { + result := make([]schemas.VerifiedFinding, len(ordered)) + for i, entry := range ordered { + result[i] = *entry.finding + } + return result + } + + for _, raw := range payload.chains { + chain, isMap := raw.(map[string]any) + if !isMap { + // Python: `chain["validated"]` on a non-dict raises TypeError. + return out(), errors.New("chain entry is not an object") + } + if err := applyValidatedChain(byID, chain); err != nil { + return out(), err + } + } + return out(), nil +} + +// pyTruthy is Python's `bool(x)` for the JSON value kinds a decoded payload can +// hold: None, false, 0, "", [] and {} are falsy; everything else is truthy. +func pyTruthy(v any) bool { + switch x := v.(type) { + case nil: + return false + case bool: + return x + case float64: + return x != 0 + case json.Number: + f, err := x.Float64() + return err == nil && f != 0 + case string: + return x != "" + case []any: + return len(x) > 0 + case map[string]any: + return len(x) > 0 + } + return true +} + +// toFloat accepts the numeric shapes a decoded JSON value can take. +func toFloat(v any) (float64, bool) { + switch x := v.(type) { + case float64: + return x, true + case int: + return float64(x), true + case json.Number: + f, err := x.Float64() + return f, err == nil + } + return 0, false +} diff --git a/go/internal/agents/prove/chain_builder_test.go b/go/internal/agents/prove/chain_builder_test.go new file mode 100644 index 0000000..10c8dc6 --- /dev/null +++ b/go/internal/agents/prove/chain_builder_test.go @@ -0,0 +1,513 @@ +package prove + +// Tests for chain_builder.go. +// +// Validation contract: +// - no chains or no findings short-circuits and returns the input untouched; +// - the harness is called with NO schema and cwd= (the only prove agent +// that runs inside the repository); +// - a transport failure, a provider failure, unparseable output, output that +// is not a JSON object, or output whose "chains" is not a list all mean +// "no chains" and return the input untouched; +// - a chain with validated=false or empty steps mutates nothing; +// - a validated chain whose steps name no known finding mutates nothing and +// raises nothing; +// - a validated chain naming a known finding sets chain_id / chain_step / +// enables on the FIRST matching step and then fails with the Python +// AttributeError (VerifiedFinding.tags is a list, not a set). + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// chainGoldenCase mirrors one entry of apply_validated_chain.json. +type chainGoldenCase struct { + Chain map[string]any `json:"chain"` + Error *string `json:"error"` + Findings []struct { + ID string `json:"id"` + ChainID *string `json:"chain_id"` + ChainStep *int `json:"chain_step"` + Enables []string `json:"enables"` + Tags []string `json:"tags"` + } `json:"findings"` +} + +func TestApplyValidatedChainGolden(t *testing.T) { + var golden map[string]chainGoldenCase + goldenJSON(t, "apply_validated_chain", &golden) + + // Each case's finding set, keyed the same way gen_golden_prove builds it. + inputs := map[string][]schemas.VerifiedFinding{ + "not_validated": {verified("v1", "fp-v1", 1.0, 1), verified("v2", "fp-v2", 1.0, 1)}, + "no_steps": {verified("v1", "fp-v1", 1.0, 1)}, + "no_matching_finding": {verified("v1", "fp-v1", 1.0, 1)}, + "matching_finding_raises": {verified("v1", "fp-v1", 1.0, 1), verified("v2", "fp-v2", 1.0, 1)}, + "second_step_matches": {verified("v1", "fp-v1", 1.0, 1)}, + } + if len(inputs) != len(golden) { + t.Fatalf("case count drift: go has %d, golden has %d", len(inputs), len(golden)) + } + + for name, want := range golden { + findings, ok := inputs[name] + if !ok { + t.Fatalf("no Go input for golden case %q", name) + } + byID, ordered := findingsByID(findings) + err := applyValidatedChain(byID, want.Chain) + + if want.Error == nil { + if err != nil { + t.Errorf("%s: unexpected error %v", name, err) + } + } else { + if err == nil { + t.Errorf("%s: want error %q, got nil", name, *want.Error) + } else if err.Error() != *want.Error { + t.Errorf("%s: error = %q, want %q", name, err.Error(), *want.Error) + } + } + + if len(ordered) != len(want.Findings) { + t.Fatalf("%s: finding count %d, want %d", name, len(ordered), len(want.Findings)) + } + for i, entry := range ordered { + w := want.Findings[i] + got := entry.finding + if got.ID != w.ID { + t.Errorf("%s[%d]: id = %q, want %q", name, i, got.ID, w.ID) + } + if !reflect.DeepEqual(got.ChainID, w.ChainID) { + t.Errorf("%s[%d]: chain_id = %v, want %v", name, i, deref(got.ChainID), deref(w.ChainID)) + } + if !reflect.DeepEqual(got.ChainStep, w.ChainStep) { + t.Errorf("%s[%d]: chain_step = %v, want %v", name, i, got.ChainStep, w.ChainStep) + } + if !reflect.DeepEqual(got.Enables, w.Enables) { + t.Errorf("%s[%d]: enables = %v, want %v", name, i, got.Enables, w.Enables) + } + // Python never reaches the tags mutation: it raises on it. + if len(got.Tags) != len(w.Tags) { + t.Errorf("%s[%d]: tags = %v, want %v", name, i, got.Tags, w.Tags) + } + } + } +} + +func deref(p *string) any { + if p == nil { + return nil + } + return *p +} + +// TestApplyValidatedChainStepOrdering pins that the steps are sorted by +// step_number before ids are collected, so `enables` points at the NEXT step in +// exploitation order rather than the next element of the raw list. +func TestApplyValidatedChainStepOrdering(t *testing.T) { + findings := []schemas.VerifiedFinding{verified("a", "fp-a", 1, 1), verified("b", "fp-b", 1, 1)} + byID, ordered := findingsByID(findings) + chain := map[string]any{ + "chain_id": "c1", + "validated": true, + "steps": []any{ + map[string]any{"step_number": float64(2), "finding_id": "b"}, + map[string]any{"step_number": float64(1), "finding_id": "a"}, + }, + } + if err := applyValidatedChain(byID, chain); !errors.Is(err, ErrChainTagsNotASet) { + t.Fatalf("want the AttributeError parity error, got %v", err) + } + a := ordered[0].finding + if a.ChainStep == nil || *a.ChainStep != 1 { + t.Errorf("first finding chain_step = %v, want 1", a.ChainStep) + } + if !reflect.DeepEqual(a.Enables, []string{"b"}) { + t.Errorf("enables = %v, want [b] (the step AFTER sorting)", a.Enables) + } +} + +// TestFindingsByIDDuplicateSemantics pins Python dict-comprehension semantics: +// a duplicate id keeps the FIRST position and the LAST value, so the returned +// list is shorter than the input. +func TestFindingsByIDDuplicateSemantics(t *testing.T) { + first := verified("dup", "fp-1", 1, 1) + first.Title = "first" + other := verified("other", "fp-2", 1, 1) + last := verified("dup", "fp-3", 1, 1) + last.Title = "last" + + _, ordered := findingsByID([]schemas.VerifiedFinding{first, other, last}) + if len(ordered) != 2 { + t.Fatalf("want 2 entries after de-duplication, got %d", len(ordered)) + } + if ordered[0].id != "dup" || ordered[1].id != "other" { + t.Errorf("order = %q,%q, want dup,other (first-seen positions)", ordered[0].id, ordered[1].id) + } + if ordered[0].finding.Title != "last" { + t.Errorf("duplicate id must keep the LAST value, got %q", ordered[0].finding.Title) + } +} + +func TestRunChainBuilderShortCircuits(t *testing.T) { + app := &appx.Fake{} + findings := []schemas.VerifiedFinding{verified("v1", "fp-v1", 1, 1)} + + got, err := RunChainBuilder(context.Background(), app, fixtureRepo, nil, findings, "quick") + if err != nil || !reflect.DeepEqual(got, findings) { + t.Errorf("no chains must return the input untouched, got %v / %v", got, err) + } + got, err = RunChainBuilder(context.Background(), app, fixtureRepo, chainBuilderFixtureChains(), nil, "quick") + if err != nil || got != nil { + t.Errorf("no findings must return the input untouched, got %v / %v", got, err) + } + if len(app.Harnesses) != 0 { + t.Error("a short circuit must not call the harness") + } +} + +// TestRunChainBuilderHarnessOptions pins the schema-less, in-repo harness call. +func TestRunChainBuilderHarnessOptions(t *testing.T) { + app := &appx.Fake{ + HarnessFn: func(_ context.Context, _ string, schema map[string]any, dest any, opts harness.Options) (*harness.Result, error) { + if schema != nil { + t.Errorf("chain_builder passes NO schema, got %v", schema) + } + if dest != nil { + t.Errorf("chain_builder passes NO destination, got %v", dest) + } + if opts.Cwd != fixtureRepo { + t.Errorf("cwd = %q, want the repository path", opts.Cwd) + } + if opts.ProjectDir != "" { + t.Errorf("chain_builder passes no project_dir, got %q", opts.ProjectDir) + } + return &harness.Result{Result: `{"chains": []}`}, nil + }, + } + findings := []schemas.VerifiedFinding{verified("v1", "fp-v1", 1, 1)} + if _, err := RunChainBuilder(context.Background(), app, fixtureRepo, + chainBuilderFixtureChains(), findings, "standard"); err != nil { + t.Fatalf("RunChainBuilder: %v", err) + } + if len(app.Harnesses) != 1 { + t.Errorf("want exactly one harness call, got %d", len(app.Harnesses)) + } +} + +// TestRunChainBuilderSwallowsFailures pins the blanket `except Exception` +// around the harness call and the payload parse. +func TestRunChainBuilderSwallowsFailures(t *testing.T) { + findings := []schemas.VerifiedFinding{verified("v1", "fp-v1", 1, 1)} + for _, tc := range []struct { + name string + fn func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) + }{ + {"transport error", func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return nil, errors.New("connection refused") + }}, + {"provider failure", func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return &harness.Result{IsError: true, ErrorMessage: "boom"}, nil + }}, + {"empty text", func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return &harness.Result{Result: ""}, nil + }}, + {"not json", func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return &harness.Result{Result: "I could not find any chains."}, nil + }}, + {"json but not an object", func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return &harness.Result{Result: `[1, 2]`}, nil + }}, + {"chains missing", func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return &harness.Result{Result: `{"other": 1}`}, nil + }}, + {"chains not a list", func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return &harness.Result{Result: `{"chains": {"a": 1}}`}, nil + }}, + } { + app := &appx.Fake{HarnessFn: tc.fn} + got, err := RunChainBuilder(context.Background(), app, fixtureRepo, + chainBuilderFixtureChains(), findings, "standard") + if err != nil { + t.Errorf("%s: want no error, got %v", tc.name, err) + } + if !reflect.DeepEqual(got, findings) { + t.Errorf("%s: findings must come back untouched, got %v", tc.name, got) + } + } +} + +// TestRunChainBuilderNoOpChain pins the end-to-end "chains present but nothing +// validated" path: findings are returned in dict order with no mutation and no +// error. +func TestRunChainBuilderNoOpChain(t *testing.T) { + payload, _ := json.Marshal(map[string]any{ + "chains": []any{map[string]any{ + "chain_id": "c1", "title": "t", "validated": false, "rationale": "r", + "steps": []any{map[string]any{"step_number": 1, "finding_id": "v1"}}, + }}, + }) + app := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return payload, nil + })} + findings := []schemas.VerifiedFinding{verified("v1", "fp-v1", 1, 1), verified("v2", "fp-v2", 1, 1)} + + got, err := RunChainBuilder(context.Background(), app, fixtureRepo, + chainBuilderFixtureChains(), findings, "standard") + if err != nil { + t.Fatalf("RunChainBuilder: %v", err) + } + if !reflect.DeepEqual(got, findings) { + t.Errorf("a non-validated chain must mutate nothing, got %v", got) + } +} + +// TestRunChainBuilderUnknownFindingIDsIsASilentNoOp pins that `chain_id` is +// read only AFTER a step matched a known finding (chain_builder.py:96-99), so a +// model-authored chain that names only hallucinated ids — and carries no +// `chain_id` at all — mutates nothing and raises nothing. +// +// VERIFIED on the pinned interpreter: +// +// _apply_validated_chain({}, {"validated": True, "steps": [ +// {"step_number": 1, "finding_id": "hallucinated-a"}, +// {"step_number": 2, "finding_id": "hallucinated-b"}]}) -> None +// +// The chain-builder harness runs with NO schema (`app.harness(prompt, cwd=...)`, +// chain_builder.py:120), so every key in the payload is model-authored and this +// shape is reachable. +func TestRunChainBuilderUnknownFindingIDsIsASilentNoOp(t *testing.T) { + payload, _ := json.Marshal(map[string]any{ + "chains": []any{map[string]any{ + // No "chain_id" key at all. + "validated": true, + "steps": []any{ + map[string]any{"step_number": 1, "finding_id": "hallucinated-a"}, + map[string]any{"step_number": 2, "finding_id": "hallucinated-b"}, + }, + }}, + }) + app := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return payload, nil + })} + findings := []schemas.VerifiedFinding{verified("v1", "fp-v1", 1, 1), verified("v2", "fp-v2", 1, 1)} + + got, err := RunChainBuilder(context.Background(), app, fixtureRepo, + chainBuilderFixtureChains(), findings, "standard") + if err != nil { + t.Fatalf("a chain naming only unknown ids must not fail the phase: %v", err) + } + if !reflect.DeepEqual(got, findings) { + t.Errorf("findings must come back untouched, got %v", got) + } +} + +// TestRunChainBuilderMissingChainIDOnAMatchedStep is the other half: once a +// step DOES match, Python evaluates `chain["chain_id"]` and raises KeyError. +func TestRunChainBuilderMissingChainIDOnAMatchedStep(t *testing.T) { + payload, _ := json.Marshal(map[string]any{ + "chains": []any{map[string]any{ + "validated": true, + "steps": []any{map[string]any{"step_number": 1, "finding_id": "v1"}}, + }}, + }) + app := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return payload, nil + })} + findings := []schemas.VerifiedFinding{verified("v1", "fp-v1", 1, 1)} + + _, err := RunChainBuilder(context.Background(), app, fixtureRepo, + chainBuilderFixtureChains(), findings, "standard") + if err == nil || err.Error() != "'chain_id'" { + t.Fatalf("err = %v, want 'chain_id'", err) + } +} + +// TestRunChainBuilderPropagatesTagsBug pins the reproduced Python AttributeError +// and the partial mutation that precedes it. See ErrChainTagsNotASet. +func TestRunChainBuilderPropagatesTagsBug(t *testing.T) { + payload, _ := json.Marshal(map[string]any{ + "chains": []any{map[string]any{ + "chain_id": "c1", "title": "t", "validated": true, "rationale": "r", + "steps": []any{ + map[string]any{"step_number": 1, "finding_id": "v1", "description": "d", "enables": "e"}, + map[string]any{"step_number": 2, "finding_id": "v2", "description": "d", "enables": "e"}, + }, + }}, + }) + app := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return payload, nil + })} + findings := []schemas.VerifiedFinding{verified("v1", "fp-v1", 1, 1), verified("v2", "fp-v2", 1, 1)} + + got, err := RunChainBuilder(context.Background(), app, fixtureRepo, + chainBuilderFixtureChains(), findings, "standard") + if !errors.Is(err, ErrChainTagsNotASet) { + t.Fatalf("want ErrChainTagsNotASet, got %v", err) + } + if len(got) != 2 { + t.Fatalf("the partially mutated findings must still come back, got %d", len(got)) + } + if got[0].ChainID == nil || *got[0].ChainID != "c1" { + t.Errorf("the first matching finding must have been assigned the chain id, got %v", got[0].ChainID) + } + if !reflect.DeepEqual(got[0].Enables, []string{"v2"}) { + t.Errorf("enables = %v, want [v2]", got[0].Enables) + } + if got[1].ChainID != nil { + t.Error("the raise happens on the FIRST match, so later steps are untouched") + } + if len(got[0].Tags) != 0 { + t.Error("Python raises ON the tag mutation, so no tag is ever added") + } + // The caller's slice must be untouched — findingsByID works on a copy. + if findings[0].ChainID != nil { + t.Error("RunChainBuilder must not mutate the caller's slice in place") + } +} + +// TestRunProvePropagatesChainBuilderError pins that the AttributeError escapes +// run_prove, exactly as it does in Python, with the metadata pass already +// applied to the partially mutated findings. +func TestRunProvePropagatesChainBuilderError(t *testing.T) { + chainPayload, _ := json.Marshal(map[string]any{ + "chains": []any{map[string]any{ + "chain_id": "c1", "validated": true, + "steps": []any{map[string]any{"step_number": 1, "finding_id": "raw-1"}}, + }}, + }) + app := &appx.Fake{ + HarnessFn: appx.HarnessJSON(func(prompt string, _ harness.Options) (json.RawMessage, error) { + switch { + case containsRole(prompt, "DataFlowTracer"): + return json.Marshal(traceRich()) + case containsRole(prompt, "SanitizationAnalyzer"): + return json.Marshal(sanitizationRich()) + case containsRole(prompt, "ExploitHypothesizer"): + return json.Marshal(exploitRich()) + } + return chainPayload, nil + }), + AIFn: appx.AIJSON(func(string) (json.RawMessage, error) { + return json.Marshal(map[string]any{"verdict": "confirmed", "evidence_level": 5, "rationale": "r", "confidence": "high"}) + }), + } + hunt := schemas.HuntResult{ + Findings: []schemas.RawFinding{findingRich()}, + Chains: chainBuilderFixtureChains(), + } + got, err := RunProve(context.Background(), app, fixtureRepo, hunt, "standard", 3) + if !errors.Is(err, ErrChainTagsNotASet) { + t.Fatalf("want ErrChainTagsNotASet to escape RunProve, got %v", err) + } + if len(got) != 1 { + t.Fatalf("want the partially mutated finding back, got %d", len(got)) + } + if got[0].ChainID == nil || *got[0].ChainID != "c1" { + t.Errorf("chain_id = %v, want c1", got[0].ChainID) + } + // Python parity: run_chain_builder RAISES, so the SECOND _apply_metadata + // pass never runs — the score is still the one the first pass computed + // (severity critical x evidence 5 x reachability, no chain bonus). + if got[0].ExploitabilityScore != 9.0 { + t.Errorf("the post-chain metadata pass must NOT have run; score = %v, want 9", got[0].ExploitabilityScore) + } +} + +// containsRole matches a prove sub-agent's ROLE line. +func containsRole(prompt, role string) bool { + return strings.Contains(prompt, "You are "+role) +} + +// TestApplyValidatedChainMissingKeyErrorsCarryNoClassName pins the exception +// TEXT for all five subscripts `_apply_validated_chain` performs +// (chain_builder.py:88-101: chain["validated"], chain["steps"], +// step["step_number"], step["finding_id"], chain["chain_id"]). +// +// Validation contract (behaviour, measured on the pinned interpreter by +// calling the real `_apply_validated_chain` and printing `str(exc)`): +// +// {} -> 'validated' +// {"validated": True} -> 'steps' +// {"validated": True, "steps": [{}]} -> 'step_number' +// {... "steps": [{"step_number": 1}]} -> 'finding_id' +// {... "steps": [{"step_number": 1, "finding_id": "a"}]} -> 'chain_id' +// +// `str(KeyError('validated'))` is `'validated'` — the repr of the key, WITHOUT +// a `KeyError: ` prefix. The distinction is user-visible because +// `_apply_validated_chain` runs outside run_chain_builder's try/except, so the +// text lands verbatim in the audit's "Audit pipeline failed: {exc}" note and in +// its 500 body "audit execution failed: {exc}" (internal/node/audit.go). +func TestApplyValidatedChainMissingKeyErrorsCarryNoClassName(t *testing.T) { + for _, tc := range []struct { + name string + byID map[string]*schemas.VerifiedFinding + chain map[string]any + want string + }{ + {"validated", nil, map[string]any{}, "'validated'"}, + {"steps", nil, map[string]any{"validated": true}, "'steps'"}, + {"step_number", nil, + map[string]any{"validated": true, "steps": []any{map[string]any{}}}, + "'step_number'"}, + {"finding_id", nil, + map[string]any{"validated": true, "steps": []any{map[string]any{"step_number": 1.0}}}, + "'finding_id'"}, + {"chain_id", func() map[string]*schemas.VerifiedFinding { + f := verified("a", "fp-a", 1, 1) + return map[string]*schemas.VerifiedFinding{"a": &f} + }(), + map[string]any{"validated": true, "steps": []any{ + map[string]any{"step_number": 1.0, "finding_id": "a"}}}, + "'chain_id'"}, + } { + t.Run(tc.name, func(t *testing.T) { + err := applyValidatedChain(tc.byID, tc.chain) + if err == nil { + t.Fatalf("applyValidatedChain accepted %v, want an error", tc.chain) + } + if err.Error() != tc.want { + t.Errorf("err = %q, want %q (str(exc) never carries the class name)", err.Error(), tc.want) + } + }) + } +} + +// TestChainBuilderErrorsNeverNameTheirExceptionClass is the whole-file rule: +// nothing this file raises may spell a CPython exception class, because every +// one of these errors reaches the operator through `str(exc)` interpolation. +func TestChainBuilderErrorsNeverNameTheirExceptionClass(t *testing.T) { + errs := []error{ + ErrChainTagsNotASet, + applyValidatedChain(nil, map[string]any{}), + applyValidatedChain(nil, map[string]any{"validated": true}), + applyValidatedChain(nil, map[string]any{"validated": true, "steps": "notalist"}), + applyValidatedChain(nil, map[string]any{"validated": true, "steps": []any{1.0}}), + applyValidatedChain(nil, map[string]any{"validated": true, "steps": []any{map[string]any{}}}), + applyValidatedChain(nil, map[string]any{"validated": true, + "steps": []any{map[string]any{"step_number": "nope"}}}), + applyValidatedChain(nil, map[string]any{"validated": true, + "steps": []any{map[string]any{"step_number": 1.0}}}), + } + for _, err := range errs { + if err == nil { + t.Fatal("expected every probe to produce an error") + } + for _, class := range []string{"KeyError:", "TypeError:", "AttributeError:", "ValueError:"} { + if strings.Contains(err.Error(), class) { + t.Errorf("%q names an exception class; str(exc) never does", err.Error()) + } + } + } +} diff --git a/go/internal/agents/prove/cross_service.go b/go/internal/agents/prove/cross_service.go new file mode 100644 index 0000000..fc0987c --- /dev/null +++ b/go/internal/agents/prove/cross_service.go @@ -0,0 +1,80 @@ +package prove + +// Ports src/sec_af/agents/prove/cross_service.py. + +import ( + "context" + "os" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/harnessx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// crossServicePromptPath mirrors cross_service.py's module-level PROMPT_PATH. +const crossServicePromptPath = "prove/cross_service.txt" + +const ( + crossServiceAgentName = "prove-cross-service" + crossServiceExtractName = "CrossServiceAnalyzer" +) + +// crossServiceBuildPrompt ports cross_service.py `_build_prompt`. +// +// `services` is a plain `list[str]` rendered with json.dumps(indent=2); a nil +// Go slice stands for Python's empty list and must render as `[]`. +func crossServiceBuildPrompt(template string, services []string, findingsSummary, depth string) string { + if services == nil { + services = []string{} + } + return applyReplacements(template, []replacement{ + {"{{SERVICES}}", pyfmt.Dumps(services, 2)}, + {"{{FINDINGS_SUMMARY}}", findingsSummary}, + {"{{DEPTH}}", depth}, + }) +} + +// CrossServicePrompt builds the exact prompt RunCrossServiceAnalyzer sends. +// Exported for the golden test. +func CrossServicePrompt(services []string, findingsSummary, repoPath, depth string) string { + return crossServiceBuildPrompt(prompts.MustLoad(crossServicePromptPath), services, findingsSummary, depth) + + "\n\nCONTEXT:\n" + + "- Repository path: " + repoPath + "\n" + + "- Use the repository path above for cross-service inspection." +} + +// RunCrossServiceAnalyzer ports cross_service.py run_cross_service_analyzer. +// +// result = await app.harness(prompt=prompt, schema=CrossServiceFinding, +// cwd=harness_cwd, project_dir=repo_path) +// return extract_harness_result(result, CrossServiceFinding, "CrossServiceAnalyzer") +// +// Same temp-dir contract as RunTracer. This agent is registered as the +// `run_cross_service_analyzer` reasoner but is NOT part of the audit DAG — the +// orchestrator never calls it (multi-repo scanning is driven externally). +func RunCrossServiceAnalyzer( + ctx context.Context, + app appx.Harnesser, + repoPath string, + services []string, + findingsSummary string, + depth string, +) (schemas.CrossServiceFinding, error) { + prompt := CrossServicePrompt(services, findingsSummary, repoPath, depth) + + harnessCwd, err := os.MkdirTemp("", "secaf-"+crossServiceAgentName+"-") + if err != nil { + return schemas.CrossServiceFinding{}, err + } + defer os.RemoveAll(harnessCwd) + + return harnessx.RunExtract[schemas.CrossServiceFinding]( + ctx, app, prompt, + harness.Options{Cwd: harnessCwd, ProjectDir: repoPath}, + crossServiceExtractName, + ) +} diff --git a/go/internal/agents/prove/dast_verifier.go b/go/internal/agents/prove/dast_verifier.go new file mode 100644 index 0000000..0333743 --- /dev/null +++ b/go/internal/agents/prove/dast_verifier.go @@ -0,0 +1,81 @@ +package prove + +// Ports src/sec_af/agents/prove/dast_verifier.py. + +import ( + "context" + "os" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/harnessx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// dastPromptPath mirrors dast_verifier.py's module-level PROMPT_PATH. +const dastPromptPath = "prove/dast_verifier.txt" + +const ( + dastAgentName = "prove-dast" + dastExtractName = "DastVerifier" +) + +// dastBuildPrompt ports dast_verifier.py `_build_prompt`. +// +// Python parity: this builder is the SHORTEST of the prove set — no +// {{CWE_NAME}}, no {{CODE_SNIPPET}}, no {{FINDING_TYPE}}, no {{RELATED_FILES}}. +// Those markers do not appear in dast_verifier.txt either, so nothing is left +// unsubstituted. {{EXPLOIT_PAYLOAD}} is inserted BEFORE {{DEPTH}}, so a literal +// "{{DEPTH}}" inside the payload IS substituted. +func dastBuildPrompt(template string, finding schemas.RawFinding, exploitPayload, depth string) string { + return applyReplacements(template, []replacement{ + {"{{TITLE}}", finding.Title}, + {"{{DESCRIPTION}}", finding.Description}, + {"{{CWE_ID}}", finding.CweID}, + {"{{FILE_PATH}}", finding.FilePath}, + {"{{EXPLOIT_PAYLOAD}}", exploitPayload}, + {"{{DEPTH}}", depth}, + }) +} + +// DastPrompt builds the exact prompt RunDastVerifier sends. Exported for the +// golden test. +func DastPrompt(finding schemas.RawFinding, exploitPayload, repoPath, depth string) string { + return dastBuildPrompt(prompts.MustLoad(dastPromptPath), finding, exploitPayload, depth) + + "\n\nCONTEXT:\n" + + "- Repository path: " + repoPath + "\n" + + "- Use the repository path above for file inspection during DAST-style verification." +} + +// RunDastVerifier ports dast_verifier.py run_dast_verifier. +// +// result = await app.harness(prompt=prompt, schema=DastVerificationResult, +// cwd=harness_cwd, project_dir=repo_path) +// return extract_harness_result(result, DastVerificationResult, "DastVerifier") +// +// Same temp-dir contract as RunTracer. The orchestrator reaches this through +// `_run_dast_verification`, gated on AuditInput.enable_dast. +func RunDastVerifier( + ctx context.Context, + app appx.Harnesser, + repoPath string, + finding schemas.RawFinding, + exploitPayload string, + depth string, +) (schemas.DastVerificationResult, error) { + prompt := DastPrompt(finding, exploitPayload, repoPath, depth) + + harnessCwd, err := os.MkdirTemp("", "secaf-"+dastAgentName+"-") + if err != nil { + return schemas.DastVerificationResult{}, err + } + defer os.RemoveAll(harnessCwd) + + return harnessx.RunExtract[schemas.DastVerificationResult]( + ctx, app, prompt, + harness.Options{Cwd: harnessCwd, ProjectDir: repoPath}, + dastExtractName, + ) +} diff --git a/go/internal/agents/prove/dep_reachability.go b/go/internal/agents/prove/dep_reachability.go new file mode 100644 index 0000000..d1a714f --- /dev/null +++ b/go/internal/agents/prove/dep_reachability.go @@ -0,0 +1,167 @@ +package prove + +// Ports src/sec_af/agents/prove/dep_reachability.py. + +import ( + "bytes" + "context" + "encoding/json" + "os" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/harnessx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// depReachabilityPromptPath mirrors dep_reachability.py's PROMPT_PATH. +const depReachabilityPromptPath = "prove/dep_reachability.txt" + +const ( + depReachabilityAgentName = "prove-dep-reachability" + depReachabilityExtractName = "DependencyReachabilityAnalyzer" +) + +// depReachabilityBuildPrompt ports dep_reachability.py `_build_prompt`: +// +// "{{CVE}}": str(finding.get("cve", "")), +// "{{PACKAGE}}": str(finding.get("package", "")), +// "{{VULNERABLE_FUNCTION}}": str(finding.get("vulnerable_function", "")), +// "{{VERSION}}": str(finding.get("version", "")), +// "{{EVIDENCE}}": json.dumps(finding.get("evidence", {}), indent=2), +// "{{DEPTH}}": depth, +// +// This is the only prove builder whose input is an untyped dict rather than a +// pydantic model, so it inherits two Python-vs-Go JSON asymmetries. Both are +// handled here rather than left to bite the prompt bytes: +// +// 1. INT vs FLOAT. Python's json.loads makes `2` an int (str -> "2", +// json.dumps -> "2") and `2.0` a float (-> "2.0"). Go's encoding/json makes +// BOTH a float64, which pyfmt would render "2.0". normalizeJSONNumbers +// re-decodes the map with json.Number so an integral literal keeps its +// integer spelling — recovering Python's behaviour for every value that +// arrived as a JSON integer. The `2.0` spelling is unrecoverable: Go +// collapsed it before this function ever saw the map, and it renders as +// "2". Documented divergence, unreachable in practice (evidence hints are +// counts and flags). +// +// 2. KEY ORDER. json.dumps follows the dict's insertion order; a Go +// map[string]any has none, so `evidence` renders with SORTED keys +// (DESIGN.md §2b). For an `evidence` dict whose keys are already sorted the +// two are byte-identical, which is what the golden fixture pins. +// +// Truthiness note: `finding.get(key, "")` returns the DEFAULT only when the key +// is ABSENT. A key present with value None yields `str(None)` == "None", not +// "" — reproduced by looking the key up with the comma-ok form. +func depReachabilityBuildPrompt(template string, finding map[string]any, depth string) string { + normalized := normalizeJSONNumbers(finding) + + get := func(key string) any { + if v, ok := normalized[key]; ok { + return v + } + return "" // Python's `.get(key, "")` + } + evidence := any(map[string]any{}) + if v, ok := normalized["evidence"]; ok { + evidence = v + } + + return applyReplacements(template, []replacement{ + {"{{CVE}}", pyStrDynamic(get("cve"))}, + {"{{PACKAGE}}", pyStrDynamic(get("package"))}, + {"{{VULNERABLE_FUNCTION}}", pyStrDynamic(get("vulnerable_function"))}, + {"{{VERSION}}", pyStrDynamic(get("version"))}, + {"{{EVIDENCE}}", pyfmt.Dumps(evidence, 2)}, + {"{{DEPTH}}", depth}, + }) +} + +// DepReachabilityPrompt builds the exact prompt RunDepReachability sends. +// Exported for the golden test. +func DepReachabilityPrompt(finding map[string]any, repoPath, depth string) string { + return depReachabilityBuildPrompt(prompts.MustLoad(depReachabilityPromptPath), finding, depth) + + "\n\nCONTEXT:\n" + + "- Repository path: " + repoPath + "\n" + + "- Use the repository path above for file inspection during dependency reachability analysis." +} + +// RunDepReachability ports dep_reachability.py run_dep_reachability. +// +// result = await app.harness(prompt=prompt, schema=ReachabilityProof, +// cwd=harness_cwd, project_dir=repo_path) +// return extract_harness_result(result, ReachabilityProof, "DependencyReachabilityAnalyzer") +// +// Same temp-dir contract as RunTracer. +func RunDepReachability( + ctx context.Context, + app appx.Harnesser, + repoPath string, + finding map[string]any, + depth string, +) (schemas.ReachabilityProof, error) { + prompt := DepReachabilityPrompt(finding, repoPath, depth) + + harnessCwd, err := os.MkdirTemp("", "secaf-"+depReachabilityAgentName+"-") + if err != nil { + return schemas.ReachabilityProof{}, err + } + defer os.RemoveAll(harnessCwd) + + return harnessx.RunExtract[schemas.ReachabilityProof]( + ctx, app, prompt, + harness.Options{Cwd: harnessCwd, ProjectDir: repoPath}, + depReachabilityExtractName, + ) +} + +// normalizeJSONNumbers round-trips the map through JSON with UseNumber so every +// numeric leaf becomes a json.Number carrying its literal spelling. See +// depReachabilityBuildPrompt's doc for why. +// +// On any marshal/decode failure (a value encoding/json cannot represent) the +// input is returned unchanged — a prompt with a "2.0" where Python wrote "2" +// beats no prompt at all. +func normalizeJSONNumbers(finding map[string]any) map[string]any { + if finding == nil { + return map[string]any{} + } + b, err := json.Marshal(finding) + if err != nil { + return finding + } + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + var out map[string]any + if err := dec.Decode(&out); err != nil { + return finding + } + return out +} + +// pyStrDynamic is `str(v)` for a value decoded out of JSON. +// +// It exists because json.Number is a Go STRING type: handing it to pyfmt.Str +// would take the reflect.String branch and quote it. Python's json.loads turns +// a number literal without '.', 'e' or 'E' into an int (str -> the digits) and +// anything else into a float (str -> repr(float)), which is exactly the split +// reproduced here. Every other kind delegates to pyfmt.Str. +func pyStrDynamic(v any) string { + n, ok := v.(json.Number) + if !ok { + return pyfmt.Str(v) + } + s := n.String() + if !strings.ContainsAny(s, ".eE") { + return s // Python int + } + f, err := n.Float64() + if err != nil { + return s + } + return pyfmt.FormatFloat(f) // Python float, repr()-style +} diff --git a/go/internal/agents/prove/doc.go b/go/internal/agents/prove/doc.go new file mode 100644 index 0000000..7e66572 --- /dev/null +++ b/go/internal/agents/prove/doc.go @@ -0,0 +1,36 @@ +// Package prove ports src/sec_af/agents/prove — the PROVE phase. +// +// PROVE takes the provisional findings HUNT produced and decides, per finding, +// whether the vulnerability is real: it traces the data flow, analyses the +// sanitization on that path, hypothesises an exploit, asks a judgment model for +// a verdict, and assembles the evidence into a schemas.VerifiedFinding. +// +// Module map (Python file -> Go file): +// +// __init__.py prove.go _priority_sort, _apply_metadata, +// _run_parallel_verification, run_prove, +// run_prove_streaming +// verifier.py verifier.go run_verifier (the in-process +// tracer -> sanitization -> exploit -> +// verdict chain), fallback +// assembler.py assembler.go assemble_verified_finding +// tracer.py tracer.go run_tracer +// sanitization.py sanitization.go run_sanitization_analyzer +// exploit.py exploit.go run_exploit_hypothesizer +// verdict.py verdict.go run_verdict_agent (.ai, not .harness) +// chain_builder.py chain_builder.go run_chain_builder (schema-less harness) +// cross_service.py cross_service.go run_cross_service_analyzer +// dast_verifier.py dast_verifier.go run_dast_verifier +// dep_reachability.py dep_reachability.go run_dep_reachability +// sandbox.py sandbox.go run_sandboxed +// +// DAG note (DESIGN.md §3): every function here is called IN PROCESS. The +// control-plane `.call(...)` fan-out that produces the DAG's `run_verifier` +// nodes lives in internal/phases (Python: reasoners/phases.py prove_phase); +// this package is what those reasoners execute once routed. Nothing in here +// may grow an app.Call — that would double the DAG. +// +// Every exported function takes ctx first and the narrowest appx sub-interface +// it needs (Harnesser, AIer, or the union HarnessAIer), because Python passes +// the SDK agent/router and only ever touches `.harness(...)` / `.ai(...)`. +package prove diff --git a/go/internal/agents/prove/exploit.go b/go/internal/agents/prove/exploit.go new file mode 100644 index 0000000..2104cb4 --- /dev/null +++ b/go/internal/agents/prove/exploit.go @@ -0,0 +1,123 @@ +package prove + +// Ports src/sec_af/agents/prove/exploit.py. + +import ( + "context" + "os" + "strconv" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/harnessx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// exploitPromptPath mirrors exploit.py's module-level PROMPT_PATH. +const exploitPromptPath = "prove/exploit.txt" + +const ( + exploitAgentName = "prove-exploit" + exploitExtractName = "ExploitHypothesizer" +) + +// sanitizationContext ports exploit.py `_sanitization_context`: +// +// found = "yes" if sanitization.found else "no" +// sufficient = "unknown" if sanitization.sufficient is None else ("yes" if sanitization.sufficient else "no") +// return (f"Sanitization found: {found}\n" +// f"Sanitization type: {sanitization.type or 'none'}\n" +// f"Sanitization sufficient: {sufficient}\n" +// f"Bypass method: {sanitization.bypass_method or 'none'}") +// +// Python parity: `sufficient` distinguishes None (unknown) from False (no), +// which is why SanitizationResult.Sufficient is a *bool and not a bool; `type` +// and `bypass_method` use `or`, so an EMPTY string also renders as "none". +func sanitizationContext(sanitization schemas.SanitizationResult) string { + found := "no" + if sanitization.Found { + found = "yes" + } + sufficient := "unknown" + if sanitization.Sufficient != nil { + sufficient = "no" + if *sanitization.Sufficient { + sufficient = "yes" + } + } + return "Sanitization found: " + found + "\n" + + "Sanitization type: " + pyOr(sanitization.Type, "none") + "\n" + + "Sanitization sufficient: " + sufficient + "\n" + + "Bypass method: " + pyOr(sanitization.BypassMethod, "none") +} + +// exploitBuildPrompt ports exploit.py `_build_prompt`. +func exploitBuildPrompt( + template string, + finding schemas.RawFinding, + dataFlowTrace schemas.DataFlowTrace, + sanitization schemas.SanitizationResult, + depth string, +) string { + return applyReplacements(template, []replacement{ + {"{{TITLE}}", finding.Title}, + {"{{DESCRIPTION}}", finding.Description}, + {"{{CWE_ID}}", finding.CweID}, + {"{{CWE_NAME}}", finding.CweName}, + {"{{FILE_PATH}}", finding.FilePath}, + {"{{START_LINE}}", strconv.Itoa(finding.StartLine)}, + {"{{CODE_SNIPPET}}", finding.CodeSnippet}, + {"{{FINDING_TYPE}}", string(finding.FindingType)}, + {"{{RELATED_FILES}}", relatedFilesJSON(finding.RelatedFiles)}, + {"{{TRACE_CONTEXT}}", traceContext(dataFlowTrace)}, + {"{{SANITIZATION_CONTEXT}}", sanitizationContext(sanitization)}, + {"{{DEPTH}}", depth}, + }) +} + +// ExploitPrompt builds the exact prompt RunExploitHypothesizer sends. Exported +// for the golden test. +func ExploitPrompt( + finding schemas.RawFinding, + dataFlowTrace schemas.DataFlowTrace, + sanitization schemas.SanitizationResult, + repoPath, depth string, +) string { + return exploitBuildPrompt(prompts.MustLoad(exploitPromptPath), finding, dataFlowTrace, sanitization, depth) + + "\n\nCONTEXT:\n" + + "- Repository path: " + repoPath + "\n" + + "- Use the repository path above for file inspection during exploit construction." +} + +// RunExploitHypothesizer ports exploit.py run_exploit_hypothesizer. +// +// result = await app.harness(prompt=prompt, schema=ExploitHypothesis, +// cwd=harness_cwd, project_dir=repo_path) +// return extract_harness_result(result, ExploitHypothesis, "ExploitHypothesizer") +// +// Same temp-dir contract as RunTracer. +func RunExploitHypothesizer( + ctx context.Context, + app appx.Harnesser, + repoPath string, + finding schemas.RawFinding, + dataFlowTrace schemas.DataFlowTrace, + sanitization schemas.SanitizationResult, + depth string, +) (schemas.ExploitHypothesis, error) { + prompt := ExploitPrompt(finding, dataFlowTrace, sanitization, repoPath, depth) + + harnessCwd, err := os.MkdirTemp("", "secaf-"+exploitAgentName+"-") + if err != nil { + return schemas.ExploitHypothesis{}, err + } + defer os.RemoveAll(harnessCwd) + + return harnessx.RunExtract[schemas.ExploitHypothesis]( + ctx, app, prompt, + harness.Options{Cwd: harnessCwd, ProjectDir: repoPath}, + exploitExtractName, + ) +} diff --git a/go/internal/agents/prove/golden_test.go b/go/internal/agents/prove/golden_test.go new file mode 100644 index 0000000..6abaed8 --- /dev/null +++ b/go/internal/agents/prove/golden_test.go @@ -0,0 +1,208 @@ +package prove + +// Shared helpers and fixtures for the golden tests in this package. +// +// Every fixture under testdata/golden is produced by go/scripts/gen_golden_prove.py +// running the REAL Python code from src/sec_af/agents/prove. Regenerate with: +// +// PYTHONPATH=src ~/.agentfield/packages/sec-af/venv/bin/python go/scripts/gen_golden.py +// +// A test failing here means the Go port and the Python source disagree about +// bytes that reach the LLM (prompts) or about the shape of a model that crosses +// the wire — not that a fixture needs refreshing. Refresh only after a +// deliberate Python change. +// +// The Go literals below repeat the Python literals in gen_golden_prove.py +// EXACTLY. Keeping the two in sync by hand is the point: a drift in either is a +// test failure rather than a silent divergence. + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +const goldenDir = "testdata/golden" + +// fixtureRepo matches gen_golden_prove.FIXTURE_REPO. +const fixtureRepo = "/fixtures/demo-repo" + +func goldenText(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(goldenDir, name+".txt")) + if err != nil { + t.Fatalf("read golden %s.txt: %v", name, err) + } + return string(b) +} + +func goldenJSON(t *testing.T, name string, dest any) { + t.Helper() + b, err := os.ReadFile(filepath.Join(goldenDir, name+".json")) + if err != nil { + t.Fatalf("read golden %s.json: %v", name, err) + } + if err := json.Unmarshal(b, dest); err != nil { + t.Fatalf("decode golden %s.json: %v", name, err) + } +} + +// jsonTree marshals v and decodes the result into the untyped shape the golden +// fixtures decode to, so the two can be compared with reflect.DeepEqual without +// either side's Go types leaking into the comparison. +func jsonTree(t *testing.T, v any) any { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal %T: %v", v, err) + } + var tree any + if err := json.Unmarshal(b, &tree); err != nil { + t.Fatalf("unmarshal %T: %v", v, err) + } + return tree +} + +func str(s string) *string { return &s } +func boolp(b bool) *bool { return &b } + +// findingRich mirrors gen_golden_prove._finding_rich, including the two +// substitution-order traps ("{{TITLE}}" in the description must survive, +// "{{DEPTH}}" in the snippet must be replaced). +func findingRich() schemas.RawFinding { + return schemas.RawFinding{ + ID: "raw-1", + HunterStrategy: "injection", + Title: "Potential SQL injection in user lookup", + Description: "Request parameter reaches a formatted SQL string. Marker: {{TITLE}}", + FindingType: schemas.FindingTypeSast, + CweID: "CWE-89", + CweName: "Improper Neutralization/Escaping of Special Elements", + OwaspCategory: str("A03:2021 - Injection"), + FilePath: "src/users.py", + StartLine: 42, + EndLine: 44, + FunctionName: str("get_user"), + CodeSnippet: `cursor.execute("SELECT * FROM users WHERE id = " + user_id) # depth={{DEPTH}}`, + EstimatedSeverity: schemas.SeverityHigh, + Confidence: schemas.ConfidenceHigh, + DataFlow: []schemas.ReconDataFlowStep{ + {FilePath: "src/routes.py", Line: 10, Component: "handler", Operation: "read request.args"}, + {FilePath: "src/users.py", Line: 42, Component: "db", Operation: "execute"}, + }, + RelatedFiles: []string{"src/routes.py", "src/café &.py"}, + Fingerprint: "fp-1", + } +} + +// findingBare mirrors gen_golden_prove._finding_bare — every optional at its +// pydantic default. +func findingBare() schemas.RawFinding { + return schemas.RawFinding{ + ID: "raw-2", + HunterStrategy: "crypto", + Title: "Weak hash", + Description: "MD5 used for password hashing", + FindingType: schemas.FindingTypeSast, + CweID: "CWE-327", + CweName: "Broken Crypto", + FilePath: "src/hash.py", + StartLine: 7, + EndLine: 7, + CodeSnippet: "hashlib.md5(pw).hexdigest()", + EstimatedSeverity: schemas.SeverityMedium, + Confidence: schemas.ConfidenceLow, + RelatedFiles: []string{}, + Fingerprint: "fp-2", + } +} + +func traceRich() schemas.DataFlowTrace { + return schemas.DataFlowTrace{ + Source: "request.args['id']", + Sink: "cursor.execute(query)", + Steps: []string{"src/routes.py:10 read request.args", "src/users.py:42 execute"}, + SinkReached: true, + } +} + +func traceBare() schemas.DataFlowTrace { + return schemas.DataFlowTrace{Source: "unknown", Sink: "unknown", Steps: []string{}, SinkReached: false} +} + +func sanitizationRich() schemas.SanitizationResult { + return schemas.SanitizationResult{ + Found: true, + Type: str("parameterized query"), + Sufficient: boolp(false), + BypassMethod: str("second-order injection through the audit log"), + } +} + +func sanitizationBare() schemas.SanitizationResult { + return schemas.SanitizationResult{Found: false} +} + +func exploitRich() schemas.ExploitHypothesis { + return schemas.ExploitHypothesis{ + Hypothesis: "Attacker supplies id=1 OR 1=1 to dump the table", + Payload: str("1 OR 1=1"), + ExpectedOutcome: "Full users table returned", + } +} + +func exploitBare() schemas.ExploitHypothesis { + return schemas.ExploitHypothesis{Hypothesis: "unknown", ExpectedOutcome: "unknown"} +} + +func verdictDecision(verdict string, level int) schemas.VerdictDecision { + return schemas.VerdictDecision{ + Verdict: verdict, + EvidenceLevel: level, + Rationale: "rationale for " + verdict, + Confidence: "high", + } +} + +// verified mirrors gen_golden_prove._verified. +func verified(id, fingerprint string, score float64, level int, tags ...string) schemas.VerifiedFinding { + if tags == nil { + tags = []string{} + } + return schemas.VerifiedFinding{ + ID: id, + Fingerprint: fingerprint, + Title: "finding " + id, + Description: "d", + FindingType: schemas.FindingTypeSast, + CweID: "CWE-89", + CweName: "SQL Injection", + Verdict: schemas.VerdictConfirmed, + EvidenceLevel: schemas.EvidenceLevel(level), + Rationale: "r", + Severity: schemas.SeverityHigh, + ExploitabilityScore: score, + Location: schemas.Location{FilePath: "src/users.py", StartLine: 1, EndLine: 2}, + Tags: tags, + RelatedLocations: []schemas.Location{}, + Compliance: []schemas.ComplianceMapping{}, + ReproductionSteps: []schemas.ReproductionStep{}, + SarifRuleID: "sec-af/sast/sql-injection", + SarifSecuritySeverity: score, + } +} + +// chainBuilderFixtureChains mirrors the `chains` list gen_golden_prove builds +// for the chain-builder prompt fixture. +func chainBuilderFixtureChains() []schemas.PotentialChain { + return []schemas.PotentialChain{{ + ChainID: "chain-1", + Title: "SSRF to internal API", + FindingIDs: []string{"v1", "v2"}, + CombinedImpact: "Internal service access", + EstimatedSeverity: schemas.SeverityCritical, + }} +} diff --git a/go/internal/agents/prove/prompts_test.go b/go/internal/agents/prove/prompts_test.go new file mode 100644 index 0000000..4e90dc3 --- /dev/null +++ b/go/internal/agents/prove/prompts_test.go @@ -0,0 +1,218 @@ +package prove + +// Golden prompt tests: every string this package hands to an LLM, compared +// byte-for-byte against the output of the real Python builder. +// +// Contract items pinned here (see DESIGN.md §5): +// - each prompt equals the Python builder's output for the same inputs; +// - the trailing CONTEXT block is present (and absent for verdict/chain); +// - substitution ORDER is preserved: "{{TITLE}}" placed inside a value +// substituted AFTER it survives, "{{DEPTH}}" placed inside a value +// substituted BEFORE it is itself replaced. + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +func TestTracerPromptGolden(t *testing.T) { + got := TracerPrompt(findingRich(), fixtureRepo, "thorough") + if want := goldenText(t, "tracer_prompt_A"); got != want { + t.Errorf("tracer_prompt_A mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } + got = TracerPrompt(findingBare(), fixtureRepo, "quick") + if want := goldenText(t, "tracer_prompt_B"); got != want { + t.Errorf("tracer_prompt_B mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +// TestTracerPromptSubstitutionOrder states the ordering contract explicitly, so +// a regression names the cause rather than dumping two 1.5 KB prompts. +func TestTracerPromptSubstitutionOrder(t *testing.T) { + got := TracerPrompt(findingRich(), fixtureRepo, "thorough") + if !strings.Contains(got, "Marker: {{TITLE}}") { + t.Error("{{TITLE}} inside the description must survive: TITLE is substituted before DESCRIPTION") + } + if strings.Contains(got, "depth={{DEPTH}}") { + t.Error("{{DEPTH}} inside the code snippet must be replaced: DEPTH is substituted after CODE_SNIPPET") + } + if !strings.Contains(got, "depth=thorough") { + t.Error("{{DEPTH}} inside the code snippet should have become the depth string") + } +} + +func TestSanitizationPromptGolden(t *testing.T) { + got := SanitizationPrompt(findingRich(), traceRich(), fixtureRepo, "thorough") + if want := goldenText(t, "sanitization_prompt_A"); got != want { + t.Errorf("sanitization_prompt_A mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } + got = SanitizationPrompt(findingBare(), traceBare(), fixtureRepo, "quick") + if want := goldenText(t, "sanitization_prompt_B"); got != want { + t.Errorf("sanitization_prompt_B mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func TestExploitPromptGolden(t *testing.T) { + got := ExploitPrompt(findingRich(), traceRich(), sanitizationRich(), fixtureRepo, "thorough") + if want := goldenText(t, "exploit_prompt_A"); got != want { + t.Errorf("exploit_prompt_A mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } + got = ExploitPrompt(findingBare(), traceBare(), sanitizationBare(), fixtureRepo, "quick") + if want := goldenText(t, "exploit_prompt_B"); got != want { + t.Errorf("exploit_prompt_B mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func TestVerdictPromptGolden(t *testing.T) { + got := VerdictPrompt(findingRich(), traceRich(), sanitizationRich(), exploitRich()) + if want := goldenText(t, "verdict_prompt_A"); got != want { + t.Errorf("verdict_prompt_A mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } + got = VerdictPrompt(findingBare(), traceBare(), sanitizationBare(), exploitBare()) + if want := goldenText(t, "verdict_prompt_B"); got != want { + t.Errorf("verdict_prompt_B mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +// TestVerdictPromptHasNoContextBlock and TestVerdictPromptKeepsDepthMarker pin +// the two things verdict.py does differently from every other prove agent. +func TestVerdictPromptHasNoContextBlock(t *testing.T) { + got := VerdictPrompt(findingRich(), traceRich(), sanitizationRich(), exploitRich()) + if strings.Contains(got, "- Repository path: ") { + t.Error("verdict.py appends no CONTEXT block — VerdictAgent needs no file access") + } +} + +func TestVerdictPromptKeepsDepthMarker(t *testing.T) { + got := VerdictPrompt(findingRich(), traceRich(), sanitizationRich(), exploitRich()) + if !strings.Contains(got, "depth={{DEPTH}}") { + t.Error("verdict._build_prompt has no {{DEPTH}} entry, so the marker must survive verbatim") + } +} + +func TestDepReachabilityPromptGolden(t *testing.T) { + var inputA map[string]any + goldenJSON(t, "dep_reachability_input_A", &inputA) + got := DepReachabilityPrompt(inputA, fixtureRepo, "thorough") + if want := goldenText(t, "dep_reachability_prompt_A"); got != want { + t.Errorf("dep_reachability_prompt_A mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } + + // An EMPTY dict: every `.get(key, "")` falls back and `evidence` is `{}`. + got = DepReachabilityPrompt(map[string]any{}, fixtureRepo, "quick") + if want := goldenText(t, "dep_reachability_prompt_B"); got != want { + t.Errorf("dep_reachability_prompt_B mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } + + // Non-string scalars exercise `str(...)`: an int keeps its integer + // spelling, a float gets repr(float), True/None stringify Python-style, + // and a key present with a null value is "None" rather than "". + var inputC map[string]any + goldenJSON(t, "dep_reachability_input_C", &inputC) + got = DepReachabilityPrompt(inputC, fixtureRepo, "standard") + if want := goldenText(t, "dep_reachability_prompt_C"); got != want { + t.Errorf("dep_reachability_prompt_C mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +// TestDepReachabilityPromptNilFinding covers the Go-only nil-map case: Python +// always receives a dict, and a nil Go map must behave like the empty one. +func TestDepReachabilityPromptNilFinding(t *testing.T) { + got := DepReachabilityPrompt(nil, fixtureRepo, "quick") + if want := goldenText(t, "dep_reachability_prompt_B"); got != want { + t.Errorf("nil finding must render like the empty dict:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +// TestDepReachabilityIntegerSpelling pins the json.Number normalization: the +// SDK hands the handler a map whose numbers are float64, and an integral value +// must still render the way Python's int does ("2", not "2.0"). +func TestDepReachabilityIntegerSpelling(t *testing.T) { + got := DepReachabilityPrompt(map[string]any{ + "cve": float64(1234), + "evidence": map[string]any{"count": float64(2), "ratio": 0.5}, + }, fixtureRepo, "quick") + if !strings.Contains(got, "- CVE: 1234\n") { + t.Errorf("integral float64 must render as a Python int; got:\n%s", got) + } + if !strings.Contains(got, `"count": 2,`) { + t.Errorf("integral float64 inside evidence must render as a Python int; got:\n%s", got) + } + if !strings.Contains(got, `"ratio": 0.5`) { + t.Errorf("a real float must keep its fraction; got:\n%s", got) + } +} + +// TestDepReachabilityEvidenceKeyOrder documents the ONE accepted divergence +// from Python in this package: json.dumps follows dict insertion order, a Go +// map has none, so evidence keys come out sorted (DESIGN.md §2b). +func TestDepReachabilityEvidenceKeyOrder(t *testing.T) { + got := DepReachabilityPrompt(map[string]any{ + "evidence": map[string]any{"zeta": 1, "alpha": 2}, + }, fixtureRepo, "quick") + if !strings.Contains(got, "{\n \"alpha\": 2,\n \"zeta\": 1\n}") { + t.Errorf("evidence must render with SORTED keys; got:\n%s", got) + } +} + +func TestDastPromptGolden(t *testing.T) { + got := DastPrompt(findingRich(), "1 OR 1=1 -- {{DEPTH}}", fixtureRepo, "thorough") + if want := goldenText(t, "dast_prompt_A"); got != want { + t.Errorf("dast_prompt_A mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } + got = DastPrompt(findingBare(), "", fixtureRepo, "quick") + if want := goldenText(t, "dast_prompt_B"); got != want { + t.Errorf("dast_prompt_B mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func TestCrossServicePromptGolden(t *testing.T) { + got := CrossServicePrompt( + []string{"gateway", "billing-café", "db<&>"}, + "- gateway: SSRF\n- billing: IDOR", + fixtureRepo, "thorough") + if want := goldenText(t, "cross_service_prompt_A"); got != want { + t.Errorf("cross_service_prompt_A mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } + got = CrossServicePrompt([]string{}, "", fixtureRepo, "quick") + if want := goldenText(t, "cross_service_prompt_B"); got != want { + t.Errorf("cross_service_prompt_B mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } + // A nil slice stands for Python's empty list, never for null. + if got := CrossServicePrompt(nil, "", fixtureRepo, "quick"); got != goldenText(t, "cross_service_prompt_B") { + t.Error("a nil services slice must render as [] like Python's empty list") + } +} + +func TestChainBuilderPromptGolden(t *testing.T) { + var input struct { + Depth string `json:"depth"` + Chains []json.RawMessage `json:"chains"` + Findings []json.RawMessage `json:"findings"` + } + goldenJSON(t, "chain_builder_prompt_input", &input) + if input.Depth != "standard" || len(input.Chains) != 1 || len(input.Findings) != 2 { + t.Fatalf("unexpected chain_builder_prompt_input shape: %+v", input) + } + + chains := chainBuilderFixtureChains() + findings := []schemas.VerifiedFinding{verified("v1", "fp-v1", 7.5, 4), verified("v2", "fp-v2", 3.0, 2)} + + got := ChainBuilderPrompt(chains, findings, "standard") + if want := goldenText(t, "chain_builder_prompt"); got != want { + t.Errorf("chain_builder_prompt mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +// TestChainBuilderPromptHasNoContextBlock: like verdict.py, chain_builder.py +// sends the substituted template alone. +func TestChainBuilderPromptHasNoContextBlock(t *testing.T) { + got := ChainBuilderPrompt(chainBuilderFixtureChains(), + []schemas.VerifiedFinding{verified("v1", "fp-v1", 7.5, 4)}, "standard") + if strings.Contains(got, "- Repository path: ") { + t.Error("chain_builder.py appends no CONTEXT block") + } +} diff --git a/go/internal/agents/prove/prove.go b/go/internal/agents/prove/prove.go new file mode 100644 index 0000000..a31ab55 --- /dev/null +++ b/go/internal/agents/prove/prove.go @@ -0,0 +1,403 @@ +package prove + +// Ports src/sec_af/agents/prove/__init__.py — the PROVE phase driver that +// prioritizes findings, fans verification out under a semaphore, decorates each +// result with scoring/compliance metadata, and (when HUNT proposed chains) +// hands the set to the chain builder. + +import ( + "context" + "sort" + "strings" + "sync" + + "golang.org/x/sync/semaphore" + + "github.com/Agent-Field/sec-af/go/internal/compliance" + "github.com/Agent-Field/sec-af/go/internal/config" + "github.com/Agent-Field/sec-af/go/internal/schemas" + "github.com/Agent-Field/sec-af/go/internal/scoring" +) + +// DefaultMaxConcurrentProvers is the `max_concurrent_provers: int = 3` default +// shared by run_prove and run_prove_streaming. +const DefaultMaxConcurrentProvers = 3 + +// DefaultProverCap is run_prove_streaming's `prover_cap: int = 30` default. +const DefaultProverCap = 30 + +// severityRank / confidenceRank port `_SEVERITY_RANK` and `_CONFIDENCE_RANK`. +// Both are looked up with `.get(key, 0)`, so an unknown value ranks below INFO +// / LOW rather than raising. +var severityRank = map[schemas.Severity]int{ + schemas.SeverityCritical: 5, + schemas.SeverityHigh: 4, + schemas.SeverityMedium: 3, + schemas.SeverityLow: 2, + schemas.SeverityInfo: 1, +} + +var confidenceRank = map[schemas.Confidence]int{ + schemas.ConfidenceHigh: 3, + schemas.ConfidenceMedium: 2, + schemas.ConfidenceLow: 1, +} + +// PrioritySort ports `_priority_sort`: +// +// return sorted(findings, +// key=lambda f: (_SEVERITY_RANK.get(f.estimated_severity, 0), +// _CONFIDENCE_RANK.get(f.confidence, 0)), +// reverse=True) +// +// Python parity: `sorted(..., reverse=True)` returns a NEW list and is STABLE — +// reversing does not reverse ties, so findings with the same +// (severity, confidence) keep their input order. sort.SliceStable over a copy +// with a strict greater-than comparator is exactly that; sort.Slice would be +// free to permute ties. +func PrioritySort(findings []schemas.RawFinding) []schemas.RawFinding { + out := make([]schemas.RawFinding, len(findings)) + copy(out, findings) + sort.SliceStable(out, func(i, j int) bool { + si, sj := severityRank[out[i].EstimatedSeverity], severityRank[out[j].EstimatedSeverity] + if si != sj { + return si > sj + } + return confidenceRank[out[i].Confidence] > confidenceRank[out[j].Confidence] + }) + return out +} + +// ApplyMetadata ports `_apply_metadata`: +// +// finding.severity = apply_cwe_severity_floor(finding.cwe_id, finding.severity) +// finding.compliance = get_compliance_mappings(finding.cwe_id) +// finding.exploitability_score = compute_exploitability_score(finding) +// finding.sarif_security_severity = finding.exploitability_score +// if not finding.sarif_rule_id: +// finding.sarif_rule_id = f"sec-af/{finding.finding_type.value}/{cwe_slug}" +// return finding +// +// ORDER IS LOAD-BEARING: the severity floor is applied BEFORE the exploitability +// score is computed, so a floored severity feeds the score. The rule-id backfill +// runs last and only when the id is falsy (empty string). +// +// Python mutates the model in place and returns it; Go takes and returns a +// value. Every call site rebuilds its list from the return value, so the two are +// observably identical. +// +// `get_compliance_mappings(finding.cwe_id)` is called with the default +// `frameworks=None`, which the Go signature spells as a nil slice. +func ApplyMetadata(finding schemas.VerifiedFinding) schemas.VerifiedFinding { + finding.Severity = scoring.ApplyCWESeverityFloor(finding.CweID, finding.Severity) + finding.Compliance = compliance.GetComplianceMappings(finding.CweID, nil) + finding.ExploitabilityScore = scoring.ComputeExploitabilityScore(finding) + finding.SarifSecuritySeverity = finding.ExploitabilityScore + if finding.SarifRuleID == "" { + finding.SarifRuleID = sarifRuleID(finding.FindingType, finding.CweName) + } + return finding +} + +// demoteOnError ports the `except BaseException` body shared by +// `_run_parallel_verification._verify` and `run_prove_streaming._verify_one`: +// +// message = str(exc) +// lowered = message.lower() +// if "unverified" in lowered and "verdict" in lowered: +// return verifier_fallback(finding, +// "Verifier returned unverified verdict; demoted for manual review", +// drop_reason="verdict_unverified", original_verdict="unverified") +// drop_reason = "schema_parse_failure" if "validationerror" in lowered else "verifier_error" +// return verifier_fallback(finding, message, drop_reason=drop_reason) +// +// Python parity: the "validationerror" probe is a substring test with NO space, +// so a pydantic ValidationError — whose str() begins "1 validation error for +// ..." — does NOT match it here. The branch is effectively unreachable through +// this path (reasoners/phases.py classifies parse failures separately, which is +// what tests/test_prove_phase_demotion.py exercises). It is ported verbatim +// rather than "fixed": changing it would change which findings get the +// schema_parse_failure drop reason. +func demoteOnError(finding schemas.RawFinding, err error) schemas.VerifiedFinding { + message := err.Error() + lowered := strings.ToLower(message) + if strings.Contains(lowered, "unverified") && strings.Contains(lowered, "verdict") { + return Fallback(finding, + "Verifier returned unverified verdict; demoted for manual review", + StrPtr("verdict_unverified"), StrPtr("unverified")) + } + dropReason := "verifier_error" + if strings.Contains(lowered, "validationerror") { + dropReason = "schema_parse_failure" + } + return Fallback(finding, message, StrPtr(dropReason), nil) +} + +// runParallelVerification ports `_run_parallel_verification`: +// +// if not findings: return [] +// concurrency_limit = max(1, min(max_concurrent_provers, len(findings))) +// semaphore = asyncio.Semaphore(concurrency_limit) +// async def _verify(finding): async with semaphore: try: ... except: ... +// return await asyncio.gather(*[_verify(f) for f in findings]) +// +// Concurrency parity: +// +// - the limit is `max(1, min(max_concurrent_provers, len(findings)))`, so a +// zero or negative max_concurrent_provers still admits one prover at a time +// and the semaphore is never wider than the work; +// - RESULT ORDER follows INPUT order (asyncio.gather preserves it), so results +// go into a pre-indexed slice, not an append-as-they-finish list; +// - `_verify` catches BaseException and always returns a VerifiedFinding, so +// the plain `gather` (no return_exceptions) never sees an exception. Go uses +// a WaitGroup for the same reason — there is no error to propagate. +// - a semaphore acquire that fails (only possible once ctx is done) is the +// closest analogue of asyncio cancelling a queued task, and is funnelled +// through demoteOnError so the finding is still reported. +func runParallelVerification( + ctx context.Context, + app HarnessAIer, + repoPath string, + findings []schemas.RawFinding, + depth string, + maxConcurrentProvers int, +) []schemas.VerifiedFinding { + if len(findings) == 0 { + return []schemas.VerifiedFinding{} + } + + limit := maxConcurrentProvers + if limit > len(findings) { + limit = len(findings) + } + if limit < 1 { + limit = 1 + } + sem := semaphore.NewWeighted(int64(limit)) + + out := make([]schemas.VerifiedFinding, len(findings)) + var wg sync.WaitGroup + for i := range findings { + wg.Add(1) + go func(idx int, finding schemas.RawFinding) { + defer wg.Done() + if err := sem.Acquire(ctx, 1); err != nil { + out[idx] = demoteOnError(finding, err) + return + } + defer sem.Release(1) + verified, err := RunVerifier(ctx, app, repoPath, finding, depth) + if err != nil { + out[idx] = demoteOnError(finding, err) + return + } + out[idx] = verified + }(i, findings[i]) + } + wg.Wait() + return out +} + +// sortByScore ports the tail both run_prove and run_prove_streaming share: +// +// verified.sort(key=lambda f: (f.exploitability_score, f.evidence_level), reverse=True) +// +// In-place, stable, descending on the pair. EvidenceLevel is an IntEnum, so the +// second key compares as an integer. +func sortByScore(findings []schemas.VerifiedFinding) { + sort.SliceStable(findings, func(i, j int) bool { + if findings[i].ExploitabilityScore != findings[j].ExploitabilityScore { + return findings[i].ExploitabilityScore > findings[j].ExploitabilityScore + } + return findings[i].EvidenceLevel > findings[j].EvidenceLevel + }) +} + +// RunProve ports `run_prove`. +// +// profile = _normalize_depth(depth) +// prioritized = _priority_sort(hunt_result.findings) +// verified = await _run_parallel_verification(app, repo_path, prioritized, profile.value, max_concurrent_provers) +// verified = [_apply_metadata(f) for f in verified] +// if hunt_result.chains: +// verified = await run_chain_builder(app=..., repo_path=..., potential_chains=hunt_result.chains, +// findings=verified, depth=profile.value) +// verified = [_apply_metadata(f) for f in verified] +// verified.sort(key=lambda f: (f.exploitability_score, f.evidence_level), reverse=True) +// return verified +// +// Python parity notes: +// +// - the depth string handed to the sub-agents is the NORMALIZED profile value, +// so an unrecognised depth reaches the prompts as "standard"; +// - `_apply_metadata` runs TWICE when chains are present — the second pass +// re-scores the findings the chain builder tagged with a chain_id, which is +// what turns a chained finding's exploitability score into the 2x +// chain-bonus form; +// - run_chain_builder's AttributeError (see ErrChainTagsNotASet) propagates +// out of run_prove, exactly as it does in Python. The partially-mutated +// findings are returned alongside the error. +func RunProve( + ctx context.Context, + app HarnessAIer, + repoPath string, + huntResult schemas.HuntResult, + depth string, + maxConcurrentProvers int, +) ([]schemas.VerifiedFinding, error) { + profile := config.NormalizeDepth(depth) + prioritized := PrioritySort(huntResult.Findings) + + verified := runParallelVerification(ctx, app, repoPath, prioritized, profile.String(), maxConcurrentProvers) + for i := range verified { + verified[i] = ApplyMetadata(verified[i]) + } + + if len(huntResult.Chains) > 0 { + chained, err := RunChainBuilder(ctx, app, repoPath, huntResult.Chains, verified, profile.String()) + if err != nil { + // Python parity: run_chain_builder RAISES here, so neither the + // second _apply_metadata pass nor the final sort ever runs. The + // partially-mutated findings are handed back purely so a Go caller + // can log them; Python has no return value at all on this path. + return chained, err + } + verified = chained + for i := range verified { + verified[i] = ApplyMetadata(verified[i]) + } + } + + sortByScore(verified) + return verified, nil +} + +// RunProveStreaming ports `run_prove_streaming` — the incremental variant the +// orchestrator drives while HUNT is still producing findings. +// +// profile = _normalize_depth(depth) +// semaphore = asyncio.Semaphore(max(1, max_concurrent_provers)) +// while True: +// batch = await findings_queue.get() +// if batch is None: break +// for finding in batch: +// if proved_count >= prover_cap: break +// pending_tasks.append(asyncio.create_task(_verify_one(finding))) +// proved_count += 1 +// if proved_count >= prover_cap: +// while True: # drain to the sentinel +// remaining = await findings_queue.get() +// if remaining is None: break +// break +// if pending_tasks: +// results = await asyncio.gather(*pending_tasks, return_exceptions=True) +// for result in results: +// if isinstance(result, BaseException): continue +// verified.append(_apply_metadata(result)) +// verified.sort(key=lambda f: (f.exploitability_score, f.evidence_level), reverse=True) +// +// The Go channel stands in for the asyncio.Queue; a NIL batch is the `None` +// sentinel. An EMPTY-but-non-nil batch is a real (empty) batch and does NOT +// terminate the loop, matching Python where `[]` is not None. +// +// Python parity notes: +// +// - tasks START as soon as they are created (asyncio.create_task schedules +// immediately), bounded only by the semaphore — so verification of batch N +// overlaps arrival of batch N+1. The Go port launches a goroutine per +// finding at the same moment for the same reason. +// - the semaphore here is `max(1, max_concurrent_provers)` — NOT clamped to +// the finding count as in _run_parallel_verification, because the total is +// not known up front. +// - once the cap is hit the producer is drained to its sentinel rather than +// abandoned, which is what keeps the producing side from blocking forever +// on a full queue. +// - `_verify_one` swallows every exception into a Fallback, so the +// `isinstance(result, BaseException): continue` filter never actually drops +// a finding. Ported as a nil-error check for the same reason as +// runParallelVerification. +// - the results keep TASK CREATION order before the final sort, which is the +// queue arrival order. +// +// Unlike RunProve there is no chain-builder pass here, so no error can escape; +// the signature returns only the findings. +func RunProveStreaming( + ctx context.Context, + app HarnessAIer, + repoPath string, + findingsQueue <-chan []schemas.RawFinding, + depth string, + maxConcurrentProvers int, + proverCap int, +) []schemas.VerifiedFinding { + profile := config.NormalizeDepth(depth) + + limit := maxConcurrentProvers + if limit < 1 { + limit = 1 + } + sem := semaphore.NewWeighted(int64(limit)) + + var ( + wg sync.WaitGroup + mu sync.Mutex + pending = map[int]schemas.VerifiedFinding{} + provedCount int + ) + + // Results land in a MAP keyed by task-creation index rather than a slice: + // slots are handed out while goroutines are already running, and appending + // to a shared slice would reallocate the backing array out from under a + // concurrent write. The map is materialized in slot order after Wait. + verifyOne := func(slot int, finding schemas.RawFinding) { + defer wg.Done() + result := func() schemas.VerifiedFinding { + if err := sem.Acquire(ctx, 1); err != nil { + return demoteOnError(finding, err) + } + defer sem.Release(1) + verified, err := RunVerifier(ctx, app, repoPath, finding, profile.String()) + if err != nil { + return demoteOnError(finding, err) + } + return verified + }() + mu.Lock() + pending[slot] = result + mu.Unlock() + } + + // Python loops on `await queue.get()` forever and relies on the None + // sentinel. Go's range additionally ends when the producer CLOSES the + // channel, which Python would hang on; that is a strictly safer superset. + for batch := range findingsQueue { + if batch == nil { + break + } + for _, finding := range batch { + if provedCount >= proverCap { + break + } + wg.Add(1) + go verifyOne(provedCount, finding) + provedCount++ + } + if provedCount >= proverCap { + // Drain the producer to its sentinel, then stop consuming. + for remaining := range findingsQueue { + if remaining == nil { + break + } + } + break + } + } + wg.Wait() + + verified := make([]schemas.VerifiedFinding, 0, len(pending)) + for slot := 0; slot < provedCount; slot++ { + verified = append(verified, ApplyMetadata(pending[slot])) + } + sortByScore(verified) + return verified +} diff --git a/go/internal/agents/prove/prove_test.go b/go/internal/agents/prove/prove_test.go new file mode 100644 index 0000000..a969656 --- /dev/null +++ b/go/internal/agents/prove/prove_test.go @@ -0,0 +1,759 @@ +package prove + +// Tests for prove.go — the PROVE driver. +// +// Validation contract: +// - PrioritySort orders by (severity rank, confidence rank) DESCENDING and is +// stable for ties; the input slice is not mutated; +// - ApplyMetadata applies the CWE severity floor BEFORE scoring, fills the +// compliance mappings, mirrors the score into sarif_security_severity, and +// backfills the rule id only when it is empty; +// - RunProve verifies every finding, applies metadata, and returns the set +// sorted by (exploitability_score, evidence_level) descending; +// - RunProve's verification fan-out never exceeds +// max(1, min(max_concurrent_provers, len(findings))) in flight; +// - a failing sub-agent demotes that finding via Fallback instead of failing +// the phase; +// - RunProveStreaming consumes batches until the nil sentinel, stops +// scheduling at prover_cap, drains the producer afterwards, and bounds +// concurrency at max(1, max_concurrent_provers). + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// cannedApp mirrors gen_golden_prove._CannedApp: it recognises each prove +// sub-agent by its ROLE line and answers with a fixed model. +func cannedApp(verdict string, level int) *appx.Fake { + return &appx.Fake{ + HarnessFn: appx.HarnessJSON(func(prompt string, _ harness.Options) (json.RawMessage, error) { + switch { + case strings.Contains(prompt, "You are DataFlowTracer"): + return json.Marshal(traceRich()) + case strings.Contains(prompt, "You are SanitizationAnalyzer"): + return json.Marshal(sanitizationRich()) + case strings.Contains(prompt, "You are ExploitHypothesizer"): + return json.Marshal(exploitRich()) + } + return nil, errors.New("unexpected harness prompt") + }), + AIFn: appx.AIJSON(func(string) (json.RawMessage, error) { + return json.Marshal(map[string]any{ + "verdict": verdict, + "evidence_level": level, + "rationale": "canned rationale", + "confidence": "high", + }) + }), + } +} + +func TestPrioritySortGolden(t *testing.T) { + var golden struct { + Input []struct { + ID string `json:"id"` + EstimatedSeverity string `json:"estimated_severity"` + Confidence string `json:"confidence"` + } `json:"input"` + WantIDs []string `json:"want_ids"` + } + goldenJSON(t, "priority_sort", &golden) + + input := make([]schemas.RawFinding, len(golden.Input)) + for i, row := range golden.Input { + input[i] = schemas.RawFinding{ + ID: row.ID, + EstimatedSeverity: schemas.Severity(row.EstimatedSeverity), + Confidence: schemas.Confidence(row.Confidence), + } + } + + got := PrioritySort(input) + gotIDs := make([]string, len(got)) + for i, f := range got { + gotIDs[i] = f.ID + } + if !reflect.DeepEqual(gotIDs, golden.WantIDs) { + t.Errorf("PrioritySort order = %v, want %v", gotIDs, golden.WantIDs) + } + + // `sorted()` returns a NEW list; the caller's slice keeps its order. + if input[0].ID != golden.Input[0].ID { + t.Error("PrioritySort must not reorder its input slice") + } +} + +// TestPrioritySortStability pins that reverse=True does NOT reverse ties: "b" +// and "e" share (critical, low) and keep their input order. +func TestPrioritySortStability(t *testing.T) { + in := []schemas.RawFinding{ + {ID: "b", EstimatedSeverity: schemas.SeverityCritical, Confidence: schemas.ConfidenceLow}, + {ID: "e", EstimatedSeverity: schemas.SeverityCritical, Confidence: schemas.ConfidenceLow}, + } + got := PrioritySort(in) + if got[0].ID != "b" || got[1].ID != "e" { + t.Errorf("ties must keep input order, got %s,%s", got[0].ID, got[1].ID) + } +} + +// TestPrioritySortUnknownRanksLast pins `.get(key, 0)` — an out-of-vocabulary +// severity or confidence ranks below every declared member. +func TestPrioritySortUnknownRanksLast(t *testing.T) { + in := []schemas.RawFinding{ + {ID: "unknown", EstimatedSeverity: "bogus", Confidence: "bogus"}, + {ID: "info", EstimatedSeverity: schemas.SeverityInfo, Confidence: schemas.ConfidenceLow}, + } + got := PrioritySort(in) + if got[0].ID != "info" || got[1].ID != "unknown" { + t.Errorf("unknown values must sort last, got %s,%s", got[0].ID, got[1].ID) + } +} + +func TestApplyMetadataGolden(t *testing.T) { + var want map[string]any + goldenJSON(t, "apply_metadata", &want) + + mints := verified("m2", "fp-m2", 0.0, 6) + mints.SarifRuleID = "" + mints.CweName = "Broken Access/Control Check" + + cases := map[string]schemas.VerifiedFinding{ + "keeps_rule_id": ApplyMetadata(verified("m1", "fp-m1", 0.0, 3)), + "mints_rule_id": ApplyMetadata(mints), + "with_reachability_tag": ApplyMetadata(verified("m3", "fp-m3", 0.0, 6, "externally_reachable")), + } + if len(cases) != len(want) { + t.Fatalf("case count drift: go has %d, golden has %d", len(cases), len(want)) + } + for name, got := range cases { + if !reflect.DeepEqual(jsonTree(t, got), want[name]) { + t.Errorf("apply_metadata[%s] mismatch:\n got: %#v\nwant: %#v", name, jsonTree(t, got), want[name]) + } + } +} + +// TestApplyMetadataFloorsBeforeScoring pins the ORDER inside _apply_metadata: +// CWE-89 floors "low" up to "critical", and the score must reflect the FLOORED +// severity, not the original one. +func TestApplyMetadataFloorsBeforeScoring(t *testing.T) { + f := verified("x", "fp-x", 0.0, 6) + f.Severity = schemas.SeverityLow + got := ApplyMetadata(f) + if got.Severity != schemas.SeverityCritical { + t.Fatalf("CWE-89 must floor severity to critical, got %q", got.Severity) + } + if got.ExploitabilityScore != 10.0 { + t.Errorf("score must use the floored severity (10.0), got %v", got.ExploitabilityScore) + } + if got.SarifSecuritySeverity != got.ExploitabilityScore { + t.Errorf("sarif_security_severity must mirror the score, got %v", got.SarifSecuritySeverity) + } + if len(got.Compliance) == 0 { + t.Error("CWE-89 has static compliance mappings; they must be attached") + } +} + +func TestRunProveGolden(t *testing.T) { + var golden struct { + RepoPath string `json:"repo_path"` + Depth string `json:"depth"` + Want []json.RawMessage `json:"want"` + } + goldenJSON(t, "run_prove", &golden) + + hunt := schemas.HuntResult{ + Findings: []schemas.RawFinding{findingBare(), findingRich()}, + Chains: []schemas.PotentialChain{}, + TotalRaw: 2, + DeduplicatedCount: 2, + StrategiesRun: []string{"injection"}, + } + got, err := RunProve(context.Background(), cannedApp("confirmed", 5), golden.RepoPath, hunt, golden.Depth, 3) + if err != nil { + t.Fatalf("RunProve: %v", err) + } + if len(got) != len(golden.Want) { + t.Fatalf("got %d findings, want %d", len(got), len(golden.Want)) + } + for i := range got { + var want any + if err := json.Unmarshal(golden.Want[i], &want); err != nil { + t.Fatalf("decode want[%d]: %v", i, err) + } + if !reflect.DeepEqual(jsonTree(t, got[i]), want) { + t.Errorf("run_prove[%d] mismatch:\n got: %#v\nwant: %#v", i, jsonTree(t, got[i]), want) + } + } +} + +// TestRunProveNormalizesDepth pins that the sub-agent prompts receive the +// NORMALIZED profile value — an unrecognised depth becomes "standard". +func TestRunProveNormalizesDepth(t *testing.T) { + app := cannedApp("likely", 3) + hunt := schemas.HuntResult{Findings: []schemas.RawFinding{findingBare()}} + if _, err := RunProve(context.Background(), app, fixtureRepo, hunt, "NoSuchDepth", 1); err != nil { + t.Fatalf("RunProve: %v", err) + } + for _, call := range app.Harnesses { + if strings.Contains(call.Prompt, "- Analysis depth: NoSuchDepth") { + t.Fatal("an unrecognised depth must be normalized to standard before it reaches a prompt") + } + } + if len(app.Harnesses) == 0 { + t.Fatal("expected harness calls") + } + if !strings.Contains(app.Harnesses[0].Prompt, "- Analysis depth: standard") { + t.Errorf("prompt should carry the normalized depth; got:\n%s", app.Harnesses[0].Prompt) + } +} + +// TestRunProveEmptyHunt pins that an empty finding list short-circuits to an +// empty (non-nil) result with no harness traffic. +func TestRunProveEmptyHunt(t *testing.T) { + app := &appx.Fake{} + got, err := RunProve(context.Background(), app, fixtureRepo, schemas.HuntResult{}, "quick", 3) + if err != nil { + t.Fatalf("RunProve: %v", err) + } + if got == nil || len(got) != 0 { + t.Errorf("want an empty non-nil slice, got %#v", got) + } + if len(app.Harnesses) != 0 || len(app.AIs) != 0 { + t.Error("no findings must mean no sub-agent calls") + } +} + +// TestRunProveDemotesVerifierFailure pins that a sub-agent failure demotes ONE +// finding rather than failing the phase — the behaviour orchestrator.py and +// reasoners/phases.py both depend on. +func TestRunProveDemotesVerifierFailure(t *testing.T) { + app := &appx.Fake{ + HarnessFn: appx.HarnessJSON(func(prompt string, _ harness.Options) (json.RawMessage, error) { + if strings.Contains(prompt, "You are ExploitHypothesizer") { + return nil, errors.New("provider exploded") + } + if strings.Contains(prompt, "You are DataFlowTracer") { + return json.Marshal(traceRich()) + } + return json.Marshal(sanitizationRich()) + }), + } + hunt := schemas.HuntResult{Findings: []schemas.RawFinding{findingRich()}} + got, err := RunProve(context.Background(), app, fixtureRepo, hunt, "quick", 3) + if err != nil { + t.Fatalf("RunProve must absorb a sub-agent failure, got %v", err) + } + if len(got) != 1 { + t.Fatalf("want 1 demoted finding, got %d", len(got)) + } + if got[0].Verdict != schemas.VerdictInconclusive { + t.Errorf("verdict = %q, want inconclusive", got[0].Verdict) + } + if got[0].DropReason == nil || *got[0].DropReason != "verifier_error" { + t.Errorf("drop_reason = %v, want verifier_error", got[0].DropReason) + } + if !strings.Contains(got[0].Rationale, "ExploitHypothesizer harness error: provider exploded") { + t.Errorf("rationale must carry the failure message, got %q", got[0].Rationale) + } + if len(got[0].Tags) != 1 || got[0].Tags[0] != "low_confidence" { + t.Errorf("tags = %v, want [low_confidence]", got[0].Tags) + } +} + +// TestDemoteOnErrorClassification pins the three drop-reason branches of the +// shared `except BaseException` body, including the "validationerror" probe +// that Python spells WITHOUT a space. +func TestDemoteOnErrorClassification(t *testing.T) { + f := findingBare() + for _, tc := range []struct { + name string + err error + dropReason string + rationalePart string + }{ + {"unverified verdict", errors.New("Verdict unverified for finding"), "verdict_unverified", + "Verifier returned unverified verdict; demoted for manual review (original verdict: unverified)"}, + {"validationerror substring", errors.New("pydantic ValidationError: bad"), "schema_parse_failure", + "pydantic ValidationError: bad"}, + {"anything else", errors.New("boom"), "verifier_error", "boom"}, + // A real pydantic message says "1 validation error for X" — with a + // SPACE — so it does NOT hit the schema_parse_failure branch here. + {"spaced validation error", errors.New("1 validation error for VerifiedFinding"), "verifier_error", + "1 validation error for VerifiedFinding"}, + } { + got := demoteOnError(f, tc.err) + if got.DropReason == nil || *got.DropReason != tc.dropReason { + t.Errorf("%s: drop_reason = %v, want %q", tc.name, got.DropReason, tc.dropReason) + } + if !strings.Contains(got.Rationale, tc.rationalePart) { + t.Errorf("%s: rationale = %q, want it to contain %q", tc.name, got.Rationale, tc.rationalePart) + } + } +} + +// TestRunProveConcurrencyBound pins the semaphore: +// max(1, min(max_concurrent_provers, len(findings))). Each verification issues +// three harness calls, two of which (tracer + sanitization) run concurrently +// WITHIN a verification, so the observable ceiling is 2*limit. +func TestRunProveConcurrencyBound(t *testing.T) { + findings := make([]schemas.RawFinding, 8) + for i := range findings { + f := findingBare() + f.ID = "f" + string(rune('a'+i)) + f.Fingerprint = f.ID + findings[i] = f + } + + for _, limit := range []int{1, 2, 3} { + app := cannedApp("confirmed", 4) + if _, err := RunProve(context.Background(), app, fixtureRepo, + schemas.HuntResult{Findings: findings}, "quick", limit); err != nil { + t.Fatalf("RunProve: %v", err) + } + if got := app.MaxConcurrentHarness(); got > 2*limit { + t.Errorf("limit %d: peak concurrent harness calls = %d, want <= %d", limit, got, 2*limit) + } + } + + // A zero/negative limit still admits one prover — `max(1, ...)`. + app := cannedApp("confirmed", 4) + if _, err := RunProve(context.Background(), app, fixtureRepo, + schemas.HuntResult{Findings: findings}, "quick", 0); err != nil { + t.Fatalf("RunProve: %v", err) + } + if got := app.MaxConcurrentHarness(); got > 2 { + t.Errorf("a zero limit must behave like 1 prover; peak = %d", got) + } +} + +// TestRunVerifierRunsTracerAndSanitizationConcurrently pins the +// asyncio.gather(tracer, sanitization) shape: both are in flight at once. +func TestRunVerifierRunsTracerAndSanitizationConcurrently(t *testing.T) { + release := make(chan struct{}) + seen := make(chan string, 2) + app := &appx.Fake{ + HarnessFn: func(_ context.Context, prompt string, _ map[string]any, dest any, _ harness.Options) (*harness.Result, error) { + switch { + case strings.Contains(prompt, "You are DataFlowTracer"): + seen <- "tracer" + <-release + b, _ := json.Marshal(traceRich()) + _ = json.Unmarshal(b, dest) + case strings.Contains(prompt, "You are SanitizationAnalyzer"): + seen <- "sanitization" + <-release + b, _ := json.Marshal(sanitizationRich()) + _ = json.Unmarshal(b, dest) + default: + b, _ := json.Marshal(exploitRich()) + _ = json.Unmarshal(b, dest) + } + return &harness.Result{Parsed: dest}, nil + }, + AIFn: appx.AIJSON(func(string) (json.RawMessage, error) { + return json.Marshal(map[string]any{"verdict": "likely", "evidence_level": 2, "rationale": "r", "confidence": "low"}) + }), + } + + done := make(chan error, 1) + go func() { + _, err := RunVerifier(context.Background(), app, fixtureRepo, findingRich(), "quick") + done <- err + }() + + // Both stage-1 agents must arrive before either is allowed to finish. + first, second := <-seen, <-seen + if first == second { + t.Fatalf("expected tracer and sanitization concurrently, saw %q twice", first) + } + close(release) + if err := <-done; err != nil { + t.Fatalf("RunVerifier: %v", err) + } +} + +func TestRunVerifierGolden(t *testing.T) { + var want map[string]any + goldenJSON(t, "run_verifier", &want) + + notExploitable, err := RunVerifier(context.Background(), cannedApp("not_exploitable", 1), fixtureRepo, findingRich(), "quick") + if err != nil { + t.Fatalf("RunVerifier(not_exploitable): %v", err) + } + likely, err := RunVerifier(context.Background(), cannedApp("likely", 3), fixtureRepo, findingBare(), "quick") + if err != nil { + t.Fatalf("RunVerifier(likely): %v", err) + } + for name, got := range map[string]schemas.VerifiedFinding{ + "not_exploitable": notExploitable, + "likely": likely, + } { + if !reflect.DeepEqual(jsonTree(t, got), want[name]) { + t.Errorf("run_verifier[%s] mismatch:\n got: %#v\nwant: %#v", name, jsonTree(t, got), want[name]) + } + } +} + +// TestRunVerifierFallsBackOnTracerFailure pins the return_exceptions=True +// contract: a failed tracer falls back to the SEED trace built from the +// hunter's data_flow, and a failed sanitization falls back to the explicit +// "nothing found" result — neither aborts the verification. +func TestRunVerifierFallsBackOnStageOneFailures(t *testing.T) { + var exploitPrompt string + app := &appx.Fake{ + HarnessFn: appx.HarnessJSON(func(prompt string, _ harness.Options) (json.RawMessage, error) { + switch { + case strings.Contains(prompt, "You are DataFlowTracer"), + strings.Contains(prompt, "You are SanitizationAnalyzer"): + return nil, errors.New("stage one down") + } + exploitPrompt = prompt + return json.Marshal(exploitRich()) + }), + AIFn: appx.AIJSON(func(string) (json.RawMessage, error) { + return json.Marshal(map[string]any{"verdict": "likely", "evidence_level": 2, "rationale": "r", "confidence": "low"}) + }), + } + got, err := RunVerifier(context.Background(), app, fixtureRepo, findingRich(), "quick") + if err != nil { + t.Fatalf("stage-one failures must not abort verification: %v", err) + } + // The seed trace's source is ":" and its steps come from + // the hunter's data_flow. + if got.Proof == nil || got.Proof.DataFlowEvidence == nil { + t.Fatal("proof/data flow evidence missing") + } + if src := got.Proof.DataFlowEvidence.Source; src == nil || *src != "src/users.py:42" { + t.Errorf("seed trace source = %v, want src/users.py:42", src) + } + if got.Proof.DataFlowEvidence.SinkReached { + t.Error("the seed trace must never claim the sink was reached") + } + if len(got.Proof.DataFlowTrace) != 2 { + t.Errorf("seed trace should carry the hunter's 2 data-flow steps, got %d", len(got.Proof.DataFlowTrace)) + } + if !strings.Contains(exploitPrompt, "Sanitization found: no") { + t.Error("a failed sanitization analysis must reach the exploit prompt as 'no'") + } + if !strings.Contains(exploitPrompt, "Sanitization sufficient: unknown") { + t.Error("the sanitization fallback leaves `sufficient` None, which renders as 'unknown'") + } +} + +// TestSeedTraceSinkFallback pins `finding.function_name or finding.file_path`. +func TestSeedTraceSinkFallback(t *testing.T) { + f := findingBare() + if got := seedTraceFor(f).Sink; got != "src/hash.py" { + t.Errorf("a nil function_name must fall back to the file path, got %q", got) + } + f.FunctionName = str("") + if got := seedTraceFor(f).Sink; got != "src/hash.py" { + t.Errorf("an EMPTY function_name is falsy and must fall back too, got %q", got) + } + f.FunctionName = str("do_hash") + if got := seedTraceFor(f).Sink; got != "do_hash" { + t.Errorf("sink = %q, want do_hash", got) + } + // No data flow means an empty (non-nil) step list. + if steps := seedTraceFor(findingBare()).Steps; steps == nil || len(steps) != 0 { + t.Errorf("want empty non-nil steps, got %#v", steps) + } +} + +// TestVerdictAgentUsesAINotHarness pins that the verdict stage goes through +// `.ai()` — one structured request, no harness session and no temp dir. +func TestVerdictAgentUsesAINotHarness(t *testing.T) { + app := &appx.Fake{ + AIFn: appx.AIJSON(func(string) (json.RawMessage, error) { + return json.Marshal(map[string]any{"verdict": "confirmed", "evidence_level": 6, "rationale": "r", "confidence": "high"}) + }), + } + got, err := RunVerdictAgent(context.Background(), app, ".", findingRich(), traceRich(), sanitizationRich(), exploitRich()) + if err != nil { + t.Fatalf("RunVerdictAgent: %v", err) + } + if got.Verdict != "confirmed" || got.EvidenceLevel != 6 { + t.Errorf("unexpected decision %+v", got) + } + if len(app.Harnesses) != 0 { + t.Error("the verdict stage must not open a harness session") + } + if len(app.AIs) != 1 { + t.Fatalf("want exactly one .ai() call, got %d", len(app.AIs)) + } + // Python passes no `system=`, so the request carries no system message — + // ai.WithSystem is the only option that prepends one. + req := &ai.Request{} + for _, opt := range app.AIs[0].Opts { + _ = opt(req) + } + for _, msg := range req.Messages { + if msg.Role == "system" { + t.Errorf("verdict.py passes no system prompt, got %+v", msg) + } + } + if req.ResponseFormat == nil { + t.Fatal("the request must carry the strictified VerdictDecision schema") + } + if req.ResponseFormat.JSONSchema == nil || !req.ResponseFormat.JSONSchema.Strict { + t.Errorf("the schema must be sent in OpenAI strict mode, got %+v", req.ResponseFormat) + } + // It must be the committed PYDANTIC schema (title == the class name), then + // strictified: every object gets additionalProperties:false and a required + // list naming all of its properties. + var sent map[string]any + if err := json.Unmarshal(req.ResponseFormat.JSONSchema.Schema, &sent); err != nil { + t.Fatalf("decode sent schema: %v", err) + } + if title, _ := sent["title"].(string); title != "VerdictDecision" { + t.Errorf("schema title = %v, want VerdictDecision", sent["title"]) + } + if extra, ok := sent["additionalProperties"].(bool); !ok || extra { + t.Errorf("strict mode requires additionalProperties:false, got %v", sent["additionalProperties"]) + } + required, _ := sent["required"].([]any) + if len(required) != 4 { + t.Errorf("strict mode requires all 4 properties, got %v", required) + } +} + +// --- run_prove_streaming ---------------------------------------------------- + +func TestRunProveStreamingConsumesUntilSentinel(t *testing.T) { + app := cannedApp("confirmed", 4) + queue := make(chan []schemas.RawFinding, 4) + queue <- []schemas.RawFinding{findingBare()} + queue <- []schemas.RawFinding{} // an EMPTY batch is not the sentinel + queue <- []schemas.RawFinding{findingRich()} + queue <- nil // sentinel + close(queue) + + got := RunProveStreaming(context.Background(), app, fixtureRepo, queue, "standard", 3, 30) + if len(got) != 2 { + t.Fatalf("want 2 verified findings, got %d", len(got)) + } + // Sorted by (exploitability_score, evidence_level) descending: the rich + // finding is CWE-89 (critical floor) so it outscores the CWE-327 one. + if got[0].ID != "raw-1" || got[1].ID != "raw-2" { + t.Errorf("order = %s,%s, want raw-1,raw-2", got[0].ID, got[1].ID) + } + if got[0].ExploitabilityScore < got[1].ExploitabilityScore { + t.Error("results must be sorted by exploitability score, descending") + } +} + +func TestRunProveStreamingRespectsProverCap(t *testing.T) { + app := cannedApp("confirmed", 4) + queue := make(chan []schemas.RawFinding, 4) + batch := make([]schemas.RawFinding, 5) + for i := range batch { + f := findingBare() + f.ID = "f" + string(rune('a'+i)) + f.Fingerprint = f.ID + batch[i] = f + } + queue <- batch + queue <- batch // never scheduled: the cap is already reached + queue <- nil + close(queue) + + got := RunProveStreaming(context.Background(), app, fixtureRepo, queue, "quick", 2, 3) + if len(got) != 3 { + t.Fatalf("prover_cap=3 must stop after 3 verifications, got %d", len(got)) + } + // Each verification issues exactly three harness calls. + if len(app.Harnesses) != 9 { + t.Errorf("want 9 harness calls (3 findings x 3 sub-agents), got %d", len(app.Harnesses)) + } + if got := app.MaxConcurrentHarness(); got > 4 { + t.Errorf("max_concurrent_provers=2 caps in-flight harness calls at 4, got %d", got) + } +} + +// TestRunProveStreamingDrainsProducer pins that once the cap is hit the queue +// is drained to its sentinel rather than abandoned — otherwise a producer +// blocked on an unbuffered channel would never finish. +func TestRunProveStreamingDrainsProducer(t *testing.T) { + app := cannedApp("confirmed", 4) + queue := make(chan []schemas.RawFinding) // UNBUFFERED + producerDone := make(chan struct{}) + go func() { + defer close(producerDone) + for i := 0; i < 4; i++ { + queue <- []schemas.RawFinding{findingBare()} + } + queue <- nil + }() + + got := RunProveStreaming(context.Background(), app, fixtureRepo, queue, "quick", 2, 1) + if len(got) != 1 { + t.Fatalf("prover_cap=1 must verify exactly one finding, got %d", len(got)) + } + select { + case <-producerDone: + default: + t.Fatal("the producer must have been drained to its sentinel") + } +} + +func TestRunProveStreamingEmptyQueue(t *testing.T) { + app := &appx.Fake{} + queue := make(chan []schemas.RawFinding, 1) + queue <- nil + close(queue) + + got := RunProveStreaming(context.Background(), app, fixtureRepo, queue, "quick", 3, 30) + if got == nil || len(got) != 0 { + t.Errorf("want an empty non-nil slice, got %#v", got) + } + if len(app.Harnesses) != 0 { + t.Error("an immediate sentinel must schedule no work") + } +} + +// TestRunProveStreamingDemotesFailures pins that a failing verification is +// demoted and KEPT, not dropped — the `except BaseException` inside +// `_verify_one` means gather's exception filter never actually fires. +func TestRunProveStreamingDemotesFailures(t *testing.T) { + app := &appx.Fake{ + HarnessFn: appx.HarnessJSON(func(prompt string, _ harness.Options) (json.RawMessage, error) { + if strings.Contains(prompt, "You are ExploitHypothesizer") { + return nil, errors.New("nope") + } + if strings.Contains(prompt, "You are DataFlowTracer") { + return json.Marshal(traceRich()) + } + return json.Marshal(sanitizationRich()) + }), + } + queue := make(chan []schemas.RawFinding, 2) + queue <- []schemas.RawFinding{findingBare()} + queue <- nil + close(queue) + + got := RunProveStreaming(context.Background(), app, fixtureRepo, queue, "quick", 3, 30) + if len(got) != 1 { + t.Fatalf("a failed verification must still be reported, got %d findings", len(got)) + } + if got[0].DropReason == nil || *got[0].DropReason != "verifier_error" { + t.Errorf("drop_reason = %v, want verifier_error", got[0].DropReason) + } +} + +// TestRunProveSemaphoreAdmitsExactlyTheLimit is the strong form of the +// concurrency contract: `asyncio.Semaphore(max(1, min(n, len(findings))))` must +// admit EXACTLY that many verifications at once — no more (an over-wide +// semaphore would stampede the provider) and no fewer (a serialized phase would +// take N times as long). +// +// Every tracer call parks until the test releases it, so the number that have +// arrived after a grace period IS the semaphore width. A too-narrow semaphore +// never fills the wave and the test fails on its deadline. +func TestRunProveSemaphoreAdmitsExactlyTheLimit(t *testing.T) { + const findingsN, limit = 6, 3 + + var ( + mu sync.Mutex + arrived int + inFlight int + peak int + ) + release := make(chan struct{}) + timedOut := make(chan struct{}) + + app := &appx.Fake{ + HarnessFn: func(_ context.Context, prompt string, _ map[string]any, dest any, _ harness.Options) (*harness.Result, error) { + var canned any = exploitRich() + switch { + case strings.Contains(prompt, "You are DataFlowTracer"): + mu.Lock() + arrived++ + inFlight++ + if inFlight > peak { + peak = inFlight + } + mu.Unlock() + select { + case <-release: + case <-timedOut: + } + mu.Lock() + inFlight-- + mu.Unlock() + canned = traceRich() + case strings.Contains(prompt, "You are SanitizationAnalyzer"): + canned = sanitizationRich() + } + b, _ := json.Marshal(canned) + if err := json.Unmarshal(b, dest); err != nil { + return nil, err + } + return &harness.Result{Parsed: dest}, nil + }, + AIFn: appx.AIJSON(func(string) (json.RawMessage, error) { + return json.Marshal(map[string]any{"verdict": "likely", "evidence_level": 2, "rationale": "r", "confidence": "low"}) + }), + } + + findings := make([]schemas.RawFinding, findingsN) + for i := range findings { + f := findingBare() + f.ID = "f" + strconv.Itoa(i) + f.Fingerprint = f.ID + findings[i] = f + } + + done := make(chan error, 1) + go func() { + _, err := RunProve(context.Background(), app, fixtureRepo, + schemas.HuntResult{Findings: findings}, "quick", limit) + done <- err + }() + + // Wait for the first wave to fill, then give a too-wide semaphore a chance + // to let a fourth verification through. + deadline := time.Now().Add(10 * time.Second) + for { + mu.Lock() + n := arrived + mu.Unlock() + if n >= limit { + break + } + if time.Now().After(deadline) { + close(timedOut) + <-done + t.Fatalf("only %d of %d verifications were in flight: the semaphore is too narrow", n, limit) + } + time.Sleep(time.Millisecond) + } + time.Sleep(150 * time.Millisecond) + mu.Lock() + over := arrived + mu.Unlock() + close(release) + + if err := <-done; err != nil { + t.Fatalf("RunProve: %v", err) + } + if over != limit { + t.Errorf("%d verifications entered the first wave, want exactly %d", over, limit) + } + if peak != limit { + t.Errorf("peak concurrent verifications = %d, want exactly %d", peak, limit) + } +} diff --git a/go/internal/agents/prove/sandbox.go b/go/internal/agents/prove/sandbox.go new file mode 100644 index 0000000..43cc197 --- /dev/null +++ b/go/internal/agents/prove/sandbox.go @@ -0,0 +1,174 @@ +package prove + +// Ports src/sec_af/agents/prove/sandbox.py: +// +// """Sandbox execution helper for DAST-like verification. +// +// Provides a safe execution context for running exploit payloads +// against target applications. Currently uses subprocess isolation +// with strict timeouts and resource limits. Future: Docker containers. +// """ +// +// Nothing in the Python tree calls run_sandboxed — it is ported for 1:1 +// completeness so the Go node has the same surface when DAST verification grows +// a real execution path. + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "syscall" + "time" +) + +// DefaultSandboxTimeout is sandbox.py `_DEFAULT_TIMEOUT`, in seconds. +const DefaultSandboxTimeout = 10 + +// MaxSandboxOutputBytes is sandbox.py `_MAX_OUTPUT_BYTES`. Python slices the +// raw BYTES before decoding (`stdout_bytes[:8192].decode("utf-8", errors="replace")`), +// so a multi-byte character straddling the cut is replaced with U+FFFD rather +// than dropped — which Go's []byte -> string conversion of an invalid tail +// reproduces on read. +const MaxSandboxOutputBytes = 8192 + +// SandboxResult ports sandbox.py's frozen dataclass of the same name. +type SandboxResult struct { + Stdout string + Stderr string + ExitCode int + TimedOut bool +} + +// RunSandboxed ports sandbox.py run_sandboxed. +// +// async def run_sandboxed(command, *, timeout=_DEFAULT_TIMEOUT, cwd=None) -> SandboxResult: +// try: +// proc = await asyncio.create_subprocess_exec(*command, stdout=PIPE, stderr=PIPE, cwd=cwd) +// out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout) +// return SandboxResult(out[:8192].decode(...), err[:8192].decode(...), proc.returncode or 0, False) +// except asyncio.TimeoutError: +// proc.kill() +// return SandboxResult("", "Execution timed out", -1, True) +// except Exception as exc: +// return SandboxResult("", str(exc), -1, False) +// +// Signature: Go has no keyword arguments, so the two keyword-only parameters +// become positional. `timeout` is in SECONDS (pass DefaultSandboxTimeout for +// Python's default) and `cwd == ""` is Python's `cwd=None` (inherit the +// parent's working directory). +// +// emptyCommandStderr is `str(TypeError)` for +// `asyncio.create_subprocess_exec(*[])` on CPython 3.11 — the message the +// blanket handler records when `command` is empty. +const emptyCommandStderr = "create_subprocess_exec() missing 1 required positional argument: 'program'" + +// Python parity notes: +// +// - NEVER returns an error. Every failure — a missing binary, an empty +// command, a permission denial — lands in Stderr with ExitCode -1, which is +// what Python's blanket `except Exception` does. +// - `proc.returncode or 0` keeps a nonzero code and maps 0/None to 0. For a +// child killed by a SIGNAL, `returncode` is the NEGATIVE signal number +// (VERIFIED on the pinned interpreter: `sh -c "kill -9 $$"` -> -9, +// `kill -15 $$` -> -15). Go's `ProcessState.ExitCode()` discards that and +// answers -1 for ANY signalled child, so the signal is read back off the +// wait status below. Reading it matters beyond the port's own deadline — +// which the timeout branch already owns — because the OOM killer, a +// container runtime stopping the pod, a supervisor's SIGTERM or a payload +// killing itself all land here, and -1 is also the sentinel the exception +// branch uses. +// - ctx is honoured on top of the timeout: a cancelled ctx kills the child +// just as the timeout does, but reports timed_out=false because Python has +// no equivalent of a caller-cancelled await here (its CancelledError would +// propagate, and BaseException is not caught by `except Exception`). +// Documented divergence; nothing in the port passes a cancellable ctx. +func RunSandboxed(ctx context.Context, command []string, timeout int, cwd string) SandboxResult { + if len(command) == 0 { + // Python parity: `asyncio.create_subprocess_exec(*[])` raises a + // TypeError before any process starts, and the blanket + // `except Exception as exc` stores `str(exc)`. VERIFIED on the pinned + // interpreter: + // + // run_sandboxed([]) -> SandboxResult(stdout='', exit_code=-1, timed_out=False, + // stderr="create_subprocess_exec() missing 1 required positional argument: 'program'") + return SandboxResult{Stderr: emptyCommandStderr, ExitCode: -1} + } + + runCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) + defer cancel() + + cmd := exec.CommandContext(runCtx, command[0], command[1:]...) + cmd.Dir = cwd + // WaitDelay bounds how long Run keeps waiting for the output pipes AFTER + // the deadline killed the child. Without it a grandchild that inherited the + // pipe (`sh -c "sleep 60"`) would hold Run open for the full 60s, while + // Python's `asyncio.wait_for(proc.communicate())` returns the moment the + // timeout fires. The delay restores that timing. + cmd.WaitDelay = 200 * time.Millisecond + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if errors.Is(runCtx.Err(), context.DeadlineExceeded) { + // CommandContext already killed the child, which is Python's + // `proc.kill()` inside the TimeoutError handler. + return SandboxResult{Stderr: "Execution timed out", ExitCode: -1, TimedOut: true} + } + if err != nil { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + // Start failure (binary missing, cwd unreadable, ctx cancelled): + // Python's generic `except Exception` branch. + return SandboxResult{Stderr: err.Error(), ExitCode: -1} + } + // A nonzero exit is NOT an exception in Python — communicate() returns + // normally and returncode carries the value. + return SandboxResult{ + Stdout: truncateBytes(stdout.Bytes(), MaxSandboxOutputBytes), + Stderr: truncateBytes(stderr.Bytes(), MaxSandboxOutputBytes), + ExitCode: pyReturnCode(exitErr.ProcessState), + } + } + return SandboxResult{ + Stdout: truncateBytes(stdout.Bytes(), MaxSandboxOutputBytes), + Stderr: truncateBytes(stderr.Bytes(), MaxSandboxOutputBytes), + ExitCode: 0, + } +} + +// signaledStatus is the part of syscall.WaitStatus this file needs. Asserting +// against an interface rather than the concrete type keeps the file free of +// build tags: on a platform whose wait status carries no signal (Windows) the +// assertion simply fails and ExitCode() stands. +type signaledStatus interface { + Signaled() bool + Signal() syscall.Signal +} + +// pyReturnCode is `proc.returncode`: the exit status for a normal exit, and the +// NEGATIVE signal number for a child killed by a signal (-9 for SIGKILL, -15 +// for SIGTERM), which is what asyncio's Process reports and what +// `exit_code=proc.returncode or 0` therefore carries through. +func pyReturnCode(state *os.ProcessState) int { + if ws, ok := state.Sys().(signaledStatus); ok && ws.Signaled() { + return -int(ws.Signal()) + } + return state.ExitCode() +} + +// truncateBytes is Python's `b[:n].decode("utf-8", errors="replace")`: the cut +// is by BYTE, and an invalid UTF-8 tail left by the cut decodes to U+FFFD. +// +// Known divergence: CPython's replace handler emits one U+FFFD per undecodable +// BYTE, while bytes.ToValidUTF8 emits one per invalid RUN. It only shows up on +// a cut that lands mid-character in already-truncated output, and the value is +// diagnostic text, so the simpler form is kept. +func truncateBytes(b []byte, n int) string { + if len(b) > n { + b = b[:n] + } + return string(bytes.ToValidUTF8(b, []byte("�"))) +} diff --git a/go/internal/agents/prove/sandbox_test.go b/go/internal/agents/prove/sandbox_test.go new file mode 100644 index 0000000..21aa4e6 --- /dev/null +++ b/go/internal/agents/prove/sandbox_test.go @@ -0,0 +1,170 @@ +package prove + +// Tests for sandbox.go. +// +// Validation contract (sandbox.py run_sandboxed): +// - a successful command returns its stdout/stderr and exit code 0, and never +// an error; +// - a nonzero exit is NOT a failure: the code is reported and timed_out stays +// false; +// - a child killed by a SIGNAL reports `proc.returncode`, which is the +// NEGATIVE signal number (VERIFIED on the pinned interpreter: `kill -9 $$` +// -> exit_code -9, `kill -15 $$` -> -15, both with timed_out False); +// - a command that outlives the timeout is killed and reported as +// ("", "Execution timed out", -1, true); +// - a command that cannot start at all reports the error text with exit +// code -1 and timed_out false; +// - output is truncated at 8192 BYTES; +// - cwd is honoured. + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func skipWithoutShell(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("sandbox tests need a POSIX shell") + } +} + +func TestRunSandboxedSuccess(t *testing.T) { + skipWithoutShell(t) + got := RunSandboxed(context.Background(), []string{"sh", "-c", "printf out; printf err 1>&2"}, + DefaultSandboxTimeout, "") + if got.Stdout != "out" { + t.Errorf("stdout = %q, want %q", got.Stdout, "out") + } + if got.Stderr != "err" { + t.Errorf("stderr = %q, want %q", got.Stderr, "err") + } + if got.ExitCode != 0 || got.TimedOut { + t.Errorf("exit_code = %d, timed_out = %v, want 0/false", got.ExitCode, got.TimedOut) + } +} + +// TestRunSandboxedNonZeroExitIsNotAnError pins that Python's communicate() +// returns normally for a failing command — only the return code changes. +func TestRunSandboxedNonZeroExitIsNotAnError(t *testing.T) { + skipWithoutShell(t) + got := RunSandboxed(context.Background(), []string{"sh", "-c", "printf partial; exit 3"}, + DefaultSandboxTimeout, "") + if got.ExitCode != 3 { + t.Errorf("exit_code = %d, want 3", got.ExitCode) + } + if got.Stdout != "partial" { + t.Errorf("stdout must still be captured, got %q", got.Stdout) + } + if got.TimedOut { + t.Error("a nonzero exit is not a timeout") + } +} + +// TestRunSandboxedSignalledChildReportsTheNegativeSignal covers the case Go's +// ProcessState.ExitCode() flattens to -1 — which is also the sentinel the +// start-failure and timeout branches use, so a caller could not tell "killed by +// SIGKILL" from "never started". Python keeps them apart. +func TestRunSandboxedSignalledChildReportsTheNegativeSignal(t *testing.T) { + skipWithoutShell(t) + for _, tc := range []struct { + signal string + want int + }{ + {"9", -9}, + {"15", -15}, + {"6", -6}, + } { + got := RunSandboxed(context.Background(), + []string{"sh", "-c", "kill -" + tc.signal + " $$"}, DefaultSandboxTimeout, "") + if got.ExitCode != tc.want { + t.Errorf("kill -%s: exit_code = %d, want %d", tc.signal, got.ExitCode, tc.want) + } + if got.TimedOut { + t.Errorf("kill -%s: timed_out must stay false", tc.signal) + } + } +} + +func TestRunSandboxedTimeout(t *testing.T) { + skipWithoutShell(t) + got := RunSandboxed(context.Background(), []string{"sh", "-c", "sleep 5"}, 1, "") + if !got.TimedOut { + t.Error("want timed_out = true") + } + if got.Stderr != "Execution timed out" { + t.Errorf("stderr = %q, want %q", got.Stderr, "Execution timed out") + } + if got.ExitCode != -1 { + t.Errorf("exit_code = %d, want -1", got.ExitCode) + } + if got.Stdout != "" { + t.Errorf("a timed-out run reports no stdout, got %q", got.Stdout) + } +} + +func TestRunSandboxedStartFailure(t *testing.T) { + got := RunSandboxed(context.Background(), + []string{"definitely-not-a-real-binary-secaf"}, DefaultSandboxTimeout, "") + if got.ExitCode != -1 || got.TimedOut { + t.Errorf("exit_code = %d, timed_out = %v, want -1/false", got.ExitCode, got.TimedOut) + } + if got.Stderr == "" { + t.Error("a start failure must report the exception text in stderr") + } +} + +// TestRunSandboxedEmptyCommand pins the whole result, TEXT INCLUDED. Python's +// `asyncio.create_subprocess_exec(*[])` raises before any process starts and +// the blanket `except Exception as exc` stores `str(exc)`. VERIFIED on the +// pinned interpreter (PYTHONPATH=src ~/.agentfield/packages/sec-af/venv/bin/python): +// +// run_sandboxed([]) -> SandboxResult(stdout='', exit_code=-1, timed_out=False, +// stderr="create_subprocess_exec() missing 1 required positional argument: 'program'") +func TestRunSandboxedEmptyCommand(t *testing.T) { + got := RunSandboxed(context.Background(), nil, DefaultSandboxTimeout, "") + want := SandboxResult{ + Stdout: "", + Stderr: "create_subprocess_exec() missing 1 required positional argument: 'program'", + ExitCode: -1, + TimedOut: false, + } + if got != want { + t.Errorf("RunSandboxed(nil) = %+v, want %+v", got, want) + } +} + +func TestRunSandboxedTruncatesOutput(t *testing.T) { + skipWithoutShell(t) + // 20000 'a' bytes, well past the 8192-byte cap. + got := RunSandboxed(context.Background(), + []string{"sh", "-c", "printf 'a%.0s' $(seq 1 20000)"}, DefaultSandboxTimeout, "") + if got.ExitCode != 0 { + t.Fatalf("exit_code = %d, stderr=%q", got.ExitCode, got.Stderr) + } + if len(got.Stdout) != MaxSandboxOutputBytes { + t.Errorf("stdout length = %d, want %d", len(got.Stdout), MaxSandboxOutputBytes) + } + if strings.Trim(got.Stdout, "a") != "" { + t.Error("truncation must keep the leading bytes verbatim") + } +} + +func TestRunSandboxedHonoursCwd(t *testing.T) { + skipWithoutShell(t) + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "marker"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + got := RunSandboxed(context.Background(), []string{"sh", "-c", "ls"}, DefaultSandboxTimeout, dir) + if got.ExitCode != 0 { + t.Fatalf("exit_code = %d, stderr = %q", got.ExitCode, got.Stderr) + } + if !strings.Contains(got.Stdout, "marker") { + t.Errorf("the command must run inside cwd; got %q", got.Stdout) + } +} diff --git a/go/internal/agents/prove/sanitization.go b/go/internal/agents/prove/sanitization.go new file mode 100644 index 0000000..271b2b6 --- /dev/null +++ b/go/internal/agents/prove/sanitization.go @@ -0,0 +1,85 @@ +package prove + +// Ports src/sec_af/agents/prove/sanitization.py. + +import ( + "context" + "os" + "strconv" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/harnessx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// sanitizationPromptPath mirrors sanitization.py's module-level PROMPT_PATH. +const sanitizationPromptPath = "prove/sanitization.txt" + +const ( + sanitizationAgentName = "prove-sanitization" + sanitizationExtractName = "SanitizationAnalyzer" +) + +// sanitizationBuildPrompt ports sanitization.py `_build_prompt`. +// +// Note the two differences from tracer.py's builder: {{TRACE_CONTEXT}} replaces +// {{DATA_FLOW_JSON}}, and it sits one slot earlier — before {{DEPTH}}, so a +// literal "{{DEPTH}}" inside the trace context WOULD be substituted. Order is +// preserved exactly. +func sanitizationBuildPrompt(template string, finding schemas.RawFinding, trace schemas.DataFlowTrace, depth string) string { + return applyReplacements(template, []replacement{ + {"{{TITLE}}", finding.Title}, + {"{{DESCRIPTION}}", finding.Description}, + {"{{CWE_ID}}", finding.CweID}, + {"{{CWE_NAME}}", finding.CweName}, + {"{{FILE_PATH}}", finding.FilePath}, + {"{{START_LINE}}", strconv.Itoa(finding.StartLine)}, + {"{{CODE_SNIPPET}}", finding.CodeSnippet}, + {"{{FINDING_TYPE}}", string(finding.FindingType)}, + {"{{RELATED_FILES}}", relatedFilesJSON(finding.RelatedFiles)}, + {"{{TRACE_CONTEXT}}", traceContext(trace)}, + {"{{DEPTH}}", depth}, + }) +} + +// SanitizationPrompt builds the exact prompt RunSanitizationAnalyzer sends. +// Exported for the golden test. +func SanitizationPrompt(finding schemas.RawFinding, trace schemas.DataFlowTrace, repoPath, depth string) string { + return sanitizationBuildPrompt(prompts.MustLoad(sanitizationPromptPath), finding, trace, depth) + + "\n\nCONTEXT:\n" + + "- Repository path: " + repoPath + "\n" + + "- Use the repository path above for file inspection during sanitization analysis." +} + +// RunSanitizationAnalyzer ports sanitization.py run_sanitization_analyzer. +// +// result = await app.harness(prompt=prompt, schema=SanitizationResult, +// cwd=harness_cwd, project_dir=repo_path) +// return extract_harness_result(result, SanitizationResult, "SanitizationAnalyzer") +// +// Same temp-dir contract as RunTracer. +func RunSanitizationAnalyzer( + ctx context.Context, + app appx.Harnesser, + repoPath string, + finding schemas.RawFinding, + dataFlowTrace schemas.DataFlowTrace, + depth string, +) (schemas.SanitizationResult, error) { + prompt := SanitizationPrompt(finding, dataFlowTrace, repoPath, depth) + + harnessCwd, err := os.MkdirTemp("", "secaf-"+sanitizationAgentName+"-") + if err != nil { + return schemas.SanitizationResult{}, err + } + defer os.RemoveAll(harnessCwd) + + return harnessx.RunExtract[schemas.SanitizationResult]( + ctx, app, prompt, + harness.Options{Cwd: harnessCwd, ProjectDir: repoPath}, + sanitizationExtractName, + ) +} diff --git a/go/internal/agents/prove/shared.go b/go/internal/agents/prove/shared.go new file mode 100644 index 0000000..7005495 --- /dev/null +++ b/go/internal/agents/prove/shared.go @@ -0,0 +1,132 @@ +package prove + +// Helpers shared by more than one prove module, plus the Python idioms the +// prompt builders lean on. Each one names the Python source it reproduces. + +import ( + "strings" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// HarnessAIer is the capability set verifier.run_verifier needs: the four +// sub-agents split between `.harness(...)` (tracer, sanitization, exploit) and +// `.ai(...)` (verdict). Python declares exactly this union as its +// `HarnessCapable` Protocol in verifier.py. +type HarnessAIer interface { + appx.Harnesser + appx.AIer +} + +// replacement is one entry of Python's `replacements` dict inside a +// `_build_prompt`. ORDER IS LOAD-BEARING: Python iterates the dict in insertion +// order and applies `prompt.replace(needle, value)` cumulatively, so a marker +// that appears INSIDE an already-substituted value is itself substituted by a +// later entry, while a marker inside a value substituted later survives. The Go +// port keeps a slice for exactly that reason — a map would scramble it. +type replacement struct { + needle string + value string +} + +// applyReplacements is the shared tail of every `_build_prompt`: +// +// prompt = template +// for needle, value in replacements.items(): +// prompt = prompt.replace(needle, value) +// return prompt +// +// strings.ReplaceAll matches Python's str.replace, which replaces EVERY +// occurrence (dep_reachability.txt names {{PACKAGE}} and +// {{VULNERABLE_FUNCTION}} twice each). +func applyReplacements(template string, reps []replacement) string { + prompt := template + for _, r := range reps { + prompt = strings.ReplaceAll(prompt, r.needle, r.value) + } + return prompt +} + +// traceContext ports the `_trace_context` helper that sanitization.py and +// exploit.py declare byte-identically: +// +// def _trace_context(trace: DataFlowTrace) -> str: +// steps = "\n".join(f"- {step}" for step in trace.steps) if trace.steps else "- (no concrete trace steps)" +// sink_reached = "yes" if trace.sink_reached else "no" +// return f"Source: {trace.source}\nSink: {trace.sink}\nSink reached: {sink_reached}\nTrace steps:\n{steps}" +// +// verdict.py builds a DIFFERENT block (`_build_context`) and does not share +// this one; see verdict.go. +func traceContext(trace schemas.DataFlowTrace) string { + steps := "- (no concrete trace steps)" + if len(trace.Steps) > 0 { + parts := make([]string, len(trace.Steps)) + for i, step := range trace.Steps { + parts[i] = "- " + step + } + steps = strings.Join(parts, "\n") + } + sinkReached := "no" + if trace.SinkReached { + sinkReached = "yes" + } + return "Source: " + trace.Source + "\nSink: " + trace.Sink + + "\nSink reached: " + sinkReached + "\nTrace steps:\n" + steps +} + +// relatedFilesJSON is `json.dumps(finding.related_files, indent=2)`. +// +// Python parity: `related_files` is `list[str] = Field(default_factory=list)`, +// so pydantic can never make it None — a NIL Go slice therefore stands for +// Python's `[]` and must render as `[]`, not `null` (which is what +// pyfmt.Dumps does for a nil slice by design). +func relatedFilesJSON(files []string) string { + if files == nil { + files = []string{} + } + return pyfmt.Dumps(files, 2) +} + +// pyOr ports Python's `value or fallback` for an optional string: both None +// and the empty string are falsy, so both take the fallback. +func pyOr(value *string, fallback string) string { + if value == nil || *value == "" { + return fallback + } + return *value +} + +// pyStrOptBool renders `f"{x}"` for a `bool | None`: True, False or None. +// pyfmt.Str cannot be handed the *bool directly — a typed nil inside an +// interface is not the untyped nil its type switch tests for. +func pyStrOptBool(v *bool) string { + if v == nil { + return "None" + } + return pyfmt.Str(*v) +} + +// sarifRuleID ports the `_sarif_rule_id` helper that verifier.py, assembler.py +// and __init__.py._apply_metadata each declare identically: +// +// cwe_slug = finding.cwe_name.lower().replace(" ", "-").replace("/", "-") +// return f"sec-af/{finding.finding_type.value}/{cwe_slug}" +// +// Python parity: `str.lower()` is Unicode-aware full case folding for the +// simple cases; strings.ToLower matches for every character SEC-AF sees. Only +// SPACE and SLASH are replaced — an underscore, tab or newline in a CWE name +// survives verbatim, exactly as in Python. +func sarifRuleID(findingType schemas.FindingType, cweName string) string { + slug := strings.ReplaceAll(strings.ReplaceAll(strings.ToLower(cweName), " ", "-"), "/", "-") + return "sec-af/" + string(findingType) + "/" + slug +} + +// StrPtr returns a pointer to s. +// +// Several ported functions take `*string` parameters because their Python +// counterparts declare keyword-only arguments defaulting to None (Fallback's +// dropReason / originalVerdict). Go has no literal address-of for a constant, +// so callers need this one-liner; it lives here rather than in each caller. +func StrPtr(s string) *string { return &s } diff --git a/go/internal/agents/prove/testdata/golden/apply_metadata.json b/go/internal/agents/prove/testdata/golden/apply_metadata.json new file mode 100644 index 0000000..d9d18d1 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/apply_metadata.json @@ -0,0 +1,196 @@ +{ + "keeps_rule_id": { + "id": "m1", + "fingerprint": "fp-m1", + "title": "finding m1", + "description": "d", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "SQL Injection", + "owasp_category": null, + "tags": [], + "verdict": "confirmed", + "evidence_level": 3, + "rationale": "r", + "severity": "critical", + "cvss_v4": null, + "epss": null, + "exploitability_score": 5.0, + "proof": null, + "location": { + "file_path": "src/users.py", + "start_line": 1, + "end_line": 2, + "start_column": null, + "end_column": null, + "function_name": null, + "code_snippet": null + }, + "related_locations": [], + "chain_id": null, + "chain_step": null, + "enables": null, + "compliance": [ + { + "framework": "PCI-DSS", + "control_id": "Req 6.2.4", + "control_name": "Custom software addresses common coding vulnerabilities" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A03:2021", + "control_name": "Injection" + }, + { + "framework": "HIPAA", + "control_id": "\u00a7164.312(a)(1)", + "control_name": "Access control" + }, + { + "framework": "ISO27001", + "control_id": "A.8.28", + "control_name": "Secure coding" + } + ], + "reproduction_steps": [], + "remediation": null, + "sarif_rule_id": "sec-af/sast/sql-injection", + "sarif_security_severity": 5.0, + "drop_reason": null + }, + "mints_rule_id": { + "id": "m2", + "fingerprint": "fp-m2", + "title": "finding m2", + "description": "d", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "Broken Access/Control Check", + "owasp_category": null, + "tags": [], + "verdict": "confirmed", + "evidence_level": 6, + "rationale": "r", + "severity": "critical", + "cvss_v4": null, + "epss": null, + "exploitability_score": 10.0, + "proof": null, + "location": { + "file_path": "src/users.py", + "start_line": 1, + "end_line": 2, + "start_column": null, + "end_column": null, + "function_name": null, + "code_snippet": null + }, + "related_locations": [], + "chain_id": null, + "chain_step": null, + "enables": null, + "compliance": [ + { + "framework": "PCI-DSS", + "control_id": "Req 6.2.4", + "control_name": "Custom software addresses common coding vulnerabilities" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A03:2021", + "control_name": "Injection" + }, + { + "framework": "HIPAA", + "control_id": "\u00a7164.312(a)(1)", + "control_name": "Access control" + }, + { + "framework": "ISO27001", + "control_id": "A.8.28", + "control_name": "Secure coding" + } + ], + "reproduction_steps": [], + "remediation": null, + "sarif_rule_id": "sec-af/sast/broken-access-control-check", + "sarif_security_severity": 10.0, + "drop_reason": null + }, + "with_reachability_tag": { + "id": "m3", + "fingerprint": "fp-m3", + "title": "finding m3", + "description": "d", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "SQL Injection", + "owasp_category": null, + "tags": [ + "externally_reachable" + ], + "verdict": "confirmed", + "evidence_level": 6, + "rationale": "r", + "severity": "critical", + "cvss_v4": null, + "epss": null, + "exploitability_score": 10.0, + "proof": null, + "location": { + "file_path": "src/users.py", + "start_line": 1, + "end_line": 2, + "start_column": null, + "end_column": null, + "function_name": null, + "code_snippet": null + }, + "related_locations": [], + "chain_id": null, + "chain_step": null, + "enables": null, + "compliance": [ + { + "framework": "PCI-DSS", + "control_id": "Req 6.2.4", + "control_name": "Custom software addresses common coding vulnerabilities" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A03:2021", + "control_name": "Injection" + }, + { + "framework": "HIPAA", + "control_id": "\u00a7164.312(a)(1)", + "control_name": "Access control" + }, + { + "framework": "ISO27001", + "control_id": "A.8.28", + "control_name": "Secure coding" + } + ], + "reproduction_steps": [], + "remediation": null, + "sarif_rule_id": "sec-af/sast/sql-injection", + "sarif_security_severity": 10.0, + "drop_reason": null + } +} diff --git a/go/internal/agents/prove/testdata/golden/apply_validated_chain.json b/go/internal/agents/prove/testdata/golden/apply_validated_chain.json new file mode 100644 index 0000000..1a71ac8 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/apply_validated_chain.json @@ -0,0 +1,159 @@ +{ + "not_validated": { + "chain": { + "chain_id": "c1", + "title": "t", + "validated": false, + "rationale": "r", + "steps": [ + { + "step_number": 1, + "finding_id": "v1", + "description": "d", + "enables": "e" + } + ] + }, + "error": null, + "findings": [ + { + "id": "v1", + "chain_id": null, + "chain_step": null, + "enables": null, + "tags": [] + }, + { + "id": "v2", + "chain_id": null, + "chain_step": null, + "enables": null, + "tags": [] + } + ] + }, + "no_steps": { + "chain": { + "chain_id": "c1", + "title": "t", + "validated": true, + "rationale": "r", + "steps": [] + }, + "error": null, + "findings": [ + { + "id": "v1", + "chain_id": null, + "chain_step": null, + "enables": null, + "tags": [] + } + ] + }, + "no_matching_finding": { + "chain": { + "chain_id": "c1", + "title": "t", + "validated": true, + "rationale": "r", + "steps": [ + { + "step_number": 2, + "finding_id": "zz", + "description": "d", + "enables": "e" + }, + { + "step_number": 1, + "finding_id": "yy", + "description": "d", + "enables": "e" + } + ] + }, + "error": null, + "findings": [ + { + "id": "v1", + "chain_id": null, + "chain_step": null, + "enables": null, + "tags": [] + } + ] + }, + "matching_finding_raises": { + "chain": { + "chain_id": "c1", + "title": "t", + "validated": true, + "rationale": "r", + "steps": [ + { + "step_number": 2, + "finding_id": "v2", + "description": "d", + "enables": "e" + }, + { + "step_number": 1, + "finding_id": "v1", + "description": "d", + "enables": "e" + } + ] + }, + "error": "'list' object has no attribute 'add'", + "findings": [ + { + "id": "v1", + "chain_id": "c1", + "chain_step": 1, + "enables": [ + "v2" + ], + "tags": [] + }, + { + "id": "v2", + "chain_id": null, + "chain_step": null, + "enables": null, + "tags": [] + } + ] + }, + "second_step_matches": { + "chain": { + "chain_id": "c9", + "title": "t", + "validated": true, + "rationale": "r", + "steps": [ + { + "step_number": 1, + "finding_id": "missing", + "description": "d", + "enables": "e" + }, + { + "step_number": 2, + "finding_id": "v1", + "description": "d", + "enables": "e" + } + ] + }, + "error": "'list' object has no attribute 'add'", + "findings": [ + { + "id": "v1", + "chain_id": "c9", + "chain_step": 2, + "enables": null, + "tags": [] + } + ] + } +} diff --git a/go/internal/agents/prove/testdata/golden/assemble.json b/go/internal/agents/prove/testdata/golden/assemble.json new file mode 100644 index 0000000..315985f --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/assemble.json @@ -0,0 +1,353 @@ +{ + "confirmed": { + "id": "raw-1", + "fingerprint": "fp-1", + "title": "Potential SQL injection in user lookup", + "description": "Request parameter reaches a formatted SQL string. Marker: {{TITLE}}", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "Improper Neutralization/Escaping of Special Elements", + "owasp_category": "A03:2021 - Injection", + "tags": [], + "verdict": "confirmed", + "evidence_level": 5, + "rationale": "rationale for confirmed", + "severity": "high", + "cvss_v4": null, + "epss": null, + "exploitability_score": 0.0, + "proof": { + "exploit_hypothesis": "Attacker supplies id=1 OR 1=1 to dump the table", + "verification_method": "composite_subagent_chain:sast", + "evidence_level": 5, + "data_flow_trace": [ + { + "file": "trace_step_1", + "line": 1, + "description": "src/routes.py:10 read request.args", + "tainted": true + }, + { + "file": "trace_step_2", + "line": 2, + "description": "src/users.py:42 execute", + "tainted": true + } + ], + "data_flow_evidence": { + "steps": [ + { + "file": "trace_step_1", + "line": 1, + "description": "src/routes.py:10 read request.args", + "tainted": true + }, + { + "file": "trace_step_2", + "line": 2, + "description": "src/users.py:42 execute", + "tainted": true + } + ], + "source": "request.args['id']", + "sink": "cursor.execute(query)", + "sink_reached": true + }, + "sanitization_analysis": { + "sanitization_found": true, + "sanitization_type": "parameterized query", + "sanitization_sufficient": false, + "bypass_possible": true, + "bypass_method": "second-order injection through the audit log" + }, + "vulnerable_code": null, + "exploit_payload": "1 OR 1=1", + "expected_outcome": "Full users table returned", + "poc_code": null, + "poc_execution_output": null, + "http_request": null, + "http_response": null, + "reachability": null, + "chain_steps": null + }, + "location": { + "file_path": "src/users.py", + "start_line": 42, + "end_line": 44, + "start_column": null, + "end_column": null, + "function_name": "get_user", + "code_snippet": "cursor.execute(\"SELECT * FROM users WHERE id = \" + user_id) # depth={{DEPTH}}" + }, + "related_locations": [], + "chain_id": null, + "chain_step": null, + "enables": null, + "compliance": [], + "reproduction_steps": [ + { + "step": 1, + "description": "Trace attacker-controlled input from source to sink in target code path.", + "command": null, + "expected_output": "Input reaches a sensitive sink." + }, + { + "step": 2, + "description": "Attacker supplies id=1 OR 1=1 to dump the table", + "command": "1 OR 1=1", + "expected_output": "Full users table returned" + } + ], + "remediation": null, + "sarif_rule_id": "sec-af/sast/improper-neutralization-escaping-of-special-elements", + "sarif_security_severity": 0.0, + "drop_reason": null + }, + "not_exploitable": { + "id": "raw-1", + "fingerprint": "fp-1", + "title": "Potential SQL injection in user lookup", + "description": "Request parameter reaches a formatted SQL string. Marker: {{TITLE}}", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "Improper Neutralization/Escaping of Special Elements", + "owasp_category": "A03:2021 - Injection", + "tags": [], + "verdict": "not_exploitable", + "evidence_level": 1, + "rationale": "rationale for not_exploitable", + "severity": "high", + "cvss_v4": null, + "epss": null, + "exploitability_score": 0.0, + "proof": { + "exploit_hypothesis": "Attacker supplies id=1 OR 1=1 to dump the table", + "verification_method": "composite_subagent_chain:sast", + "evidence_level": 1, + "data_flow_trace": [ + { + "file": "trace_step_1", + "line": 1, + "description": "src/routes.py:10 read request.args", + "tainted": true + }, + { + "file": "trace_step_2", + "line": 2, + "description": "src/users.py:42 execute", + "tainted": true + } + ], + "data_flow_evidence": { + "steps": [ + { + "file": "trace_step_1", + "line": 1, + "description": "src/routes.py:10 read request.args", + "tainted": true + }, + { + "file": "trace_step_2", + "line": 2, + "description": "src/users.py:42 execute", + "tainted": true + } + ], + "source": "request.args['id']", + "sink": "cursor.execute(query)", + "sink_reached": true + }, + "sanitization_analysis": { + "sanitization_found": true, + "sanitization_type": "parameterized query", + "sanitization_sufficient": false, + "bypass_possible": true, + "bypass_method": "second-order injection through the audit log" + }, + "vulnerable_code": null, + "exploit_payload": "1 OR 1=1", + "expected_outcome": "Full users table returned", + "poc_code": null, + "poc_execution_output": null, + "http_request": null, + "http_response": null, + "reachability": null, + "chain_steps": null + }, + "location": { + "file_path": "src/users.py", + "start_line": 42, + "end_line": 44, + "start_column": null, + "end_column": null, + "function_name": "get_user", + "code_snippet": "cursor.execute(\"SELECT * FROM users WHERE id = \" + user_id) # depth={{DEPTH}}" + }, + "related_locations": [], + "chain_id": null, + "chain_step": null, + "enables": null, + "compliance": [], + "reproduction_steps": [], + "remediation": null, + "sarif_rule_id": "sec-af/sast/improper-neutralization-escaping-of-special-elements", + "sarif_security_severity": 0.0, + "drop_reason": null + }, + "unknown_verdict": { + "id": "raw-2", + "fingerprint": "fp-2", + "title": "Weak hash", + "description": "MD5 used for password hashing", + "finding_type": "sast", + "cwe_id": "CWE-327", + "cwe_name": "Broken Crypto", + "owasp_category": null, + "tags": [], + "verdict": "inconclusive", + "evidence_level": 6, + "rationale": "rationale for unverified", + "severity": "medium", + "cvss_v4": null, + "epss": null, + "exploitability_score": 0.0, + "proof": { + "exploit_hypothesis": "unknown", + "verification_method": "composite_subagent_chain:sast", + "evidence_level": 6, + "data_flow_trace": [], + "data_flow_evidence": { + "steps": [], + "source": "unknown", + "sink": "unknown", + "sink_reached": false + }, + "sanitization_analysis": { + "sanitization_found": false, + "sanitization_type": null, + "sanitization_sufficient": null, + "bypass_possible": false, + "bypass_method": null + }, + "vulnerable_code": null, + "exploit_payload": null, + "expected_outcome": "unknown", + "poc_code": null, + "poc_execution_output": null, + "http_request": null, + "http_response": null, + "reachability": null, + "chain_steps": null + }, + "location": { + "file_path": "src/hash.py", + "start_line": 7, + "end_line": 7, + "start_column": null, + "end_column": null, + "function_name": null, + "code_snippet": "hashlib.md5(pw).hexdigest()" + }, + "related_locations": [], + "chain_id": null, + "chain_step": null, + "enables": null, + "compliance": [], + "reproduction_steps": [ + { + "step": 1, + "description": "Trace attacker-controlled input from source to sink in target code path.", + "command": null, + "expected_output": "Input reaches a sensitive sink." + }, + { + "step": 2, + "description": "unknown", + "command": null, + "expected_output": "unknown" + } + ], + "remediation": null, + "sarif_rule_id": "sec-af/sast/broken-crypto", + "sarif_security_severity": 0.0, + "drop_reason": null + }, + "clamped_low": { + "id": "raw-2", + "fingerprint": "fp-2", + "title": "Weak hash", + "description": "MD5 used for password hashing", + "finding_type": "sast", + "cwe_id": "CWE-327", + "cwe_name": "Broken Crypto", + "owasp_category": null, + "tags": [], + "verdict": "likely", + "evidence_level": 1, + "rationale": "rationale for likely", + "severity": "medium", + "cvss_v4": null, + "epss": null, + "exploitability_score": 0.0, + "proof": { + "exploit_hypothesis": "unknown", + "verification_method": "composite_subagent_chain:sast", + "evidence_level": 1, + "data_flow_trace": [], + "data_flow_evidence": { + "steps": [], + "source": "unknown", + "sink": "unknown", + "sink_reached": false + }, + "sanitization_analysis": { + "sanitization_found": false, + "sanitization_type": null, + "sanitization_sufficient": null, + "bypass_possible": false, + "bypass_method": null + }, + "vulnerable_code": null, + "exploit_payload": null, + "expected_outcome": "unknown", + "poc_code": null, + "poc_execution_output": null, + "http_request": null, + "http_response": null, + "reachability": null, + "chain_steps": null + }, + "location": { + "file_path": "src/hash.py", + "start_line": 7, + "end_line": 7, + "start_column": null, + "end_column": null, + "function_name": null, + "code_snippet": "hashlib.md5(pw).hexdigest()" + }, + "related_locations": [], + "chain_id": null, + "chain_step": null, + "enables": null, + "compliance": [], + "reproduction_steps": [ + { + "step": 1, + "description": "Trace attacker-controlled input from source to sink in target code path.", + "command": null, + "expected_output": "Input reaches a sensitive sink." + }, + { + "step": 2, + "description": "unknown", + "command": null, + "expected_output": "unknown" + } + ], + "remediation": null, + "sarif_rule_id": "sec-af/sast/broken-crypto", + "sarif_security_severity": 0.0, + "drop_reason": null + } +} diff --git a/go/internal/agents/prove/testdata/golden/chain_builder_prompt.txt b/go/internal/agents/prove/testdata/golden/chain_builder_prompt.txt new file mode 100644 index 0000000..0362899 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/chain_builder_prompt.txt @@ -0,0 +1,129 @@ +ROLE: +You are a security attack-chain verifier for SEC-AF PROVE phase. + +CONTEXT: +- Analysis depth: standard +- Seed chain candidates from HUNT (validate and expand these): [ + { + "chain_id": "chain-1", + "title": "SSRF to internal API", + "finding_ids": [ + "v1", + "v2" + ], + "combined_impact": "Internal service access", + "estimated_severity": "critical" + } +] +- Individually verified findings: { + "v1": { + "id": "v1", + "fingerprint": "fp-v1", + "title": "finding v1", + "description": "d", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "SQL Injection", + "owasp_category": null, + "tags": [], + "verdict": "confirmed", + "evidence_level": 4, + "rationale": "r", + "severity": "high", + "cvss_v4": null, + "epss": null, + "exploitability_score": 7.5, + "proof": null, + "location": { + "file_path": "src/users.py", + "start_line": 1, + "end_line": 2, + "start_column": null, + "end_column": null, + "function_name": null, + "code_snippet": null + }, + "related_locations": [], + "chain_id": null, + "chain_step": null, + "enables": null, + "compliance": [], + "reproduction_steps": [], + "remediation": null, + "sarif_rule_id": "sec-af/sast/sql-injection", + "sarif_security_severity": 7.5, + "drop_reason": null + }, + "v2": { + "id": "v2", + "fingerprint": "fp-v2", + "title": "finding v2", + "description": "d", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "SQL Injection", + "owasp_category": null, + "tags": [], + "verdict": "confirmed", + "evidence_level": 2, + "rationale": "r", + "severity": "high", + "cvss_v4": null, + "epss": null, + "exploitability_score": 3.0, + "proof": null, + "location": { + "file_path": "src/users.py", + "start_line": 1, + "end_line": 2, + "start_column": null, + "end_column": null, + "function_name": null, + "code_snippet": null + }, + "related_locations": [], + "chain_id": null, + "chain_step": null, + "enables": null, + "compliance": [], + "reproduction_steps": [], + "remediation": null, + "sarif_rule_id": "sec-af/sast/sql-injection", + "sarif_security_severity": 3.0, + "drop_reason": null + } +} + +TASK: +Use the HUNT chain candidates as seeds, but do not assume they are complete. +Validate whether each step is exploitable and whether step N enables step N+1. +Look for additional multi-step attack chains beyond the provided seeds when verified findings support them. +A chain is multi-step when exploitation of one finding enables exploitation of another. + +OUTPUT: +- Return JSON matching ChainAnalysisResult with this shape: + { + "chains": [ + { + "chain_id": "...", + "title": "...", + "validated": true | false, + "rationale": "...", + "steps": [ + { + "step_number": 1, + "finding_id": "...", + "description": "...", + "enables": "..." + } + ] + } + ] + } + +CONSTRAINTS: +- Mark validated=true only when all links are plausible with evidence from verified findings. +- Keep step order aligned with exploitation progression. +- If not validated, include rationale and return empty steps. +- You may include newly discovered chains that were not in the seed list. +- Do not return markdown or prose outside JSON. diff --git a/go/internal/agents/prove/testdata/golden/chain_builder_prompt_input.json b/go/internal/agents/prove/testdata/golden/chain_builder_prompt_input.json new file mode 100644 index 0000000..b287109 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/chain_builder_prompt_input.json @@ -0,0 +1,93 @@ +{ + "depth": "standard", + "chains": [ + { + "chain_id": "chain-1", + "title": "SSRF to internal API", + "finding_ids": [ + "v1", + "v2" + ], + "combined_impact": "Internal service access", + "estimated_severity": "critical" + } + ], + "findings": [ + { + "id": "v1", + "fingerprint": "fp-v1", + "title": "finding v1", + "description": "d", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "SQL Injection", + "owasp_category": null, + "tags": [], + "verdict": "confirmed", + "evidence_level": 4, + "rationale": "r", + "severity": "high", + "cvss_v4": null, + "epss": null, + "exploitability_score": 7.5, + "proof": null, + "location": { + "file_path": "src/users.py", + "start_line": 1, + "end_line": 2, + "start_column": null, + "end_column": null, + "function_name": null, + "code_snippet": null + }, + "related_locations": [], + "chain_id": null, + "chain_step": null, + "enables": null, + "compliance": [], + "reproduction_steps": [], + "remediation": null, + "sarif_rule_id": "sec-af/sast/sql-injection", + "sarif_security_severity": 7.5, + "drop_reason": null + }, + { + "id": "v2", + "fingerprint": "fp-v2", + "title": "finding v2", + "description": "d", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "SQL Injection", + "owasp_category": null, + "tags": [], + "verdict": "confirmed", + "evidence_level": 2, + "rationale": "r", + "severity": "high", + "cvss_v4": null, + "epss": null, + "exploitability_score": 3.0, + "proof": null, + "location": { + "file_path": "src/users.py", + "start_line": 1, + "end_line": 2, + "start_column": null, + "end_column": null, + "function_name": null, + "code_snippet": null + }, + "related_locations": [], + "chain_id": null, + "chain_step": null, + "enables": null, + "compliance": [], + "reproduction_steps": [], + "remediation": null, + "sarif_rule_id": "sec-af/sast/sql-injection", + "sarif_security_severity": 3.0, + "drop_reason": null + } + ] +} diff --git a/go/internal/agents/prove/testdata/golden/cross_service_prompt_A.txt b/go/internal/agents/prove/testdata/golden/cross_service_prompt_A.txt new file mode 100644 index 0000000..c2c200d --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/cross_service_prompt_A.txt @@ -0,0 +1,44 @@ +ROLE: +You are a cross-service security analyst for SEC-AF. You identify attack chains that span multiple microservices. + +CONTEXT: +Multiple services in a microservice architecture have been individually scanned. +Your task is to identify attack paths that cross service boundaries. + +Services: +[ + "gateway", + "billing-caf\u00e9", + "db<&>" +] + +Individual findings summary: +- gateway: SSRF +- billing: IDOR + +Depth: thorough + +TASK: +Analyze cross-service attack vectors: +1. Identify API call chains between services +2. Find trust boundary violations where one service trusts another's input +3. Trace data flow across service boundaries (e.g., public API -> internal service -> database) +4. Identify privilege escalation paths across services + +OUTPUT: +Return a JSON object matching CrossServiceFinding schema: +- chain_description: Full description of the cross-service attack path +- services_involved: List of service names in the chain +- entry_point: Where an attacker would begin (public-facing endpoint) +- impact: Business impact if exploited + +CONSTRAINTS: +- Focus on realistic cross-service paths, not theoretical +- Consider API authentication between services +- Consider data validation at service boundaries +- Do not return markdown or prose outside the JSON object + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Use the repository path above for cross-service inspection. \ No newline at end of file diff --git a/go/internal/agents/prove/testdata/golden/cross_service_prompt_B.txt b/go/internal/agents/prove/testdata/golden/cross_service_prompt_B.txt new file mode 100644 index 0000000..56d84eb --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/cross_service_prompt_B.txt @@ -0,0 +1,39 @@ +ROLE: +You are a cross-service security analyst for SEC-AF. You identify attack chains that span multiple microservices. + +CONTEXT: +Multiple services in a microservice architecture have been individually scanned. +Your task is to identify attack paths that cross service boundaries. + +Services: +[] + +Individual findings summary: + + +Depth: quick + +TASK: +Analyze cross-service attack vectors: +1. Identify API call chains between services +2. Find trust boundary violations where one service trusts another's input +3. Trace data flow across service boundaries (e.g., public API -> internal service -> database) +4. Identify privilege escalation paths across services + +OUTPUT: +Return a JSON object matching CrossServiceFinding schema: +- chain_description: Full description of the cross-service attack path +- services_involved: List of service names in the chain +- entry_point: Where an attacker would begin (public-facing endpoint) +- impact: Business impact if exploited + +CONSTRAINTS: +- Focus on realistic cross-service paths, not theoretical +- Consider API authentication between services +- Consider data validation at service boundaries +- Do not return markdown or prose outside the JSON object + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Use the repository path above for cross-service inspection. \ No newline at end of file diff --git a/go/internal/agents/prove/testdata/golden/dast_prompt_A.txt b/go/internal/agents/prove/testdata/golden/dast_prompt_A.txt new file mode 100644 index 0000000..2cd32d3 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/dast_prompt_A.txt @@ -0,0 +1,38 @@ +ROLE: +You are a DAST (Dynamic Application Security Testing) verification specialist for SEC-AF. + +CONTEXT: +A static analysis finding has been identified and an exploit hypothesis generated. +Your task is to design and evaluate a runtime verification approach for this finding. + +Finding: Potential SQL injection in user lookup +Description: Request parameter reaches a formatted SQL string. Marker: {{TITLE}} +CWE: CWE-89 +File: src/users.py +Proposed payload: 1 OR 1=1 -- thorough +Depth: thorough + +TASK: +Analyze whether the proposed exploit payload would succeed at runtime: +1. Evaluate the payload against the application's runtime behavior +2. Consider framework protections, middleware, and runtime sanitization +3. Determine if the exploit would be blocked, modified, or succeed +4. Document safety measures that would be needed for actual execution + +OUTPUT: +Return a JSON object matching DastVerificationResult schema with: +- payload_sent: The specific payload analyzed +- response_summary: Expected application behavior +- exploit_confirmed: Whether exploit would succeed at runtime +- safety_notes: Safety measures for actual verification + +CONSTRAINTS: +- This is SIMULATED verification -- analyze expected runtime behavior, do not execute +- Consider all runtime protections (WAF, framework middleware, input validation) +- Be conservative: only confirm if runtime success is highly likely +- Do not return markdown or prose outside the JSON object + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Use the repository path above for file inspection during DAST-style verification. \ No newline at end of file diff --git a/go/internal/agents/prove/testdata/golden/dast_prompt_B.txt b/go/internal/agents/prove/testdata/golden/dast_prompt_B.txt new file mode 100644 index 0000000..38655a3 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/dast_prompt_B.txt @@ -0,0 +1,38 @@ +ROLE: +You are a DAST (Dynamic Application Security Testing) verification specialist for SEC-AF. + +CONTEXT: +A static analysis finding has been identified and an exploit hypothesis generated. +Your task is to design and evaluate a runtime verification approach for this finding. + +Finding: Weak hash +Description: MD5 used for password hashing +CWE: CWE-327 +File: src/hash.py +Proposed payload: +Depth: quick + +TASK: +Analyze whether the proposed exploit payload would succeed at runtime: +1. Evaluate the payload against the application's runtime behavior +2. Consider framework protections, middleware, and runtime sanitization +3. Determine if the exploit would be blocked, modified, or succeed +4. Document safety measures that would be needed for actual execution + +OUTPUT: +Return a JSON object matching DastVerificationResult schema with: +- payload_sent: The specific payload analyzed +- response_summary: Expected application behavior +- exploit_confirmed: Whether exploit would succeed at runtime +- safety_notes: Safety measures for actual verification + +CONSTRAINTS: +- This is SIMULATED verification -- analyze expected runtime behavior, do not execute +- Consider all runtime protections (WAF, framework middleware, input validation) +- Be conservative: only confirm if runtime success is highly likely +- Do not return markdown or prose outside the JSON object + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Use the repository path above for file inspection during DAST-style verification. \ No newline at end of file diff --git a/go/internal/agents/prove/testdata/golden/dep_reachability_input_A.json b/go/internal/agents/prove/testdata/golden/dep_reachability_input_A.json new file mode 100644 index 0000000..a408680 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/dep_reachability_input_A.json @@ -0,0 +1,19 @@ +{ + "cve": "CVE-2021-44228", + "package": "log4j-core", + "vulnerable_function": "JndiLookup.lookup", + "version": "2.14.1", + "evidence": { + "direct": true, + "manifest": "pom.xml", + "nested": { + "a": 1, + "b": [ + true, + null + ] + }, + "score": 9.8, + "transitive_depth": 2 + } +} diff --git a/go/internal/agents/prove/testdata/golden/dep_reachability_input_C.json b/go/internal/agents/prove/testdata/golden/dep_reachability_input_C.json new file mode 100644 index 0000000..bf2210c --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/dep_reachability_input_C.json @@ -0,0 +1,10 @@ +{ + "cve": 1234, + "package": null, + "vulnerable_function": 3.5, + "version": true, + "evidence": [ + 1, + "two" + ] +} diff --git a/go/internal/agents/prove/testdata/golden/dep_reachability_prompt_A.txt b/go/internal/agents/prove/testdata/golden/dep_reachability_prompt_A.txt new file mode 100644 index 0000000..34cd4c7 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/dep_reachability_prompt_A.txt @@ -0,0 +1,43 @@ +ROLE: +You are DependencyReachabilityAnalyzer, a focused security analysis sub-agent. + +CONTEXT: +- Analysis depth: thorough +- CVE: CVE-2021-44228 +- Package: log4j-core +- Version: 2.14.1 +- Vulnerable function: JndiLookup.lookup +- Additional evidence/hints: { + "direct": true, + "manifest": "pom.xml", + "nested": { + "a": 1, + "b": [ + true, + null + ] + }, + "score": 9.8, + "transitive_depth": 2 +} + +TASK: +1) Inspect the repository to determine whether application code imports/calls into the vulnerable dependency path. +2) Build a concrete import/call chain from app entry points to JndiLookup.lookup when possible. +3) Decide if the vulnerable function is reachable in runtime paths. +4) Determine whether log4j-core is a direct dependency or transitive dependency. + +OUTPUT: +- Return JSON that strictly matches ReachabilityProof. +- Fill vulnerable_function, call_chain, reachable, and direct. +- call_chain must be ordered app-to-dependency evidence and can be empty when unreachable. + +CONSTRAINTS: +- Take multiple turns for file inspection before final JSON. +- Be evidence-driven; avoid assumptions when the chain is not present. +- Do not return markdown, prose wrappers, or code fences. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Use the repository path above for file inspection during dependency reachability analysis. \ No newline at end of file diff --git a/go/internal/agents/prove/testdata/golden/dep_reachability_prompt_B.txt b/go/internal/agents/prove/testdata/golden/dep_reachability_prompt_B.txt new file mode 100644 index 0000000..bd5e8bf --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/dep_reachability_prompt_B.txt @@ -0,0 +1,31 @@ +ROLE: +You are DependencyReachabilityAnalyzer, a focused security analysis sub-agent. + +CONTEXT: +- Analysis depth: quick +- CVE: +- Package: +- Version: +- Vulnerable function: +- Additional evidence/hints: {} + +TASK: +1) Inspect the repository to determine whether application code imports/calls into the vulnerable dependency path. +2) Build a concrete import/call chain from app entry points to when possible. +3) Decide if the vulnerable function is reachable in runtime paths. +4) Determine whether is a direct dependency or transitive dependency. + +OUTPUT: +- Return JSON that strictly matches ReachabilityProof. +- Fill vulnerable_function, call_chain, reachable, and direct. +- call_chain must be ordered app-to-dependency evidence and can be empty when unreachable. + +CONSTRAINTS: +- Take multiple turns for file inspection before final JSON. +- Be evidence-driven; avoid assumptions when the chain is not present. +- Do not return markdown, prose wrappers, or code fences. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Use the repository path above for file inspection during dependency reachability analysis. \ No newline at end of file diff --git a/go/internal/agents/prove/testdata/golden/dep_reachability_prompt_C.txt b/go/internal/agents/prove/testdata/golden/dep_reachability_prompt_C.txt new file mode 100644 index 0000000..7ef8527 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/dep_reachability_prompt_C.txt @@ -0,0 +1,34 @@ +ROLE: +You are DependencyReachabilityAnalyzer, a focused security analysis sub-agent. + +CONTEXT: +- Analysis depth: standard +- CVE: 1234 +- Package: None +- Version: True +- Vulnerable function: 3.5 +- Additional evidence/hints: [ + 1, + "two" +] + +TASK: +1) Inspect the repository to determine whether application code imports/calls into the vulnerable dependency path. +2) Build a concrete import/call chain from app entry points to 3.5 when possible. +3) Decide if the vulnerable function is reachable in runtime paths. +4) Determine whether None is a direct dependency or transitive dependency. + +OUTPUT: +- Return JSON that strictly matches ReachabilityProof. +- Fill vulnerable_function, call_chain, reachable, and direct. +- call_chain must be ordered app-to-dependency evidence and can be empty when unreachable. + +CONSTRAINTS: +- Take multiple turns for file inspection before final JSON. +- Be evidence-driven; avoid assumptions when the chain is not present. +- Do not return markdown, prose wrappers, or code fences. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Use the repository path above for file inspection during dependency reachability analysis. \ No newline at end of file diff --git a/go/internal/agents/prove/testdata/golden/exploit_prompt_A.txt b/go/internal/agents/prove/testdata/golden/exploit_prompt_A.txt new file mode 100644 index 0000000..502a228 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/exploit_prompt_A.txt @@ -0,0 +1,51 @@ +ROLE: +You are ExploitHypothesizer, a focused security analysis sub-agent. + +CONTEXT: +- Finding type: sast +- Analysis depth: thorough +- Related files: [ + "src/routes.py", + "src/caf\u00e9 &.py" +] +- Data flow context: +Source: request.args['id'] +Sink: cursor.execute(query) +Sink reached: yes +Trace steps: +- src/routes.py:10 read request.args +- src/users.py:42 execute +- Sanitization context: +Sanitization found: yes +Sanitization type: parameterized query +Sanitization sufficient: no +Bypass method: second-order injection through the audit log + +TASK: +1) Read code at src/users.py around line 42 and inspect supporting files as needed. +2) Build the most plausible exploit scenario for this finding. +3) Propose concrete payload if feasible. +4) State expected observable outcome on success. + +SCOPE: +FINDING: +- Title: Potential SQL injection in user lookup +- Description: Request parameter reaches a formatted SQL string. Marker: {{TITLE}} +- CWE: CWE-89 - Improper Neutralization/Escaping of Special Elements +- File: src/users.py:42 +- Code snippet: +cursor.execute("SELECT * FROM users WHERE id = " + user_id) # depth=thorough + +OUTPUT: +- Return JSON that strictly matches ExploitHypothesis. +- Fill hypothesis, payload, and expected_outcome. + +CONSTRAINTS: +- Take multiple turns for file inspection before final JSON. +- Keep exploit logic consistent with trace and sanitization context. +- Do not return markdown, prose wrappers, or code fences. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Use the repository path above for file inspection during exploit construction. \ No newline at end of file diff --git a/go/internal/agents/prove/testdata/golden/exploit_prompt_B.txt b/go/internal/agents/prove/testdata/golden/exploit_prompt_B.txt new file mode 100644 index 0000000..6891193 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/exploit_prompt_B.txt @@ -0,0 +1,47 @@ +ROLE: +You are ExploitHypothesizer, a focused security analysis sub-agent. + +CONTEXT: +- Finding type: sast +- Analysis depth: quick +- Related files: [] +- Data flow context: +Source: unknown +Sink: unknown +Sink reached: no +Trace steps: +- (no concrete trace steps) +- Sanitization context: +Sanitization found: no +Sanitization type: none +Sanitization sufficient: unknown +Bypass method: none + +TASK: +1) Read code at src/hash.py around line 7 and inspect supporting files as needed. +2) Build the most plausible exploit scenario for this finding. +3) Propose concrete payload if feasible. +4) State expected observable outcome on success. + +SCOPE: +FINDING: +- Title: Weak hash +- Description: MD5 used for password hashing +- CWE: CWE-327 - Broken Crypto +- File: src/hash.py:7 +- Code snippet: +hashlib.md5(pw).hexdigest() + +OUTPUT: +- Return JSON that strictly matches ExploitHypothesis. +- Fill hypothesis, payload, and expected_outcome. + +CONSTRAINTS: +- Take multiple turns for file inspection before final JSON. +- Keep exploit logic consistent with trace and sanitization context. +- Do not return markdown, prose wrappers, or code fences. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Use the repository path above for file inspection during exploit construction. \ No newline at end of file diff --git a/go/internal/agents/prove/testdata/golden/fallback.json b/go/internal/agents/prove/testdata/golden/fallback.json new file mode 100644 index 0000000..e67859e --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/fallback.json @@ -0,0 +1,158 @@ +{ + "plain": { + "id": "raw-1", + "fingerprint": "fp-1", + "title": "Potential SQL injection in user lookup", + "description": "Request parameter reaches a formatted SQL string. Marker: {{TITLE}}", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "Improper Neutralization/Escaping of Special Elements", + "owasp_category": "A03:2021 - Injection", + "tags": [], + "verdict": "inconclusive", + "evidence_level": 1, + "rationale": "Verification incomplete: harness blew up", + "severity": "high", + "cvss_v4": null, + "epss": null, + "exploitability_score": 0.0, + "proof": null, + "location": { + "file_path": "src/users.py", + "start_line": 42, + "end_line": 44, + "start_column": null, + "end_column": null, + "function_name": "get_user", + "code_snippet": "cursor.execute(\"SELECT * FROM users WHERE id = \" + user_id) # depth={{DEPTH}}" + }, + "related_locations": [], + "chain_id": null, + "chain_step": null, + "enables": null, + "compliance": [], + "reproduction_steps": [], + "remediation": null, + "sarif_rule_id": "sec-af/sast/improper-neutralization-escaping-of-special-elements", + "sarif_security_severity": 0.0, + "drop_reason": null + }, + "with_drop_reason": { + "id": "raw-1", + "fingerprint": "fp-1", + "title": "Potential SQL injection in user lookup", + "description": "Request parameter reaches a formatted SQL string. Marker: {{TITLE}}", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "Improper Neutralization/Escaping of Special Elements", + "owasp_category": "A03:2021 - Injection", + "tags": [ + "low_confidence" + ], + "verdict": "inconclusive", + "evidence_level": 1, + "rationale": "Verification incomplete: boom", + "severity": "high", + "cvss_v4": null, + "epss": null, + "exploitability_score": 0.0, + "proof": null, + "location": { + "file_path": "src/users.py", + "start_line": 42, + "end_line": 44, + "start_column": null, + "end_column": null, + "function_name": "get_user", + "code_snippet": "cursor.execute(\"SELECT * FROM users WHERE id = \" + user_id) # depth={{DEPTH}}" + }, + "related_locations": [], + "chain_id": null, + "chain_step": null, + "enables": null, + "compliance": [], + "reproduction_steps": [], + "remediation": null, + "sarif_rule_id": "sec-af/sast/improper-neutralization-escaping-of-special-elements", + "sarif_security_severity": 0.0, + "drop_reason": "verifier_error" + }, + "demoted": { + "id": "raw-2", + "fingerprint": "fp-2", + "title": "Weak hash", + "description": "MD5 used for password hashing", + "finding_type": "sast", + "cwe_id": "CWE-327", + "cwe_name": "Broken Crypto", + "owasp_category": null, + "tags": [ + "low_confidence" + ], + "verdict": "inconclusive", + "evidence_level": 1, + "rationale": "Verification incomplete: Verifier returned unverified verdict; demoted for manual review (original verdict: unverified)", + "severity": "medium", + "cvss_v4": null, + "epss": null, + "exploitability_score": 0.0, + "proof": null, + "location": { + "file_path": "src/hash.py", + "start_line": 7, + "end_line": 7, + "start_column": null, + "end_column": null, + "function_name": null, + "code_snippet": "hashlib.md5(pw).hexdigest()" + }, + "related_locations": [], + "chain_id": null, + "chain_step": null, + "enables": null, + "compliance": [], + "reproduction_steps": [], + "remediation": null, + "sarif_rule_id": "sec-af/sast/broken-crypto", + "sarif_security_severity": 0.0, + "drop_reason": "verdict_unverified" + }, + "original_verdict_only": { + "id": "raw-2", + "fingerprint": "fp-2", + "title": "Weak hash", + "description": "MD5 used for password hashing", + "finding_type": "sast", + "cwe_id": "CWE-327", + "cwe_name": "Broken Crypto", + "owasp_category": null, + "tags": [], + "verdict": "inconclusive", + "evidence_level": 1, + "rationale": "Verification incomplete: why (original verdict: unverified)", + "severity": "medium", + "cvss_v4": null, + "epss": null, + "exploitability_score": 0.0, + "proof": null, + "location": { + "file_path": "src/hash.py", + "start_line": 7, + "end_line": 7, + "start_column": null, + "end_column": null, + "function_name": null, + "code_snippet": "hashlib.md5(pw).hexdigest()" + }, + "related_locations": [], + "chain_id": null, + "chain_step": null, + "enables": null, + "compliance": [], + "reproduction_steps": [], + "remediation": null, + "sarif_rule_id": "sec-af/sast/broken-crypto", + "sarif_security_severity": 0.0, + "drop_reason": null + } +} diff --git a/go/internal/agents/prove/testdata/golden/priority_sort.json b/go/internal/agents/prove/testdata/golden/priority_sort.json new file mode 100644 index 0000000..69fb933 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/priority_sort.json @@ -0,0 +1,42 @@ +{ + "input": [ + { + "id": "a", + "estimated_severity": "low", + "confidence": "high" + }, + { + "id": "b", + "estimated_severity": "critical", + "confidence": "low" + }, + { + "id": "c", + "estimated_severity": "critical", + "confidence": "high" + }, + { + "id": "d", + "estimated_severity": "info", + "confidence": "medium" + }, + { + "id": "e", + "estimated_severity": "critical", + "confidence": "low" + }, + { + "id": "f", + "estimated_severity": "medium", + "confidence": "medium" + } + ], + "want_ids": [ + "c", + "b", + "e", + "f", + "a", + "d" + ] +} diff --git a/go/internal/agents/prove/testdata/golden/run_prove.json b/go/internal/agents/prove/testdata/golden/run_prove.json new file mode 100644 index 0000000..33bfac0 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/run_prove.json @@ -0,0 +1,362 @@ +{ + "repo_path": "/fixtures/demo-repo", + "depth": "standard", + "hunt_result": { + "findings": [ + { + "id": "raw-2", + "hunter_strategy": "crypto", + "title": "Weak hash", + "description": "MD5 used for password hashing", + "finding_type": "sast", + "cwe_id": "CWE-327", + "cwe_name": "Broken Crypto", + "owasp_category": null, + "file_path": "src/hash.py", + "start_line": 7, + "end_line": 7, + "function_name": null, + "code_snippet": "hashlib.md5(pw).hexdigest()", + "estimated_severity": "medium", + "confidence": "low", + "data_flow": null, + "related_files": [], + "fingerprint": "fp-2" + }, + { + "id": "raw-1", + "hunter_strategy": "injection", + "title": "Potential SQL injection in user lookup", + "description": "Request parameter reaches a formatted SQL string. Marker: {{TITLE}}", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "Improper Neutralization/Escaping of Special Elements", + "owasp_category": "A03:2021 - Injection", + "file_path": "src/users.py", + "start_line": 42, + "end_line": 44, + "function_name": "get_user", + "code_snippet": "cursor.execute(\"SELECT * FROM users WHERE id = \" + user_id) # depth={{DEPTH}}", + "estimated_severity": "high", + "confidence": "high", + "data_flow": [ + { + "file_path": "src/routes.py", + "line": 10, + "component": "handler", + "operation": "read request.args" + }, + { + "file_path": "src/users.py", + "line": 42, + "component": "db", + "operation": "execute" + } + ], + "related_files": [ + "src/routes.py", + "src/caf\u00e9 &.py" + ], + "fingerprint": "fp-1" + } + ], + "chains": [], + "total_raw": 2, + "deduplicated_count": 2, + "chain_count": 0, + "strategies_run": [ + "injection" + ], + "hunt_duration_seconds": 0.0 + }, + "canned": { + "tracer": { + "source": "request.args['id']", + "sink": "cursor.execute(query)", + "steps": [ + "src/routes.py:10 read request.args", + "src/users.py:42 execute" + ], + "sink_reached": true + }, + "sanitization": { + "found": true, + "type": "parameterized query", + "sufficient": false, + "bypass_method": "second-order injection through the audit log" + }, + "exploit": { + "hypothesis": "Attacker supplies id=1 OR 1=1 to dump the table", + "payload": "1 OR 1=1", + "expected_outcome": "Full users table returned" + }, + "verdict": { + "verdict": "confirmed", + "evidence_level": 5, + "rationale": "canned rationale", + "confidence": "high" + } + }, + "want": [ + { + "id": "raw-1", + "fingerprint": "fp-1", + "title": "Potential SQL injection in user lookup", + "description": "Request parameter reaches a formatted SQL string. Marker: {{TITLE}}", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "Improper Neutralization/Escaping of Special Elements", + "owasp_category": "A03:2021 - Injection", + "tags": [], + "verdict": "confirmed", + "evidence_level": 5, + "rationale": "canned rationale", + "severity": "critical", + "cvss_v4": null, + "epss": null, + "exploitability_score": 9.0, + "proof": { + "exploit_hypothesis": "Attacker supplies id=1 OR 1=1 to dump the table", + "verification_method": "composite_subagent_chain:sast", + "evidence_level": 5, + "data_flow_trace": [ + { + "file": "trace_step_1", + "line": 1, + "description": "src/routes.py:10 read request.args", + "tainted": true + }, + { + "file": "trace_step_2", + "line": 2, + "description": "src/users.py:42 execute", + "tainted": true + } + ], + "data_flow_evidence": { + "steps": [ + { + "file": "trace_step_1", + "line": 1, + "description": "src/routes.py:10 read request.args", + "tainted": true + }, + { + "file": "trace_step_2", + "line": 2, + "description": "src/users.py:42 execute", + "tainted": true + } + ], + "source": "request.args['id']", + "sink": "cursor.execute(query)", + "sink_reached": true + }, + "sanitization_analysis": { + "sanitization_found": true, + "sanitization_type": "parameterized query", + "sanitization_sufficient": false, + "bypass_possible": true, + "bypass_method": "second-order injection through the audit log" + }, + "vulnerable_code": null, + "exploit_payload": "1 OR 1=1", + "expected_outcome": "Full users table returned", + "poc_code": null, + "poc_execution_output": null, + "http_request": null, + "http_response": null, + "reachability": null, + "chain_steps": null + }, + "location": { + "file_path": "src/users.py", + "start_line": 42, + "end_line": 44, + "start_column": null, + "end_column": null, + "function_name": "get_user", + "code_snippet": "cursor.execute(\"SELECT * FROM users WHERE id = \" + user_id) # depth={{DEPTH}}" + }, + "related_locations": [], + "chain_id": null, + "chain_step": null, + "enables": null, + "compliance": [ + { + "framework": "PCI-DSS", + "control_id": "Req 6.2.4", + "control_name": "Custom software addresses common coding vulnerabilities" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A03:2021", + "control_name": "Injection" + }, + { + "framework": "HIPAA", + "control_id": "\u00a7164.312(a)(1)", + "control_name": "Access control" + }, + { + "framework": "ISO27001", + "control_id": "A.8.28", + "control_name": "Secure coding" + } + ], + "reproduction_steps": [ + { + "step": 1, + "description": "Trace attacker-controlled input from source to sink in target code path.", + "command": null, + "expected_output": "Input reaches a sensitive sink." + }, + { + "step": 2, + "description": "Attacker supplies id=1 OR 1=1 to dump the table", + "command": "1 OR 1=1", + "expected_output": "Full users table returned" + } + ], + "remediation": null, + "sarif_rule_id": "sec-af/sast/improper-neutralization-escaping-of-special-elements", + "sarif_security_severity": 9.0, + "drop_reason": null + }, + { + "id": "raw-2", + "fingerprint": "fp-2", + "title": "Weak hash", + "description": "MD5 used for password hashing", + "finding_type": "sast", + "cwe_id": "CWE-327", + "cwe_name": "Broken Crypto", + "owasp_category": null, + "tags": [], + "verdict": "confirmed", + "evidence_level": 5, + "rationale": "canned rationale", + "severity": "medium", + "cvss_v4": null, + "epss": null, + "exploitability_score": 4.5, + "proof": { + "exploit_hypothesis": "Attacker supplies id=1 OR 1=1 to dump the table", + "verification_method": "composite_subagent_chain:sast", + "evidence_level": 5, + "data_flow_trace": [ + { + "file": "trace_step_1", + "line": 1, + "description": "src/routes.py:10 read request.args", + "tainted": true + }, + { + "file": "trace_step_2", + "line": 2, + "description": "src/users.py:42 execute", + "tainted": true + } + ], + "data_flow_evidence": { + "steps": [ + { + "file": "trace_step_1", + "line": 1, + "description": "src/routes.py:10 read request.args", + "tainted": true + }, + { + "file": "trace_step_2", + "line": 2, + "description": "src/users.py:42 execute", + "tainted": true + } + ], + "source": "request.args['id']", + "sink": "cursor.execute(query)", + "sink_reached": true + }, + "sanitization_analysis": { + "sanitization_found": true, + "sanitization_type": "parameterized query", + "sanitization_sufficient": false, + "bypass_possible": true, + "bypass_method": "second-order injection through the audit log" + }, + "vulnerable_code": null, + "exploit_payload": "1 OR 1=1", + "expected_outcome": "Full users table returned", + "poc_code": null, + "poc_execution_output": null, + "http_request": null, + "http_response": null, + "reachability": null, + "chain_steps": null + }, + "location": { + "file_path": "src/hash.py", + "start_line": 7, + "end_line": 7, + "start_column": null, + "end_column": null, + "function_name": null, + "code_snippet": "hashlib.md5(pw).hexdigest()" + }, + "related_locations": [], + "chain_id": null, + "chain_step": null, + "enables": null, + "compliance": [ + { + "framework": "PCI-DSS", + "control_id": "Req 3", + "control_name": "Protect stored account data" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A02:2021", + "control_name": "Cryptographic Failures" + }, + { + "framework": "HIPAA", + "control_id": "\u00a7164.312(a)(2)(iv)", + "control_name": "Encryption and decryption" + }, + { + "framework": "ISO27001", + "control_id": "A.8.24", + "control_name": "Use of cryptography" + } + ], + "reproduction_steps": [ + { + "step": 1, + "description": "Trace attacker-controlled input from source to sink in target code path.", + "command": null, + "expected_output": "Input reaches a sensitive sink." + }, + { + "step": 2, + "description": "Attacker supplies id=1 OR 1=1 to dump the table", + "command": "1 OR 1=1", + "expected_output": "Full users table returned" + } + ], + "remediation": null, + "sarif_rule_id": "sec-af/sast/broken-crypto", + "sarif_security_severity": 4.5, + "drop_reason": null + } + ] +} diff --git a/go/internal/agents/prove/testdata/golden/run_verifier.json b/go/internal/agents/prove/testdata/golden/run_verifier.json new file mode 100644 index 0000000..0bb0af1 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/run_verifier.json @@ -0,0 +1,197 @@ +{ + "not_exploitable": { + "id": "raw-1", + "fingerprint": "fp-1", + "title": "Potential SQL injection in user lookup", + "description": "Request parameter reaches a formatted SQL string. Marker: {{TITLE}}", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "Improper Neutralization/Escaping of Special Elements", + "owasp_category": "A03:2021 - Injection", + "tags": [], + "verdict": "not_exploitable", + "evidence_level": 1, + "rationale": "canned rationale", + "severity": "high", + "cvss_v4": null, + "epss": null, + "exploitability_score": 0.0, + "proof": { + "exploit_hypothesis": "Attacker supplies id=1 OR 1=1 to dump the table", + "verification_method": "composite_subagent_chain:sast", + "evidence_level": 1, + "data_flow_trace": [ + { + "file": "trace_step_1", + "line": 1, + "description": "src/routes.py:10 read request.args", + "tainted": true + }, + { + "file": "trace_step_2", + "line": 2, + "description": "src/users.py:42 execute", + "tainted": true + } + ], + "data_flow_evidence": { + "steps": [ + { + "file": "trace_step_1", + "line": 1, + "description": "src/routes.py:10 read request.args", + "tainted": true + }, + { + "file": "trace_step_2", + "line": 2, + "description": "src/users.py:42 execute", + "tainted": true + } + ], + "source": "request.args['id']", + "sink": "cursor.execute(query)", + "sink_reached": true + }, + "sanitization_analysis": { + "sanitization_found": true, + "sanitization_type": "parameterized query", + "sanitization_sufficient": false, + "bypass_possible": true, + "bypass_method": "second-order injection through the audit log" + }, + "vulnerable_code": null, + "exploit_payload": "1 OR 1=1", + "expected_outcome": "Full users table returned", + "poc_code": null, + "poc_execution_output": null, + "http_request": null, + "http_response": null, + "reachability": null, + "chain_steps": null + }, + "location": { + "file_path": "src/users.py", + "start_line": 42, + "end_line": 44, + "start_column": null, + "end_column": null, + "function_name": "get_user", + "code_snippet": "cursor.execute(\"SELECT * FROM users WHERE id = \" + user_id) # depth={{DEPTH}}" + }, + "related_locations": [], + "chain_id": null, + "chain_step": null, + "enables": null, + "compliance": [], + "reproduction_steps": [], + "remediation": null, + "sarif_rule_id": "sec-af/sast/improper-neutralization-escaping-of-special-elements", + "sarif_security_severity": 0.0, + "drop_reason": null + }, + "likely": { + "id": "raw-2", + "fingerprint": "fp-2", + "title": "Weak hash", + "description": "MD5 used for password hashing", + "finding_type": "sast", + "cwe_id": "CWE-327", + "cwe_name": "Broken Crypto", + "owasp_category": null, + "tags": [], + "verdict": "likely", + "evidence_level": 3, + "rationale": "canned rationale", + "severity": "medium", + "cvss_v4": null, + "epss": null, + "exploitability_score": 0.0, + "proof": { + "exploit_hypothesis": "Attacker supplies id=1 OR 1=1 to dump the table", + "verification_method": "composite_subagent_chain:sast", + "evidence_level": 3, + "data_flow_trace": [ + { + "file": "trace_step_1", + "line": 1, + "description": "src/routes.py:10 read request.args", + "tainted": true + }, + { + "file": "trace_step_2", + "line": 2, + "description": "src/users.py:42 execute", + "tainted": true + } + ], + "data_flow_evidence": { + "steps": [ + { + "file": "trace_step_1", + "line": 1, + "description": "src/routes.py:10 read request.args", + "tainted": true + }, + { + "file": "trace_step_2", + "line": 2, + "description": "src/users.py:42 execute", + "tainted": true + } + ], + "source": "request.args['id']", + "sink": "cursor.execute(query)", + "sink_reached": true + }, + "sanitization_analysis": { + "sanitization_found": true, + "sanitization_type": "parameterized query", + "sanitization_sufficient": false, + "bypass_possible": true, + "bypass_method": "second-order injection through the audit log" + }, + "vulnerable_code": null, + "exploit_payload": "1 OR 1=1", + "expected_outcome": "Full users table returned", + "poc_code": null, + "poc_execution_output": null, + "http_request": null, + "http_response": null, + "reachability": null, + "chain_steps": null + }, + "location": { + "file_path": "src/hash.py", + "start_line": 7, + "end_line": 7, + "start_column": null, + "end_column": null, + "function_name": null, + "code_snippet": "hashlib.md5(pw).hexdigest()" + }, + "related_locations": [], + "chain_id": null, + "chain_step": null, + "enables": null, + "compliance": [], + "reproduction_steps": [ + { + "step": 1, + "description": "Trace attacker-controlled input from source to sink in target code path.", + "command": null, + "expected_output": "Input reaches a sensitive sink." + }, + { + "step": 2, + "description": "Attacker supplies id=1 OR 1=1 to dump the table", + "command": "1 OR 1=1", + "expected_output": "Full users table returned" + } + ], + "remediation": null, + "sarif_rule_id": "sec-af/sast/broken-crypto", + "sarif_security_severity": 0.0, + "drop_reason": null + } +} diff --git a/go/internal/agents/prove/testdata/golden/sanitization_prompt_A.txt b/go/internal/agents/prove/testdata/golden/sanitization_prompt_A.txt new file mode 100644 index 0000000..6d83b89 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/sanitization_prompt_A.txt @@ -0,0 +1,46 @@ +ROLE: +You are SanitizationAnalyzer, a focused security analysis sub-agent. + +CONTEXT: +- Finding type: sast +- Analysis depth: thorough +- Related files: [ + "src/routes.py", + "src/caf\u00e9 &.py" +] +- Data flow context from tracer/hints: +Source: request.args['id'] +Sink: cursor.execute(query) +Sink reached: yes +Trace steps: +- src/routes.py:10 read request.args +- src/users.py:42 execute + +TASK: +1) Read code at src/users.py around line 42 and inspect related files as needed. +2) Locate validation/sanitization/encoding controls on the source-to-sink path. +3) Assess whether controls are sufficient against realistic attacker input. +4) If bypassable, describe practical bypass method. + +SCOPE: +FINDING: +- Title: Potential SQL injection in user lookup +- Description: Request parameter reaches a formatted SQL string. Marker: {{TITLE}} +- CWE: CWE-89 - Improper Neutralization/Escaping of Special Elements +- File: src/users.py:42 +- Code snippet: +cursor.execute("SELECT * FROM users WHERE id = " + user_id) # depth=thorough + +OUTPUT: +- Return JSON that strictly matches SanitizationResult. +- Fill found, type, sufficient, and bypass_method. + +CONSTRAINTS: +- Take multiple turns for file inspection before final JSON. +- Be explicit about uncertainty using null values where needed. +- Do not return markdown, prose wrappers, or code fences. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Use the repository path above for file inspection during sanitization analysis. \ No newline at end of file diff --git a/go/internal/agents/prove/testdata/golden/sanitization_prompt_B.txt b/go/internal/agents/prove/testdata/golden/sanitization_prompt_B.txt new file mode 100644 index 0000000..e77dee4 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/sanitization_prompt_B.txt @@ -0,0 +1,42 @@ +ROLE: +You are SanitizationAnalyzer, a focused security analysis sub-agent. + +CONTEXT: +- Finding type: sast +- Analysis depth: quick +- Related files: [] +- Data flow context from tracer/hints: +Source: unknown +Sink: unknown +Sink reached: no +Trace steps: +- (no concrete trace steps) + +TASK: +1) Read code at src/hash.py around line 7 and inspect related files as needed. +2) Locate validation/sanitization/encoding controls on the source-to-sink path. +3) Assess whether controls are sufficient against realistic attacker input. +4) If bypassable, describe practical bypass method. + +SCOPE: +FINDING: +- Title: Weak hash +- Description: MD5 used for password hashing +- CWE: CWE-327 - Broken Crypto +- File: src/hash.py:7 +- Code snippet: +hashlib.md5(pw).hexdigest() + +OUTPUT: +- Return JSON that strictly matches SanitizationResult. +- Fill found, type, sufficient, and bypass_method. + +CONSTRAINTS: +- Take multiple turns for file inspection before final JSON. +- Be explicit about uncertainty using null values where needed. +- Do not return markdown, prose wrappers, or code fences. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Use the repository path above for file inspection during sanitization analysis. \ No newline at end of file diff --git a/go/internal/agents/prove/testdata/golden/tracer_prompt_A.txt b/go/internal/agents/prove/testdata/golden/tracer_prompt_A.txt new file mode 100644 index 0000000..34e15ee --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/tracer_prompt_A.txt @@ -0,0 +1,54 @@ +ROLE: +You are DataFlowTracer, a focused security analysis sub-agent. + +CONTEXT: +- Finding type: sast +- Analysis depth: thorough +- Related files: [ + "src/routes.py", + "src/caf\u00e9 &.py" +] +- Candidate flow hints from HUNT: [ + { + "file_path": "src/routes.py", + "line": 10, + "component": "handler", + "operation": "read request.args" + }, + { + "file_path": "src/users.py", + "line": 42, + "component": "db", + "operation": "execute" + } +] + +TASK: +1) Read code at src/users.py around line 42 and inspect related files as needed. +2) Identify the attacker-controlled source and security-sensitive sink. +3) Trace source-to-sink flow with ordered path evidence. +4) Decide whether tainted input actually reaches the sink. + +SCOPE: +FINDING: +- Title: Potential SQL injection in user lookup +- Description: Request parameter reaches a formatted SQL string. Marker: {{TITLE}} +- CWE: CWE-89 - Improper Neutralization/Escaping of Special Elements +- File: src/users.py:42 +- Code snippet: +cursor.execute("SELECT * FROM users WHERE id = " + user_id) # depth=thorough + +OUTPUT: +- Return JSON that strictly matches DataFlowTrace. +- Fill source, sink, steps, and sink_reached. +- Keep steps concise, ordered, and concrete (file:line path style). + +CONSTRAINTS: +- Take multiple turns for file inspection before final JSON. +- Be evidence-driven and avoid speculation. +- Do not return markdown, prose wrappers, or code fences. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Use the repository path above for file inspection during source-to-sink tracing. \ No newline at end of file diff --git a/go/internal/agents/prove/testdata/golden/tracer_prompt_B.txt b/go/internal/agents/prove/testdata/golden/tracer_prompt_B.txt new file mode 100644 index 0000000..a5689c7 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/tracer_prompt_B.txt @@ -0,0 +1,38 @@ +ROLE: +You are DataFlowTracer, a focused security analysis sub-agent. + +CONTEXT: +- Finding type: sast +- Analysis depth: quick +- Related files: [] +- Candidate flow hints from HUNT: [] + +TASK: +1) Read code at src/hash.py around line 7 and inspect related files as needed. +2) Identify the attacker-controlled source and security-sensitive sink. +3) Trace source-to-sink flow with ordered path evidence. +4) Decide whether tainted input actually reaches the sink. + +SCOPE: +FINDING: +- Title: Weak hash +- Description: MD5 used for password hashing +- CWE: CWE-327 - Broken Crypto +- File: src/hash.py:7 +- Code snippet: +hashlib.md5(pw).hexdigest() + +OUTPUT: +- Return JSON that strictly matches DataFlowTrace. +- Fill source, sink, steps, and sink_reached. +- Keep steps concise, ordered, and concrete (file:line path style). + +CONSTRAINTS: +- Take multiple turns for file inspection before final JSON. +- Be evidence-driven and avoid speculation. +- Do not return markdown, prose wrappers, or code fences. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Use the repository path above for file inspection during source-to-sink tracing. \ No newline at end of file diff --git a/go/internal/agents/prove/testdata/golden/verdict_prompt_A.txt b/go/internal/agents/prove/testdata/golden/verdict_prompt_A.txt new file mode 100644 index 0000000..2091877 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/verdict_prompt_A.txt @@ -0,0 +1,54 @@ +ROLE: +You are VerdictAgent, a focused judgment sub-agent. + +CONTEXT: +- Finding type: sast +- Related files: [ + "src/routes.py", + "src/caf\u00e9 &.py" +] +- Consolidated sub-agent outputs: +Tracer output: +- source: request.args['id'] +- sink: cursor.execute(query) +- sink_reached: True +- steps: +- src/routes.py:10 read request.args +- src/users.py:42 execute + +Sanitization output: +- found: True +- type: parameterized query +- sufficient: False +- bypass_method: second-order injection through the audit log + +Exploit output: +- hypothesis: Attacker supplies id=1 OR 1=1 to dump the table +- payload: 1 OR 1=1 +- expected_outcome: Full users table returned + +TASK: +1) Evaluate tracer, sanitization, and exploit outputs together. +2) Decide final exploitability verdict. +3) Assign evidence level on 1-6 scale. +4) Provide concise rationale and confidence level. + +SCOPE: +FINDING: +- Title: Potential SQL injection in user lookup +- Description: Request parameter reaches a formatted SQL string. Marker: {{TITLE}} +- CWE: CWE-89 - Improper Neutralization/Escaping of Special Elements +- File: src/users.py:42 +- Code snippet: +cursor.execute("SELECT * FROM users WHERE id = " + user_id) # depth={{DEPTH}} + +OUTPUT: +- Return JSON that strictly matches VerdictDecision. +- verdict MUST be one of: confirmed, likely, inconclusive, not_exploitable. +- evidence_level MUST be integer 1..6. +- confidence MUST be one of: high, medium, low. + +CONSTRAINTS: +- Prioritize consistency with provided sub-agent evidence. +- Keep rationale concise and evidence-grounded. +- Do not return markdown, prose wrappers, or code fences. diff --git a/go/internal/agents/prove/testdata/golden/verdict_prompt_B.txt b/go/internal/agents/prove/testdata/golden/verdict_prompt_B.txt new file mode 100644 index 0000000..bb7fd28 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/verdict_prompt_B.txt @@ -0,0 +1,50 @@ +ROLE: +You are VerdictAgent, a focused judgment sub-agent. + +CONTEXT: +- Finding type: sast +- Related files: [] +- Consolidated sub-agent outputs: +Tracer output: +- source: unknown +- sink: unknown +- sink_reached: False +- steps: +- (none) + +Sanitization output: +- found: False +- type: none +- sufficient: None +- bypass_method: none + +Exploit output: +- hypothesis: unknown +- payload: none +- expected_outcome: unknown + +TASK: +1) Evaluate tracer, sanitization, and exploit outputs together. +2) Decide final exploitability verdict. +3) Assign evidence level on 1-6 scale. +4) Provide concise rationale and confidence level. + +SCOPE: +FINDING: +- Title: Weak hash +- Description: MD5 used for password hashing +- CWE: CWE-327 - Broken Crypto +- File: src/hash.py:7 +- Code snippet: +hashlib.md5(pw).hexdigest() + +OUTPUT: +- Return JSON that strictly matches VerdictDecision. +- verdict MUST be one of: confirmed, likely, inconclusive, not_exploitable. +- evidence_level MUST be integer 1..6. +- confidence MUST be one of: high, medium, low. + +CONSTRAINTS: +- Prioritize consistency with provided sub-agent evidence. +- Keep rationale concise and evidence-grounded. +- Do not return markdown, prose wrappers, or code fences. diff --git a/go/internal/agents/prove/tracer.go b/go/internal/agents/prove/tracer.go new file mode 100644 index 0000000..e726df1 --- /dev/null +++ b/go/internal/agents/prove/tracer.go @@ -0,0 +1,119 @@ +package prove + +// Ports src/sec_af/agents/prove/tracer.py. + +import ( + "context" + "os" + "strconv" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/harnessx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// tracerPromptPath is Python's module-level +// `PROMPT_PATH = Path(__file__).resolve().parents[2] / "prompts" / "prove" / "tracer.txt"`, +// expressed as the embed-relative name internal/prompts uses. +const tracerPromptPath = "prove/tracer.txt" + +// Agent identity strings. Python spells the same sub-agent two ways and both +// are observable: agentName goes into the temp-dir prefix +// `secaf--`, extractName is what extract_harness_result prints and +// embeds in the error ("DataFlowTracer harness error: ..."). +const ( + tracerAgentName = "prove-tracer" + tracerExtractName = "DataFlowTracer" +) + +// findingDataFlow ports `_finding_data_flow`: +// +// if not finding.data_flow: +// return "[]" +// rows = [{"file_path": s.file_path, "line": s.line, +// "component": s.component, "operation": s.operation} for s in finding.data_flow] +// return json.dumps(rows, indent=2) +// +// Python parity: `if not finding.data_flow` is falsy for BOTH None and an empty +// list, which len(...)==0 covers for a Go slice. The row dicts are built with +// literal keys, so their json.dumps order is the literal order — reproduced +// with pyfmt.O (an insertion-ordered mapping); a Go map would sort them into +// component/file_path/line/operation. +func findingDataFlow(finding schemas.RawFinding) string { + if len(finding.DataFlow) == 0 { + return "[]" + } + rows := make([]pyfmt.Ordered, len(finding.DataFlow)) + for i, step := range finding.DataFlow { + rows[i] = pyfmt.O( + "file_path", step.FilePath, + "line", step.Line, + "component", step.Component, + "operation", step.Operation, + ) + } + return pyfmt.Dumps(rows, 2) +} + +// tracerBuildPrompt ports tracer.py `_build_prompt`. See replacement's doc for +// why the entries are an ORDERED slice. +func tracerBuildPrompt(template string, finding schemas.RawFinding, depth string) string { + return applyReplacements(template, []replacement{ + {"{{TITLE}}", finding.Title}, + {"{{DESCRIPTION}}", finding.Description}, + {"{{CWE_ID}}", finding.CweID}, + {"{{CWE_NAME}}", finding.CweName}, + {"{{FILE_PATH}}", finding.FilePath}, + {"{{START_LINE}}", strconv.Itoa(finding.StartLine)}, + {"{{CODE_SNIPPET}}", finding.CodeSnippet}, + {"{{FINDING_TYPE}}", string(finding.FindingType)}, + {"{{RELATED_FILES}}", relatedFilesJSON(finding.RelatedFiles)}, + {"{{DATA_FLOW_JSON}}", findingDataFlow(finding)}, + {"{{DEPTH}}", depth}, + }) +} + +// TracerPrompt builds the exact prompt RunTracer sends, including the CONTEXT +// block Python appends after substitution. Exported for the golden test. +func TracerPrompt(finding schemas.RawFinding, repoPath, depth string) string { + return tracerBuildPrompt(prompts.MustLoad(tracerPromptPath), finding, depth) + + "\n\nCONTEXT:\n" + + "- Repository path: " + repoPath + "\n" + + "- Use the repository path above for file inspection during source-to-sink tracing." +} + +// RunTracer ports tracer.py run_tracer. +// +// async def run_tracer(app, repo_path, finding, depth) -> DataFlowTrace: +// prompt = _build_prompt(...) + "\n\nCONTEXT:\n..." +// harness_cwd = tempfile.mkdtemp(prefix="secaf-prove-tracer-") +// try: +// result = await app.harness(prompt=prompt, schema=DataFlowTrace, +// cwd=harness_cwd, project_dir=repo_path) +// return extract_harness_result(result, DataFlowTrace, "DataFlowTracer") +// finally: +// shutil.rmtree(harness_cwd, ignore_errors=True) +// +// Python parity: the harness runs with Cwd set to a PRIVATE scratch directory +// and ProjectDir set to the repository, so the coding agent explores the repo +// but writes its JSON output outside it. `shutil.rmtree(..., ignore_errors=True)` +// maps to a deferred os.RemoveAll whose error is deliberately dropped. +func RunTracer(ctx context.Context, app appx.Harnesser, repoPath string, finding schemas.RawFinding, depth string) (schemas.DataFlowTrace, error) { + prompt := TracerPrompt(finding, repoPath, depth) + + harnessCwd, err := os.MkdirTemp("", "secaf-"+tracerAgentName+"-") + if err != nil { + return schemas.DataFlowTrace{}, err + } + defer os.RemoveAll(harnessCwd) + + return harnessx.RunExtract[schemas.DataFlowTrace]( + ctx, app, prompt, + harness.Options{Cwd: harnessCwd, ProjectDir: repoPath}, + tracerExtractName, + ) +} diff --git a/go/internal/agents/prove/verdict.go b/go/internal/agents/prove/verdict.go new file mode 100644 index 0000000..539c126 --- /dev/null +++ b/go/internal/agents/prove/verdict.go @@ -0,0 +1,151 @@ +package prove + +// Ports src/sec_af/agents/prove/verdict.py. + +import ( + "context" + "strconv" + "strings" + + "github.com/Agent-Field/sec-af/go/internal/aix" + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// verdictPromptPath mirrors verdict.py's module-level PROMPT_PATH. +const verdictPromptPath = "prove/verdict.txt" + +// verdictExtractName is the name Python's `_extract_ai_result` puts in its +// TypeError ("VerdictAgent .ai() did not return a valid VerdictDecision: ..."). +// There is no temp dir here: VerdictAgent is a pure judgment task and uses +// `.ai()` (one structured LLM request) rather than `.harness()` (a multi-turn +// coding-agent session), so it never touches the filesystem. +const verdictExtractName = "VerdictAgent" + +// verdictBuildContext ports verdict.py `_build_context` — the +// {{SUBAGENT_CONTEXT}} block. +// +// trace_steps = "\n".join(f"- {step}" for step in data_flow.steps) if data_flow.steps else "- (none)" +// return ("Tracer output:\n" +// f"- source: {data_flow.source}\n" +// f"- sink: {data_flow.sink}\n" +// f"- sink_reached: {data_flow.sink_reached}\n" +// f"- steps:\n{trace_steps}\n\n" +// "Sanitization output:\n" +// f"- found: {sanitization.found}\n" +// f"- type: {sanitization.type or 'none'}\n" +// f"- sufficient: {sanitization.sufficient}\n" +// f"- bypass_method: {sanitization.bypass_method or 'none'}\n\n" +// "Exploit output:\n" +// f"- hypothesis: {exploit.hypothesis}\n" +// f"- payload: {exploit.payload or 'none'}\n" +// f"- expected_outcome: {exploit.expected_outcome}") +// +// This is deliberately NOT shared with `_trace_context` (shared.go): the empty +// placeholder is "- (none)" rather than "- (no concrete trace steps)", and the +// booleans are interpolated as Python bools ("True"/"False") rather than +// yes/no. `sufficient` is interpolated RAW, so None prints as "None" — the one +// place a tri-state reaches the model unmapped. +func verdictBuildContext( + dataFlow schemas.DataFlowTrace, + sanitization schemas.SanitizationResult, + exploit schemas.ExploitHypothesis, +) string { + traceSteps := "- (none)" + if len(dataFlow.Steps) > 0 { + parts := make([]string, len(dataFlow.Steps)) + for i, step := range dataFlow.Steps { + parts[i] = "- " + step + } + traceSteps = strings.Join(parts, "\n") + } + var b strings.Builder + b.WriteString("Tracer output:\n") + b.WriteString("- source: " + dataFlow.Source + "\n") + b.WriteString("- sink: " + dataFlow.Sink + "\n") + b.WriteString("- sink_reached: " + pyfmt.Str(dataFlow.SinkReached) + "\n") + b.WriteString("- steps:\n" + traceSteps + "\n\n") + b.WriteString("Sanitization output:\n") + b.WriteString("- found: " + pyfmt.Str(sanitization.Found) + "\n") + b.WriteString("- type: " + pyOr(sanitization.Type, "none") + "\n") + b.WriteString("- sufficient: " + pyStrOptBool(sanitization.Sufficient) + "\n") + b.WriteString("- bypass_method: " + pyOr(sanitization.BypassMethod, "none") + "\n\n") + b.WriteString("Exploit output:\n") + b.WriteString("- hypothesis: " + exploit.Hypothesis + "\n") + b.WriteString("- payload: " + pyOr(exploit.Payload, "none") + "\n") + b.WriteString("- expected_outcome: " + exploit.ExpectedOutcome) + return b.String() +} + +// verdictBuildPrompt ports verdict.py `_build_prompt`. +// +// Python parity: this builder has NO {{DEPTH}} entry — verdict.txt does not +// name one and run_verdict_agent takes no depth argument — so a literal +// "{{DEPTH}}" anywhere in the finding survives into the prompt. +func verdictBuildPrompt( + template string, + finding schemas.RawFinding, + dataFlow schemas.DataFlowTrace, + sanitization schemas.SanitizationResult, + exploit schemas.ExploitHypothesis, +) string { + return applyReplacements(template, []replacement{ + {"{{TITLE}}", finding.Title}, + {"{{DESCRIPTION}}", finding.Description}, + {"{{CWE_ID}}", finding.CweID}, + {"{{CWE_NAME}}", finding.CweName}, + {"{{FILE_PATH}}", finding.FilePath}, + {"{{START_LINE}}", strconv.Itoa(finding.StartLine)}, + {"{{CODE_SNIPPET}}", finding.CodeSnippet}, + {"{{FINDING_TYPE}}", string(finding.FindingType)}, + {"{{RELATED_FILES}}", relatedFilesJSON(finding.RelatedFiles)}, + {"{{SUBAGENT_CONTEXT}}", verdictBuildContext(dataFlow, sanitization, exploit)}, + }) +} + +// VerdictPrompt builds the exact prompt RunVerdictAgent sends. Unlike every +// other prove agent there is NO trailing CONTEXT block — verdict.py sends the +// substituted template alone. Exported for the golden test. +func VerdictPrompt( + finding schemas.RawFinding, + dataFlow schemas.DataFlowTrace, + sanitization schemas.SanitizationResult, + exploit schemas.ExploitHypothesis, +) string { + return verdictBuildPrompt(prompts.MustLoad(verdictPromptPath), finding, dataFlow, sanitization, exploit) +} + +// RunVerdictAgent ports verdict.py run_verdict_agent. +// +// result = await app.ai(user=prompt, schema=VerdictDecision) +// return _extract_ai_result(result, "VerdictAgent") +// +// Python parity notes: +// +// - `repo_path` is accepted and IGNORED (the comment in verdict.py explains +// why: a pure judgment task needs no file access). The parameter is kept so +// the Go call sites read like the Python ones and so reasoners/phases.py's +// `repo_path="."` has somewhere to go. +// - `system=` is not passed, so aix.Structured gets the empty system prompt +// and adds no system message — Python's `system=None`. +// - `_extract_ai_result`'s three duck-typed branches (already a +// VerdictDecision / a dict / `.parsed`) all collapse into aix.Structured's +// single "unmarshal the response text into T", because the Go SDK's +// ai.Response only ever carries text. A response that will not parse yields +// an error whose message names VerdictDecision, which is what the Python +// TypeError does. +func RunVerdictAgent( + ctx context.Context, + app appx.AIer, + repoPath string, + finding schemas.RawFinding, + dataFlow schemas.DataFlowTrace, + sanitization schemas.SanitizationResult, + exploit schemas.ExploitHypothesis, +) (schemas.VerdictDecision, error) { + _ = repoPath // Python parity: accepted, never used. + prompt := VerdictPrompt(finding, dataFlow, sanitization, exploit) + return aix.Structured[schemas.VerdictDecision](ctx, app, "", prompt) +} diff --git a/go/internal/agents/prove/verifier.go b/go/internal/agents/prove/verifier.go new file mode 100644 index 0000000..071e72b --- /dev/null +++ b/go/internal/agents/prove/verifier.go @@ -0,0 +1,187 @@ +package prove + +// Ports src/sec_af/agents/prove/verifier.py — the per-finding verification +// chain and the demotion fallback the PROVE phase reaches for when it fails. + +import ( + "context" + "strconv" + "sync" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// Fallback ports verifier.py `fallback`: +// +// def fallback(finding, reason, *, drop_reason=None, original_verdict=None) -> VerifiedFinding: +// rationale = f"Verification incomplete: {reason}" +// if original_verdict: +// rationale = f"{rationale} (original verdict: {original_verdict})" +// tags = ["low_confidence"] if drop_reason else [] +// return VerifiedFinding(... verdict=INCONCLUSIVE, evidence_level=STATIC_MATCH ...) +// +// This is the demotion path: a finding whose verification could not complete +// stays in the report as INCONCLUSIVE / STATIC_MATCH with a zero score, rather +// than being dropped silently. reasoners/phases.py and orchestrator.py both +// call it, and tests/test_prove_phase_demotion.py asserts its observable shape +// (verdict "inconclusive", drop_reason preserved, "low_confidence" in tags). +// +// SIGNATURE PARITY. Python's two keyword-only arguments default to None, and +// both defaults are load-bearing: +// +// - `drop_reason` drives BOTH the drop_reason field and the "low_confidence" +// tag — passing None yields an EMPTY tag list, not a tag list containing "". +// - `original_verdict` is appended to the rationale only when TRUTHY, so an +// empty string behaves like None. +// +// Go spells "or None" as a nil *string; StrPtr (shared.go) is the call-site +// helper. Passing (nil, nil) is Python's `fallback(finding, reason)`. +func Fallback(finding schemas.RawFinding, reason string, dropReason, originalVerdict *string) schemas.VerifiedFinding { + rationale := "Verification incomplete: " + reason + if originalVerdict != nil && *originalVerdict != "" { + rationale = rationale + " (original verdict: " + *originalVerdict + ")" + } + tags := []string{} + if dropReason != nil && *dropReason != "" { + tags = []string{"low_confidence"} + } + return schemas.VerifiedFinding{ + ID: finding.ID, + Fingerprint: finding.Fingerprint, + Title: finding.Title, + Description: finding.Description, + FindingType: finding.FindingType, + CweID: finding.CweID, + CweName: finding.CweName, + OwaspCategory: finding.OwaspCategory, + Verdict: schemas.VerdictInconclusive, + EvidenceLevel: schemas.EvidenceLevelStaticMatch, + Rationale: rationale, + Severity: finding.EstimatedSeverity, + Tags: tags, + ExploitabilityScore: 0.0, + Location: locationOf(finding), + RelatedLocations: []schemas.Location{}, + Compliance: []schemas.ComplianceMapping{}, + ReproductionSteps: []schemas.ReproductionStep{}, + SarifRuleID: sarifRuleID(finding.FindingType, finding.CweName), + SarifSecuritySeverity: 0.0, + DropReason: dropReason, + } +} + +// RunVerifier ports verifier.py run_verifier — the four-stage in-process chain. +// +// seed_trace = DataFlowTrace(source=f"{file}:{line}", sink=function or file, +// steps=[...] if finding.data_flow else [], sink_reached=False) +// results = await asyncio.gather(run_tracer(...), run_sanitization_analyzer(...), +// return_exceptions=True) +// data_flow_trace = seed_trace if tracer failed else tracer_result +// sanitization = SanitizationResult(found=False, sufficient=None, bypass_method=None) +// if sanitization failed else sanitization_result +// exploit = await run_exploit_hypothesizer(...) # errors PROPAGATE +// verdict = await run_verdict_agent(...) # errors PROPAGATE +// verified = assemble_verified_finding(...) +// # backfill sarif_rule_id and reproduction_steps +// +// Concurrency parity: stages 1 and 2 run CONCURRENTLY with +// `return_exceptions=True`, so a failure in one does not cancel or fail the +// other — each has its own documented fallback value. Go uses a WaitGroup with +// two indexed result slots rather than errgroup precisely because no error must +// escape. Stages 3 and 4 are sequential awaits whose errors DO propagate; the +// caller (_run_parallel_verification) turns them into a demoted Fallback. +// +// Python parity on the sanitization seed: the note that the seed trace's +// `sink_reached` is False even when the hunter reported a flow is deliberate — +// only the tracer may claim the sink is reached. +func RunVerifier(ctx context.Context, app HarnessAIer, repoPath string, finding schemas.RawFinding, depth string) (schemas.VerifiedFinding, error) { + seedTrace := seedTraceFor(finding) + + var ( + wg sync.WaitGroup + tracerRes schemas.DataFlowTrace + tracerErr error + sanitizeRes schemas.SanitizationResult + sanitizeErr error + ) + wg.Add(2) + go func() { + defer wg.Done() + tracerRes, tracerErr = RunTracer(ctx, app, repoPath, finding, depth) + }() + go func() { + defer wg.Done() + sanitizeRes, sanitizeErr = RunSanitizationAnalyzer(ctx, app, repoPath, finding, seedTrace, depth) + }() + wg.Wait() + + dataFlowTrace := tracerRes + if tracerErr != nil { + dataFlowTrace = seedTrace + } + + sanitization := sanitizeRes + if sanitizeErr != nil { + // Python parity: the failure fallback is constructed explicitly with + // found=False and BOTH optionals None — not SanitizationResult()'s + // defaults, though they happen to be identical. + sanitization = schemas.SanitizationResult{Found: false, Sufficient: nil, BypassMethod: nil} + } + + exploit, err := RunExploitHypothesizer(ctx, app, repoPath, finding, dataFlowTrace, sanitization, depth) + if err != nil { + return schemas.VerifiedFinding{}, err + } + verdict, err := RunVerdictAgent(ctx, app, repoPath, finding, dataFlowTrace, sanitization, exploit) + if err != nil { + return schemas.VerifiedFinding{}, err + } + + verified := AssembleVerifiedFinding(finding, dataFlowTrace, sanitization, exploit, verdict) + + // Backfills. AssembleVerifiedFinding always sets a rule id, so the first + // branch is unreachable through this path; it is ported anyway because + // Python guards it and a future assembler change would rely on it. + if verified.SarifRuleID == "" { + verified.SarifRuleID = sarifRuleID(finding.FindingType, finding.CweName) + } + if len(verified.ReproductionSteps) == 0 && verified.Verdict != schemas.VerdictNotExploitable { + desc1 := "Review vulnerable code location and trace data flow to sink." + out1 := "Flow reaches sensitive sink without sufficient mitigation." + desc2 := "Craft payload from exploit_hypothesis and execute against target path." + out2 := "Observed effect aligns with expected exploit outcome." + verified.ReproductionSteps = []schemas.ReproductionStep{ + {Step: 1, Description: desc1, Command: nil, ExpectedOutput: &out1}, + {Step: 2, Description: desc2, Command: nil, ExpectedOutput: &out2}, + } + } + return verified, nil +} + +// seedTraceFor builds run_verifier's `seed_trace`: +// +// DataFlowTrace( +// source=f"{finding.file_path}:{finding.start_line}", +// sink=finding.function_name or finding.file_path, +// steps=[f"{s.file_path}:{s.line} {s.operation}" for s in finding.data_flow] if finding.data_flow else [], +// sink_reached=False, +// ) +// +// Python parity: `finding.function_name or finding.file_path` is truthiness, so +// an EMPTY function name also falls back to the file path. +func seedTraceFor(finding schemas.RawFinding) schemas.DataFlowTrace { + sink := finding.FilePath + if finding.FunctionName != nil && *finding.FunctionName != "" { + sink = *finding.FunctionName + } + steps := []string{} + for _, step := range finding.DataFlow { + steps = append(steps, step.FilePath+":"+strconv.Itoa(step.Line)+" "+step.Operation) + } + return schemas.DataFlowTrace{ + Source: finding.FilePath + ":" + strconv.Itoa(finding.StartLine), + Sink: sink, + Steps: steps, + SinkReached: false, + } +} diff --git a/go/internal/agents/recon/architecture.go b/go/internal/agents/recon/architecture.go new file mode 100644 index 0000000..040c0fe --- /dev/null +++ b/go/internal/agents/recon/architecture.go @@ -0,0 +1,119 @@ +package recon + +// Ports src/sec_af/agents/recon/architecture.py. + +import ( + "context" + "os" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/harnessx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// architecturePromptPath is Python's module-level +// `PROMPT_PATH = Path(__file__).resolve().parents[2] / "prompts" / "recon" / "architecture.txt"`, +// expressed as the embed-relative name internal/prompts uses. +const architecturePromptPath = "recon/architecture.txt" + +// Agent identity strings. Python spells the SAME agent two different ways and +// both are observable, so both are pinned here: +// +// - agentName goes into the temp-dir prefix `secaf--`; +// - extractName is what extract_harness_result prints and puts in the error +// ("Architecture mapper harness error: ..."). +const ( + architectureAgentName = "recon-architecture" + architectureExtractName = "Architecture mapper" +) + +// fileListingContextSuffix is the CONTEXT block the three repo-only mappers +// (architecture, dependencies, config scanner) append to their template +// verbatim. Python builds it with an f-string per module; the three copies are +// byte-identical apart from the interpolated path, so the port shares one +// builder. Any change here changes what reaches the LLM — it is golden-tested +// against the real Python builders (testdata/golden/*_prompt.txt). +func fileListingContextSuffix(repoPath string) string { + return "\n\nCONTEXT:\n" + + "- Repository path: " + repoPath + "\n" + + "- Start by listing files in the repository path above.\n" + + "- After gathering evidence, write the JSON output file using your Write tool." +} + +// architecturePrompt builds the exact prompt run_architecture_mapper sends. +func architecturePrompt(repoPath string) string { + return prompts.MustLoad(architecturePromptPath) + fileListingContextSuffix(repoPath) +} + +// RunArchitectureMapper ports architecture.py run_architecture_mapper. +// +// async def run_architecture_mapper(app, repo_path) -> ArchitectureMap: +// prompt = PROMPT_PATH.read_text(...) + "\n\nCONTEXT:\n" + ... +// harness_cwd = tempfile.mkdtemp(prefix="secaf-recon-architecture-") +// try: +// result = await app.harness(prompt=prompt, schema=ArchitectureMapRaw, +// cwd=harness_cwd, project_dir=repo_path) +// raw = extract_harness_result(result, ArchitectureMapRaw, "Architecture mapper") +// return parse_architecture_raw(raw) +// finally: +// shutil.rmtree(harness_cwd, ignore_errors=True) +// +// Python parity: the harness runs with Cwd set to a PRIVATE scratch directory +// and ProjectDir set to the repository, so the coding agent explores the repo +// but writes its JSON output outside it. `shutil.rmtree(..., ignore_errors=True)` +// maps to a deferred os.RemoveAll whose error is deliberately dropped. +// +// On any failure the zero ArchitectureMap is returned alongside the error; +// Python raises, so no caller reads the value in that case. +func RunArchitectureMapper(ctx context.Context, app appx.Harnesser, repoPath string) (schemas.ArchitectureMap, error) { + prompt := architecturePrompt(repoPath) + + harnessCwd, err := os.MkdirTemp("", "secaf-"+architectureAgentName+"-") + if err != nil { + return schemas.ArchitectureMap{}, err + } + defer os.RemoveAll(harnessCwd) + + raw, err := harnessx.RunExtract[schemas.ArchitectureMapRaw]( + ctx, app, prompt, + harness.Options{Cwd: harnessCwd, ProjectDir: repoPath}, + architectureExtractName, + ) + if err != nil { + return schemas.ArchitectureMap{}, err + } + return ParseArchitectureRaw(raw), nil +} + +// ArchitectureContextBlock ports architecture.py architecture_context_block: +// +// def architecture_context_block(architecture: ArchitectureMap) -> str: +// return json.dumps(architecture.model_dump(), indent=2) +// +// The result is substituted for `{{ARCHITECTURE_MAP_JSON}}` in the data-flow +// and security-context prompt templates, so it reaches the LLM verbatim and +// must be byte-identical to CPython's json.dumps — which Go's encoding/json is +// NOT (it escapes `<`, `>` and `&`, leaves non-ASCII unescaped where CPython's +// ensure_ascii=True writes \uXXXX, and renders floats with Go's rules rather +// than repr()). pyfmt.Dumps is the shared encoder that closes all of those; see +// DESIGN.md §2b. +// +// Struct fields are emitted in DECLARATION order, which is the order the +// pydantic class declares them and therefore the order model_dump() inserts +// them, so no key-order fixup is needed. +// +// Python parity caveat inherited from pyfmt.Dumps: a NIL Go slice renders as +// `null`, while a pydantic `Field(default_factory=list)` always dumps as `[]`. +// Every ArchitectureMap that reaches this function came from +// ParseArchitectureRaw (which builds non-nil slices) or from JSON via +// ArchitectureMap.UnmarshalJSON (which seeds the `[]` defaults), so the nil +// case is not reachable through the pipeline — construct with +// schemas.NewArchitectureMap() rather than a bare literal if you ever call this +// by hand. +func ArchitectureContextBlock(architecture schemas.ArchitectureMap) string { + return pyfmt.Dumps(architecture, 2) +} diff --git a/go/internal/agents/recon/config_scanner.go b/go/internal/agents/recon/config_scanner.go new file mode 100644 index 0000000..d7dc8e8 --- /dev/null +++ b/go/internal/agents/recon/config_scanner.go @@ -0,0 +1,53 @@ +package recon + +// Ports src/sec_af/agents/recon/config_scanner.py. + +import ( + "context" + "os" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/harnessx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +const ( + configScannerPromptPath = "recon/config_scanner.txt" + configScannerAgentName = "recon-config-scanner" + configScannerExtractName = "Config scanner" +) + +// configScannerPrompt builds the exact prompt run_config_scanner sends. It +// shares the CONTEXT suffix with the architecture and dependency mappers (see +// fileListingContextSuffix). +func configScannerPrompt(repoPath string) string { + return prompts.MustLoad(configScannerPromptPath) + fileListingContextSuffix(repoPath) +} + +// RunConfigScanner ports config_scanner.py run_config_scanner: harness for a +// ConfigReportRaw, then parse_config_report_raw. +// +// Structurally identical to RunArchitectureMapper — see its doc comment for the +// temp-dir / Cwd / ProjectDir contract, which is the same here. +func RunConfigScanner(ctx context.Context, app appx.Harnesser, repoPath string) (schemas.ConfigReport, error) { + prompt := configScannerPrompt(repoPath) + + harnessCwd, err := os.MkdirTemp("", "secaf-"+configScannerAgentName+"-") + if err != nil { + return schemas.ConfigReport{}, err + } + defer os.RemoveAll(harnessCwd) + + raw, err := harnessx.RunExtract[schemas.ConfigReportRaw]( + ctx, app, prompt, + harness.Options{Cwd: harnessCwd, ProjectDir: repoPath}, + configScannerExtractName, + ) + if err != nil { + return schemas.ConfigReport{}, err + } + return ParseConfigReportRaw(raw), nil +} diff --git a/go/internal/agents/recon/data_flow.go b/go/internal/agents/recon/data_flow.go new file mode 100644 index 0000000..eb0abbf --- /dev/null +++ b/go/internal/agents/recon/data_flow.go @@ -0,0 +1,78 @@ +package recon + +// Ports src/sec_af/agents/recon/data_flow.py. + +import ( + "context" + "os" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/harnessx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +const ( + dataFlowPromptPath = "recon/data_flow.txt" + dataFlowAgentName = "recon-data-flow" + dataFlowExtractName = "Data flow mapper" + + // architectureMapPlaceholder is the token the two architecture-aware + // templates carry; Python replaces it with architecture_context_block(...) + // before appending the CONTEXT block. + architectureMapPlaceholder = "{{ARCHITECTURE_MAP_JSON}}" +) + +// explorationContextSuffix is the CONTEXT block the two architecture-aware +// mappers (data flow, security context) append. It differs from the +// file-listing suffix the other three use: those tell the agent to start by +// listing files, these tell it to explore across multiple turns first. +func explorationContextSuffix(repoPath string) string { + return "\n\nCONTEXT:\n" + + "- Repository path: " + repoPath + "\n" + + "- Take multiple turns to explore the codebase first, then build your analysis.\n" + + "- Write final JSON only when analysis is complete." +} + +// dataFlowPrompt builds the exact prompt run_data_flow_mapper sends. +// +// Python parity: `str.replace(old, new)` with no count replaces EVERY +// occurrence of the placeholder, and the substitution happens BEFORE the +// CONTEXT block is appended — so an architecture map that itself contained the +// placeholder text would not be re-substituted. strings.ReplaceAll matches on +// both counts. +func dataFlowPrompt(repoPath string, architecture schemas.ArchitectureMap) string { + template := prompts.MustLoad(dataFlowPromptPath) + return strings.ReplaceAll(template, architectureMapPlaceholder, ArchitectureContextBlock(architecture)) + + explorationContextSuffix(repoPath) +} + +// RunDataFlowMapper ports data_flow.py run_data_flow_mapper: substitute the +// architecture map into the template, harness for a DataFlowMapRaw, then +// parse_data_flow_raw. +// +// The architecture argument is the ArchitectureMap the architecture mapper +// produced earlier in the same RECON phase; run_recon only reaches this mapper +// after that gather has completed. +func RunDataFlowMapper(ctx context.Context, app appx.Harnesser, repoPath string, architecture schemas.ArchitectureMap) (schemas.DataFlowMap, error) { + prompt := dataFlowPrompt(repoPath, architecture) + + harnessCwd, err := os.MkdirTemp("", "secaf-"+dataFlowAgentName+"-") + if err != nil { + return schemas.DataFlowMap{}, err + } + defer os.RemoveAll(harnessCwd) + + raw, err := harnessx.RunExtract[schemas.DataFlowMapRaw]( + ctx, app, prompt, + harness.Options{Cwd: harnessCwd, ProjectDir: repoPath}, + dataFlowExtractName, + ) + if err != nil { + return schemas.DataFlowMap{}, err + } + return ParseDataFlowRaw(raw), nil +} diff --git a/go/internal/agents/recon/dependencies.go b/go/internal/agents/recon/dependencies.go new file mode 100644 index 0000000..607a07d --- /dev/null +++ b/go/internal/agents/recon/dependencies.go @@ -0,0 +1,53 @@ +package recon + +// Ports src/sec_af/agents/recon/dependencies.py. + +import ( + "context" + "os" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/harnessx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +const ( + dependenciesPromptPath = "recon/dependencies.txt" + dependenciesAgentName = "recon-dependencies" + dependenciesExtractName = "Dependency auditor" +) + +// dependenciesPrompt builds the exact prompt run_dependency_auditor sends. +// It shares the CONTEXT suffix with the architecture and config-scanner +// mappers (see fileListingContextSuffix). +func dependenciesPrompt(repoPath string) string { + return prompts.MustLoad(dependenciesPromptPath) + fileListingContextSuffix(repoPath) +} + +// RunDependencyAuditor ports dependencies.py run_dependency_auditor: harness +// for a DependencyReportRaw, then parse_dependency_report_raw. +// +// Structurally identical to RunArchitectureMapper — see its doc comment for the +// temp-dir / Cwd / ProjectDir contract, which is the same here. +func RunDependencyAuditor(ctx context.Context, app appx.Harnesser, repoPath string) (schemas.DependencyReport, error) { + prompt := dependenciesPrompt(repoPath) + + harnessCwd, err := os.MkdirTemp("", "secaf-"+dependenciesAgentName+"-") + if err != nil { + return schemas.DependencyReport{}, err + } + defer os.RemoveAll(harnessCwd) + + raw, err := harnessx.RunExtract[schemas.DependencyReportRaw]( + ctx, app, prompt, + harness.Options{Cwd: harnessCwd, ProjectDir: repoPath}, + dependenciesExtractName, + ) + if err != nil { + return schemas.DependencyReport{}, err + } + return ParseDependencyReportRaw(raw), nil +} diff --git a/go/internal/agents/recon/doc.go b/go/internal/agents/recon/doc.go new file mode 100644 index 0000000..c26f0d6 --- /dev/null +++ b/go/internal/agents/recon/doc.go @@ -0,0 +1,33 @@ +// Package recon ports src/sec_af/agents/recon — the RECON phase of the SEC-AF +// pipeline. +// +// RECON runs five independent harness "mappers" over a repository and folds +// their flat, pipe-delimited output into the structured schemas the HUNT and +// PROVE phases consume: +// +// run_architecture_mapper -> schemas.ArchitectureMap +// run_dependency_auditor -> schemas.DependencyReport +// run_config_scanner -> schemas.ConfigReport +// run_data_flow_mapper -> schemas.DataFlowMap (needs the architecture) +// run_security_context_profiler -> schemas.SecurityContext (needs the architecture) +// +// Python module -> Go file: +// +// agents/recon/architecture.py -> architecture.go +// agents/recon/dependencies.py -> dependencies.go +// agents/recon/config_scanner.py -> config_scanner.go +// agents/recon/data_flow.py -> data_flow.go +// agents/recon/security_context.py -> security_context.go +// agents/recon/_parsers.py -> parsers.go +// agents/recon/__init__.py -> recon.go (run_recon / run_fast_recon / +// run_deep_recon), metrics.go (_repo_metrics), +// findings.go (extract_recon_findings) +// +// Every mapper follows one shape, so the port does too: build the prompt +// (template + a literal CONTEXT suffix), make a private temp dir named +// `secaf--*`, run the harness with `Cwd` = that temp dir and +// `ProjectDir` = the repository, extract the `*Raw` model, parse it, and remove +// the temp dir. The prompt strings are byte-verbatim; the golden fixtures under +// testdata/golden are produced from the REAL Python builders by +// go/scripts/gen_golden.py. +package recon diff --git a/go/internal/agents/recon/findings.go b/go/internal/agents/recon/findings.go new file mode 100644 index 0000000..c4a07c7 --- /dev/null +++ b/go/internal/agents/recon/findings.go @@ -0,0 +1,230 @@ +package recon + +// Ports the RawFinding-extraction half of src/sec_af/agents/recon/__init__.py: +// _safe_line, _to_recon_finding, _extract_from_config, _extract_weak_tls, +// _extract_structured_security_items and extract_recon_findings. +// +// RECON is not a hunter, but two of its mappers already produce concrete +// vulnerabilities (hardcoded secrets, insecure config, weak transport crypto). +// These helpers lift those into the same RawFinding currency the HUNT phase +// speaks so the orchestrator can merge them into the hunt result. + +import ( + "strconv" + "strings" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// safeLine ports `_safe_line(value, default=1)` — "use the reported line only +// when it is a positive int, otherwise 1". The two call sites differ in the +// static type of `value` (SecretFinding.line is `int`, MisconfigFinding.line is +// `int | None`), so the port has one helper per shape. +func safeLine(value int) int { + if value > 0 { + return value + } + return 1 +} + +// safeLinePtr is safeLine for an `int | None` field: nil is not an int, so +// Python's `isinstance(value, int)` guard fails and the default wins. +func safeLinePtr(value *int) int { + if value != nil && *value > 0 { + return *value + } + return 1 +} + +// toReconFinding ports _to_recon_finding — the single RawFinding factory every +// RECON extraction goes through. +// +// Python parity: end_line is always start_line (RECON reports points, not +// ranges), confidence is always HIGH, hunter_strategy is always the literal +// "recon", and id/fingerprint get fresh uuid4s from schemas.NewRawFinding — so +// two extractions of the same input are never deeply equal. +func toReconFinding( + title, description string, + findingType schemas.FindingType, + cweID, cweName, owaspCategory string, + severity schemas.Severity, + filePath string, + startLine int, + codeSnippet string, +) schemas.RawFinding { + f := schemas.NewRawFinding() + f.HunterStrategy = "recon" + f.Title = title + f.Description = description + f.FindingType = findingType + f.CweID = cweID + f.CweName = cweName + owasp := owaspCategory + f.OwaspCategory = &owasp + f.FilePath = filePath + f.StartLine = startLine + f.EndLine = startLine + f.CodeSnippet = codeSnippet + f.EstimatedSeverity = severity + f.Confidence = schemas.ConfidenceHigh + return f +} + +// extractFromConfig ports _extract_from_config: every secret becomes a +// CWE-798 HIGH finding and every misconfiguration a CWE-16 MEDIUM one. +// +// Python parity: the misconfig snippet is `", ".join(item for item in [key, +// value] if item)` — a TRUTHINESS filter, so an empty-string key or value is +// dropped just like None — and falls back to the risk text when both are +// absent. +func extractFromConfig(config schemas.ConfigReport) []schemas.RawFinding { + findings := []schemas.RawFinding{} + + for _, secret := range config.Secrets { + line := safeLine(secret.Line) + location := secret.FilePath + ":" + strconv.Itoa(line) + findings = append(findings, toReconFinding( + "Hardcoded secret in "+secret.FilePath, + "Detected "+secret.SecretType+" secret at "+location+". "+ + "Data flow summary: hardcoded credential from source file can be reused by an attacker.", + schemas.FindingTypeSecrets, + "CWE-798", + "Use of Hard-coded Credentials", + "A07:2021", + schemas.SeverityHigh, + secret.FilePath, + line, + secret.Match, + )) + } + + for _, misconfig := range config.Misconfigs { + line := safeLinePtr(misconfig.Line) + var details []string + if misconfig.Key != nil && *misconfig.Key != "" { + details = append(details, *misconfig.Key) + } + if misconfig.Value != nil && *misconfig.Value != "" { + details = append(details, *misconfig.Value) + } + snippet := strings.Join(details, ", ") + if snippet == "" { + snippet = misconfig.Risk + } + findings = append(findings, toReconFinding( + "Insecure configuration in "+misconfig.FilePath, + "Detected "+misconfig.Category+" with risk: "+misconfig.Risk+". "+ + "Data flow summary: insecure runtime configuration weakens application security controls.", + schemas.FindingTypeConfig, + "CWE-16", + "Configuration", + "A05:2021", + schemas.SeverityMedium, + misconfig.FilePath, + line, + snippet, + )) + } + + return findings +} + +// extractWeakTLS ports _extract_weak_tls: a CWE-327 MEDIUM finding per crypto +// usage that is BOTH flagged weak and transport-related. +// +// Python parity, all three of which are load-bearing: +// +// - `if usage.is_weak is not True: continue` is an identity test, so only an +// explicit true qualifies — `None` (unknown) does not. +// - `algorithm.strip() if usage.algorithm else "unknown"` substitutes the +// literal "unknown" for an EMPTY algorithm, and that substitute is then +// what the "tls"/"ssl" substring test runs against. +// - the transport test is an OR across three lowercased haystacks: "tls" or +// "ssl" in the algorithm, or "tls" in the usage context. "ssl" in the usage +// context alone does NOT qualify. +// +// file_path is the literal "security_context" and start_line is 1, because a +// SecurityContext entry has no source location. +func extractWeakTLS(context schemas.SecurityContext) []schemas.RawFinding { + findings := []schemas.RawFinding{} + + for _, usage := range context.CryptoUsage { + if usage.IsWeak == nil || !*usage.IsWeak { + continue + } + algorithm := "unknown" + if usage.Algorithm != "" { + algorithm = pyStrip(usage.Algorithm) + } + usageContext := "security context" + if usage.UsageContext != nil && *usage.UsageContext != "" { + usageContext = *usage.UsageContext + } + lowerAlgorithm := strings.ToLower(algorithm) + if !strings.Contains(lowerAlgorithm, "tls") && + !strings.Contains(lowerAlgorithm, "ssl") && + !strings.Contains(strings.ToLower(usageContext), "tls") { + continue + } + findings = append(findings, toReconFinding( + "Weak TLS configuration: "+algorithm, + "Detected weak transport crypto usage in "+usageContext+". "+ + "Data flow summary: clients may negotiate weak encryption for in-transit data.", + schemas.FindingTypeConfig, + "CWE-327", + "Use of a Broken or Risky Cryptographic Algorithm", + "A02:2021", + schemas.SeverityMedium, + "security_context", + 1, + "algorithm="+algorithm+"; context="+usageContext, + )) + } + + return findings +} + +// extractStructuredSecurityItems ports _extract_structured_security_items, +// which in Python iterates four categories — hardcoded_secrets, +// dangerous_configs, weak_tls, exposed_endpoints — pulled off the +// SecurityContext with `getattr(context, category, None)`. +// +// Python parity: it always returns []. schemas/recon.py's SecurityContext +// declares NONE of those four attributes and sets no model_config, so pydantic +// v2's default `extra="ignore"` drops them at validation time and +// `getattr(context, category, None)` returns None for every category. The very +// next line is `if not isinstance(items_obj, list): continue`, so every +// iteration short-circuits. This was verified against the pinned interpreter: +// validating a SecurityContext with all four keys present still yields None for +// each getattr, and tests/test_recon_findings.py's expected count of 3 +// (1 secret + 1 misconfig + 1 weak TLS) confirms this contributes nothing. +// +// Go's SecurityContext is a static struct with the same six fields, so the +// dead branch is not merely unused — it is unrepresentable. The function is +// kept so the call order in ExtractReconFindings still reads 1:1 against the +// Python, and so this analysis has somewhere to live. `_safe_path`, which only +// that dead branch calls, is deliberately not ported. +// +// The one way Python could reach the live branch is an UNVALIDATED assignment +// (`recon.security_context = `); pydantic v2 +// leaves attribute assignment unchecked by default. No SEC-AF code does that. +func extractStructuredSecurityItems(_ schemas.SecurityContext) []schemas.RawFinding { + return []schemas.RawFinding{} +} + +// ExtractReconFindings ports extract_recon_findings: +// +// findings.extend(_extract_from_config(recon.config)) +// findings.extend(_extract_structured_security_items(recon.security_context)) +// findings.extend(_extract_weak_tls(recon.security_context)) +// +// Order matters — the orchestrator prepends this list to the hunt findings, so +// config secrets lead, then misconfigurations, then weak TLS. Always non-nil so +// it serializes as `[]`. +func ExtractReconFindings(recon schemas.ReconResult) []schemas.RawFinding { + findings := []schemas.RawFinding{} + findings = append(findings, extractFromConfig(recon.Config)...) + findings = append(findings, extractStructuredSecurityItems(recon.SecurityContext)...) + findings = append(findings, extractWeakTLS(recon.SecurityContext)...) + return findings +} diff --git a/go/internal/agents/recon/findings_test.go b/go/internal/agents/recon/findings_test.go new file mode 100644 index 0000000..a518b29 --- /dev/null +++ b/go/internal/agents/recon/findings_test.go @@ -0,0 +1,393 @@ +package recon + +// Ports tests/test_recon_findings.py::test_extract_recon_findings_builds_raw_findings_from_recon_detections +// plus the behaviors that test exercises only implicitly. +// +// The file's OTHER test — test_merge_recon_findings_prepends_and_updates_counts +// — exercises sec_af.orchestrator.merge_recon_findings_into_hunt, which lives +// in internal/orch and is ported there, not here. +// +// Validation contract for extract_recon_findings: +// +// - Every ConfigReport secret becomes one CWE-798 / A07:2021 HIGH SECRETS +// finding whose snippet is the matched text. +// - Every ConfigReport misconfiguration becomes one CWE-16 / A05:2021 MEDIUM +// CONFIG finding whose snippet is "key, value" (empty parts dropped), +// falling back to the risk text. +// - Every crypto usage flagged weak AND transport-related becomes one +// CWE-327 / A02:2021 MEDIUM CONFIG finding located at "security_context:1". +// - Every finding carries hunter_strategy "recon", confidence HIGH and +// end_line == start_line. +// - The four "structured security items" categories contribute nothing. +// - Order is: config secrets, config misconfigurations, weak TLS. + +import ( + "testing" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// minimalReconResult ports the test module's _minimal_recon_result(): a +// ReconResult validated from a sparse dict, so every absent nested model falls +// back to its pydantic defaults. +func minimalReconResult() schemas.ReconResult { + recon := schemas.NewReconResult() + recon.SecurityContext.AuthModel = "jwt" + recon.SecurityContext.AuthDetails = "Bearer token" + weak := true + usageContext := "legacy tls terminator" + recon.SecurityContext.CryptoUsage = []schemas.CryptoUsage{ + {Algorithm: "TLSv1.0", UsageContext: &usageContext, IsWeak: &weak}, + } + return recon +} + +// TestExtractReconFindingsBuildsRawFindingsFromReconDetections ports +// test_extract_recon_findings_builds_raw_findings_from_recon_detections +// assertion for assertion. +func TestExtractReconFindingsBuildsRawFindingsFromReconDetections(t *testing.T) { + str := func(s string) *string { return &s } + intp := func(i int) *int { return &i } + boolp := func(b bool) *bool { return &b } + + recon := minimalReconResult() + + secret := schemas.NewSecretFinding() + secret.SecretType = "api_key" + secret.FilePath = "src/config.py" + secret.Line = 7 + secret.Match = `API_KEY = "sk-live-123"` + secret.Confidence = "high" + + misconfig := schemas.NewMisconfigFinding() + misconfig.Category = "dangerous_config" + misconfig.FilePath = "deploy/prod.yaml" + misconfig.Line = intp(22) + misconfig.Key = str("DEBUG") + misconfig.Value = str("true") + misconfig.Risk = "Debug mode enabled in production" + + recon.Config = schemas.ConfigReport{ + Secrets: []schemas.SecretFinding{secret}, + Misconfigs: []schemas.MisconfigFinding{misconfig}, + } + + recon.SecurityContext = schemas.NewSecurityContext() + recon.SecurityContext.AuthModel = "jwt" + recon.SecurityContext.AuthDetails = "Bearer token" + recon.SecurityContext.CryptoUsage = []schemas.CryptoUsage{ + {Algorithm: "TLSv1.0", UsageContext: str("public edge"), IsWeak: boolp(true)}, + } + + findings := ExtractReconFindings(recon) + + // assert len(findings) == 3 + if len(findings) != 3 { + t.Fatalf("len(findings) = %d, want 3", len(findings)) + } + + // assert {f.hunter_strategy for f in findings} == {"recon"} + for i, f := range findings { + if f.HunterStrategy != "recon" { + t.Errorf("findings[%d].hunter_strategy = %q, want %q", i, f.HunterStrategy, "recon") + } + // assert {f.confidence for f in findings} == {Confidence.HIGH} + if f.Confidence != schemas.ConfidenceHigh { + t.Errorf("findings[%d].confidence = %q, want %q", i, f.Confidence, schemas.ConfidenceHigh) + } + // finding_type is one of {SECRETS, CONFIG} + if f.FindingType != schemas.FindingTypeSecrets && f.FindingType != schemas.FindingTypeConfig { + t.Errorf("findings[%d].finding_type = %q, want secrets or config", i, f.FindingType) + } + if f.EndLine != f.StartLine { + t.Errorf("findings[%d] end_line %d != start_line %d", i, f.EndLine, f.StartLine) + } + } + + types := map[schemas.FindingType]bool{} + for _, f := range findings { + types[f.FindingType] = true + } + if !types[schemas.FindingTypeSecrets] || !types[schemas.FindingTypeConfig] || len(types) != 2 { + t.Errorf("finding types = %v, want exactly {secrets, config}", types) + } + + // assert any(f.cwe_id == "CWE-798" and f.estimated_severity == Severity.HIGH ...) + if !anyFinding(findings, func(f schemas.RawFinding) bool { + return f.CweID == "CWE-798" && f.EstimatedSeverity == schemas.SeverityHigh + }) { + t.Error("no CWE-798 HIGH finding") + } + // assert any(f.cwe_id == "CWE-16" and f.estimated_severity == Severity.MEDIUM ...) + if !anyFinding(findings, func(f schemas.RawFinding) bool { + return f.CweID == "CWE-16" && f.EstimatedSeverity == schemas.SeverityMedium + }) { + t.Error("no CWE-16 MEDIUM finding") + } + // assert any(f.cwe_id == "CWE-327" and f.file_path == "security_context" ...) + if !anyFinding(findings, func(f schemas.RawFinding) bool { + return f.CweID == "CWE-327" && f.FilePath == "security_context" + }) { + t.Error("no CWE-327 finding located at security_context") + } +} + +// TestExtractReconFindingsFieldsAndOrder pins the exact strings the extraction +// builds — the descriptions and snippets are what the HUNT/PROVE prompts see — +// and the config-secrets / config-misconfigs / weak-TLS ordering. +func TestExtractReconFindingsFieldsAndOrder(t *testing.T) { + str := func(s string) *string { return &s } + intp := func(i int) *int { return &i } + boolp := func(b bool) *bool { return &b } + + recon := schemas.NewReconResult() + + secret := schemas.NewSecretFinding() + secret.SecretType = "api_key" + secret.FilePath = "src/config.py" + secret.Line = 7 + secret.Match = `API_KEY = "sk-live-123"` + secret.Confidence = "high" + + misconfig := schemas.NewMisconfigFinding() + misconfig.Category = "dangerous_config" + misconfig.FilePath = "deploy/prod.yaml" + misconfig.Line = intp(22) + misconfig.Key = str("DEBUG") + misconfig.Value = str("true") + misconfig.Risk = "Debug mode enabled in production" + + recon.Config = schemas.ConfigReport{ + Secrets: []schemas.SecretFinding{secret}, + Misconfigs: []schemas.MisconfigFinding{misconfig}, + } + recon.SecurityContext.CryptoUsage = []schemas.CryptoUsage{ + {Algorithm: "TLSv1.0", UsageContext: str("public edge"), IsWeak: boolp(true)}, + } + + got := ExtractReconFindings(recon) + if len(got) != 3 { + t.Fatalf("len = %d, want 3", len(got)) + } + + // [0] the secret + wantTitle := "Hardcoded secret in src/config.py" + wantDesc := "Detected api_key secret at src/config.py:7. " + + "Data flow summary: hardcoded credential from source file can be reused by an attacker." + if got[0].Title != wantTitle { + t.Errorf("findings[0].title = %q, want %q", got[0].Title, wantTitle) + } + if got[0].Description != wantDesc { + t.Errorf("findings[0].description = %q, want %q", got[0].Description, wantDesc) + } + if got[0].CodeSnippet != `API_KEY = "sk-live-123"` { + t.Errorf("findings[0].code_snippet = %q", got[0].CodeSnippet) + } + if got[0].OwaspCategory == nil || *got[0].OwaspCategory != "A07:2021" { + t.Errorf("findings[0].owasp_category = %v, want A07:2021", got[0].OwaspCategory) + } + if got[0].StartLine != 7 || got[0].EndLine != 7 { + t.Errorf("findings[0] lines = (%d, %d), want (7, 7)", got[0].StartLine, got[0].EndLine) + } + + // [1] the misconfiguration — snippet is "key, value" + wantMisDesc := "Detected dangerous_config with risk: Debug mode enabled in production. " + + "Data flow summary: insecure runtime configuration weakens application security controls." + if got[1].Title != "Insecure configuration in deploy/prod.yaml" { + t.Errorf("findings[1].title = %q", got[1].Title) + } + if got[1].Description != wantMisDesc { + t.Errorf("findings[1].description = %q, want %q", got[1].Description, wantMisDesc) + } + if got[1].CodeSnippet != "DEBUG, true" { + t.Errorf("findings[1].code_snippet = %q, want %q", got[1].CodeSnippet, "DEBUG, true") + } + + // [2] weak TLS + if got[2].Title != "Weak TLS configuration: TLSv1.0" { + t.Errorf("findings[2].title = %q", got[2].Title) + } + wantTLSDesc := "Detected weak transport crypto usage in public edge. " + + "Data flow summary: clients may negotiate weak encryption for in-transit data." + if got[2].Description != wantTLSDesc { + t.Errorf("findings[2].description = %q, want %q", got[2].Description, wantTLSDesc) + } + if got[2].CodeSnippet != "algorithm=TLSv1.0; context=public edge" { + t.Errorf("findings[2].code_snippet = %q", got[2].CodeSnippet) + } + if got[2].StartLine != 1 || got[2].EndLine != 1 { + t.Errorf("findings[2] lines = (%d, %d), want (1, 1)", got[2].StartLine, got[2].EndLine) + } +} + +// TestSafeLineFallbacks pins _safe_line: a non-positive or absent line becomes +// 1, so a finding always points somewhere. +func TestSafeLineFallbacks(t *testing.T) { + recon := schemas.NewReconResult() + + zeroLineSecret := schemas.NewSecretFinding() + zeroLineSecret.SecretType = "token" + zeroLineSecret.FilePath = "a.py" + zeroLineSecret.Line = 0 + zeroLineSecret.Match = "tok" + zeroLineSecret.Confidence = "low" + + noLineMisconfig := schemas.NewMisconfigFinding() + noLineMisconfig.Category = "cors" + noLineMisconfig.FilePath = "nginx.conf" + noLineMisconfig.Line = nil + noLineMisconfig.Risk = "Wildcard origin" + + recon.Config = schemas.ConfigReport{ + Secrets: []schemas.SecretFinding{zeroLineSecret}, + Misconfigs: []schemas.MisconfigFinding{noLineMisconfig}, + } + + got := ExtractReconFindings(recon) + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0].StartLine != 1 { + t.Errorf("secret with line 0 -> start_line %d, want 1", got[0].StartLine) + } + if got[1].StartLine != 1 { + t.Errorf("misconfig with line nil -> start_line %d, want 1", got[1].StartLine) + } + // Both key and value absent -> the snippet falls back to the risk text. + if got[1].CodeSnippet != "Wildcard origin" { + t.Errorf("misconfig snippet = %q, want the risk text", got[1].CodeSnippet) + } +} + +// TestExtractWeakTLSGate pins the three-clause filter: only an EXPLICIT +// is_weak==true qualifies, and the usage must look transport-related through +// the algorithm ("tls"/"ssl") or the usage context ("tls" only). +func TestExtractWeakTLSGate(t *testing.T) { + str := func(s string) *string { return &s } + boolp := func(b bool) *bool { return &b } + + cases := []struct { + name string + usage schemas.CryptoUsage + want bool + }{ + {"weak_tls_algorithm", schemas.CryptoUsage{Algorithm: "TLSv1.0", UsageContext: str("edge"), IsWeak: boolp(true)}, true}, + {"weak_ssl_algorithm", schemas.CryptoUsage{Algorithm: "SSLv3", UsageContext: str("edge"), IsWeak: boolp(true)}, true}, + {"weak_tls_context", schemas.CryptoUsage{Algorithm: "RC4", UsageContext: str("legacy TLS terminator"), IsWeak: boolp(true)}, true}, + {"weak_ssl_context_only", schemas.CryptoUsage{Algorithm: "RC4", UsageContext: str("ssl offload"), IsWeak: boolp(true)}, false}, + {"weak_but_not_transport", schemas.CryptoUsage{Algorithm: "MD5", UsageContext: str("password hashing"), IsWeak: boolp(true)}, false}, + {"transport_but_not_weak", schemas.CryptoUsage{Algorithm: "TLSv1.3", UsageContext: str("edge"), IsWeak: boolp(false)}, false}, + {"transport_weak_unknown", schemas.CryptoUsage{Algorithm: "TLSv1.0", UsageContext: str("edge"), IsWeak: nil}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := schemas.NewSecurityContext() + ctx.CryptoUsage = []schemas.CryptoUsage{tc.usage} + got := extractWeakTLS(ctx) + if (len(got) == 1) != tc.want { + t.Errorf("extractWeakTLS produced %d findings, want %v", len(got), tc.want) + } + }) + } +} + +// TestExtractWeakTLSUnknownSubstitutions pins that an EMPTY algorithm becomes +// the literal "unknown" (which is then what the transport test sees, so such a +// usage only qualifies via its context) and an empty/absent usage context +// becomes "security context". +func TestExtractWeakTLSUnknownSubstitutions(t *testing.T) { + boolp := func(b bool) *bool { return &b } + str := func(s string) *string { return &s } + + ctx := schemas.NewSecurityContext() + ctx.CryptoUsage = []schemas.CryptoUsage{ + {Algorithm: "", UsageContext: str("tls handshake"), IsWeak: boolp(true)}, + {Algorithm: "TLSv1.0", UsageContext: nil, IsWeak: boolp(true)}, + } + got := extractWeakTLS(ctx) + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0].Title != "Weak TLS configuration: unknown" { + t.Errorf("empty algorithm -> title %q, want the 'unknown' substitution", got[0].Title) + } + if got[1].CodeSnippet != "algorithm=TLSv1.0; context=security context" { + t.Errorf("absent usage context -> snippet %q", got[1].CodeSnippet) + } +} + +// TestExtractWeakTLSDoesNotFireOnEmptyAlgorithmAlone pins the consequence of +// the "unknown" substitution: an empty algorithm with a non-transport context +// is filtered out, because "unknown" contains neither "tls" nor "ssl". +func TestExtractWeakTLSDoesNotFireOnEmptyAlgorithmAlone(t *testing.T) { + boolp := func(b bool) *bool { return &b } + ctx := schemas.NewSecurityContext() + ctx.CryptoUsage = []schemas.CryptoUsage{{Algorithm: "", IsWeak: boolp(true)}} + if got := extractWeakTLS(ctx); len(got) != 0 { + t.Errorf("extractWeakTLS = %d findings, want 0", len(got)) + } +} + +// TestExtractStructuredSecurityItemsIsAlwaysEmpty pins the dead-in-Python +// branch. SecurityContext declares none of the four categories and pydantic's +// default extra="ignore" drops them, so getattr returns None and the loop never +// runs. See the function's doc comment. +func TestExtractStructuredSecurityItemsIsAlwaysEmpty(t *testing.T) { + ctx := schemas.NewSecurityContext() + ctx.AuthModel = "jwt" + ctx.FrameworkSecurity = []string{"whatever"} + if got := extractStructuredSecurityItems(ctx); len(got) != 0 { + t.Errorf("extractStructuredSecurityItems = %d findings, want 0", len(got)) + } +} + +// TestExtractReconFindingsEmptyIsNotNil pins that the result serializes as `[]` +// rather than `null`, matching Python's list return. +func TestExtractReconFindingsEmptyIsNotNil(t *testing.T) { + got := ExtractReconFindings(schemas.NewReconResult()) + if got == nil { + t.Fatal("ExtractReconFindings returned nil, want an empty slice") + } + if len(got) != 0 { + t.Errorf("len = %d, want 0", len(got)) + } +} + +// TestReconFindingsGetDistinctIDs pins that every finding carries its own uuid4 +// id and fingerprint (pydantic default_factory parity), which the deduplicator +// relies on. +func TestReconFindingsGetDistinctIDs(t *testing.T) { + recon := schemas.NewReconResult() + secrets := make([]schemas.SecretFinding, 0, 3) + for i := 0; i < 3; i++ { + s := schemas.NewSecretFinding() + s.SecretType = "api_key" + s.FilePath = "a.py" + s.Line = i + 1 + s.Match = "m" + s.Confidence = "high" + secrets = append(secrets, s) + } + recon.Config = schemas.ConfigReport{Secrets: secrets} + + got := ExtractReconFindings(recon) + seen := map[string]bool{} + for _, f := range got { + if f.ID == "" || f.Fingerprint == "" { + t.Fatalf("finding has empty id/fingerprint: %+v", f) + } + if seen[f.ID] { + t.Errorf("duplicate finding id %q", f.ID) + } + seen[f.ID] = true + } +} + +func anyFinding(findings []schemas.RawFinding, pred func(schemas.RawFinding) bool) bool { + for _, f := range findings { + if pred(f) { + return true + } + } + return false +} diff --git a/go/internal/agents/recon/golden_test.go b/go/internal/agents/recon/golden_test.go new file mode 100644 index 0000000..86b9f99 --- /dev/null +++ b/go/internal/agents/recon/golden_test.go @@ -0,0 +1,96 @@ +package recon + +// Shared helpers for the golden-fixture tests in this package. +// +// Every fixture under testdata/golden is produced by go/scripts/gen_golden.py +// running the REAL Python code from src/sec_af/agents/recon. Regenerate with: +// +// PYTHONPATH=src ~/.agentfield/packages/sec-af/venv/bin/python go/scripts/gen_golden.py +// +// A test failing here means the Go port and the Python source disagree about +// bytes that reach the LLM (prompts) or the wire (parsed models) — not that a +// fixture needs refreshing. Refresh only after a deliberate Python change. + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +const goldenDir = "testdata/golden" + +// goldenText reads a *.txt fixture verbatim. +func goldenText(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(goldenDir, name+".txt")) + if err != nil { + t.Fatalf("read golden %s.txt: %v", name, err) + } + return string(b) +} + +// goldenJSON decodes a *.json fixture into dest. +func goldenJSON(t *testing.T, name string, dest any) { + t.Helper() + b, err := os.ReadFile(filepath.Join(goldenDir, name+".json")) + if err != nil { + t.Fatalf("read golden %s.json: %v", name, err) + } + if err := json.Unmarshal(b, dest); err != nil { + t.Fatalf("decode golden %s.json: %v", name, err) + } +} + +// jsonTree marshals v and decodes the result into the untyped tree shape the +// golden fixtures decode to, so the two can be compared with reflect.DeepEqual +// without either side's Go types leaking into the comparison. +func jsonTree(t *testing.T, v any) any { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal %T: %v", v, err) + } + var tree any + if err := json.Unmarshal(b, &tree); err != nil { + t.Fatalf("unmarshal %T: %v", v, err) + } + return tree +} + +// scrubIDs replaces every string value under an "id" key with the placeholder +// gen_golden.py writes. SecretFinding and MisconfigFinding mint a fresh uuid4 +// per parse (pydantic default_factory / schemas.New*), so the ids are +// nondeterministic by construction and cannot be compared. +func scrubIDs(v any) any { + switch t := v.(type) { + case map[string]any: + out := make(map[string]any, len(t)) + for k, val := range t { + if k == "id" { + if _, isStr := val.(string); isStr { + out[k] = "" + continue + } + } + out[k] = scrubIDs(val) + } + return out + case []any: + out := make([]any, len(t)) + for i, val := range t { + out[i] = scrubIDs(val) + } + return out + default: + return v + } +} + +// diffJSON renders got/want for a readable failure message. +func diffJSON(t *testing.T, got, want any) string { + t.Helper() + g, _ := json.MarshalIndent(got, "", " ") + w, _ := json.MarshalIndent(want, "", " ") + return "\n--- got ---\n" + string(g) + "\n--- want (python) ---\n" + string(w) +} diff --git a/go/internal/agents/recon/metrics.go b/go/internal/agents/recon/metrics.go new file mode 100644 index 0000000..550dd82 --- /dev/null +++ b/go/internal/agents/recon/metrics.go @@ -0,0 +1,265 @@ +package recon + +// Ports the `_SKIP_DIRS` / `_CODE_EXTS` tables and `_repo_metrics` from +// src/sec_af/agents/recon/__init__.py. + +import ( + "io" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// skipDirs ports _SKIP_DIRS. Membership is tested against EVERY path component +// (see RepoMetrics), not just the immediate parent. +var skipDirs = map[string]bool{ + ".git": true, + ".hg": true, + ".svn": true, + "node_modules": true, + "vendor": true, + ".venv": true, + "venv": true, + "__pycache__": true, +} + +// codeExts ports _CODE_EXTS — the extensions whose LINES are counted. Files +// outside this set still count toward the file total. +var codeExts = map[string]bool{ + ".py": true, ".go": true, ".js": true, ".jsx": true, ".ts": true, ".tsx": true, + ".java": true, ".kt": true, ".swift": true, ".rs": true, ".c": true, ".cc": true, + ".cpp": true, ".h": true, ".hpp": true, ".cs": true, ".rb": true, ".php": true, + ".scala": true, ".sql": true, ".sh": true, ".yaml": true, ".yml": true, + ".json": true, ".toml": true, +} + +// RepoMetrics ports _repo_metrics: +// +// def _repo_metrics(repo_path: str) -> tuple[int, int]: +// root = Path(repo_path) +// if not root.exists(): +// return 0, 0 +// file_count = 0 +// line_count = 0 +// for path in root.rglob("*"): +// if any(part in _SKIP_DIRS for part in path.parts): +// continue +// if not path.is_file(): +// continue +// file_count += 1 +// if path.suffix.lower() not in _CODE_EXTS: +// continue +// try: +// with path.open("r", encoding="utf-8", errors="ignore") as handle: +// for _ in handle: +// line_count += 1 +// except OSError: +// continue +// return line_count, file_count +// +// Returns (linesOfCode, fileCount) — Python's tuple order, which is the +// opposite of what the names suggest, so callers must not swap them. +// +// Python parity, in the order the quirks bite: +// +// - The skip test runs over `path.parts`, the components of the FULL path +// including everything in repo_path itself. A repository that happens to +// live under a directory named `vendor` or `venv` therefore reports (0, 0). +// That is reproduced here (rootHasSkippedPart short-circuits), not fixed. +// - `is_file()` follows symlinks: a symlink to a regular file IS counted, a +// broken symlink and a symlink to a directory are not, and neither are +// sockets/fifos/devices. +// - pathlib does not descend THROUGH a symlinked directory, and neither does +// filepath.WalkDir, so a symlink loop cannot hang either implementation. +// - `Path.suffix` is NOT filepath.Ext: a leading dot does not start a suffix +// (".gitignore" has none), a trailing dot does not end one ("x." has none), +// and only the last component counts ("a.tar.gz" is ".gz"). pySuffix +// implements pathlib's rule. +// - Line counting is Python TEXT-mode iteration with universal newlines: LF, +// CRLF and a bare CR each terminate a line, and a final line with no +// terminator still counts. `errors="ignore"` cannot change the count — +// UTF-8 continuation bytes are 0x80..0xBF, so a dropped invalid byte can +// never have been a 0x0A/0x0D. +// - Both loops swallow their errors: an unreadable directory contributes +// nothing (pathlib catches PermissionError), and a file that fails to open +// stays counted in file_count but adds no lines (`except OSError: continue` +// runs AFTER the increment). +// +// Deviation: Python's `Path("")` is `Path(".")`, which exists, so an empty +// repo_path would scan the process working directory. Go's os.Stat("") fails, +// so this returns (0, 0). No caller passes an empty path — app.py resolves it +// to an absolute directory first. +func RepoMetrics(repoPath string) (int, int) { + if _, err := os.Stat(repoPath); err != nil { + return 0, 0 + } + if rootHasSkippedPart(repoPath) { + return 0, 0 + } + + lineCount, fileCount := 0, 0 + _ = filepath.WalkDir(repoPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + // pathlib swallows PermissionError while iterating; an unreadable + // directory simply yields nothing. + if d != nil && d.IsDir() { + return fs.SkipDir + } + return nil + } + if path == repoPath { + // rglob("*") never yields the root itself. + return nil + } + if d.IsDir() { + if skipDirs[d.Name()] { + // Every path under a skipped directory carries that component, + // so Python's per-path filter rejects all of them. Pruning here + // is observationally identical and avoids walking node_modules. + return fs.SkipDir + } + return nil + } + if skipDirs[d.Name()] { + // A FILE whose own name is in the table is filtered too — the + // Python test is over components, not just directories. + return nil + } + + if !isRegularFollowingSymlinks(path, d) { + return nil + } + fileCount++ + + if !codeExts[strings.ToLower(pySuffix(d.Name()))] { + return nil + } + n, err := countPythonLines(path) + if err != nil { + return nil // except OSError: continue + } + lineCount += n + return nil + }) + + return lineCount, fileCount +} + +// rootHasSkippedPart reports whether repo_path's OWN components include a +// skipped directory name, which makes Python's `any(part in _SKIP_DIRS for part +// in path.parts)` true for every descendant. +func rootHasSkippedPart(repoPath string) bool { + for _, part := range pathParts(repoPath) { + if skipDirs[part] { + return true + } + } + return false +} + +// pathParts reproduces pathlib.PurePosixPath(p).parts: the POSIX root (if any) +// as its own leading element, then the non-empty components with "." segments +// dropped and ".." kept. +// +// "/a/b/c" -> ["/", "a", "b", "c"] "./a/b" -> ["a", "b"] +// "/a//b/" -> ["/", "a", "b"] "../a" -> ["..", "a"] +// "a" -> ["a"] "/" -> ["/"] +// +// The exactly-two-leading-slashes case is POSIX's implementation-defined root +// and pathlib keeps it as the literal "//" element. +func pathParts(p string) []string { + var parts []string + rest := p + if strings.HasPrefix(p, "/") { + if strings.HasPrefix(p, "//") && !strings.HasPrefix(p, "///") { + parts = append(parts, "//") + } else { + parts = append(parts, "/") + } + rest = strings.TrimLeft(p, "/") + } + for _, seg := range strings.Split(rest, "/") { + if seg == "" || seg == "." { + continue + } + parts = append(parts, seg) + } + return parts +} + +// pySuffix reproduces pathlib.PurePath(name).suffix — NOT filepath.Ext. +// +// "a.py" -> ".py" ".gitignore" -> "" "x." -> "" "a.tar.gz" -> ".gz" +func pySuffix(name string) string { + i := strings.LastIndexByte(name, '.') + if i > 0 && i < len(name)-1 { + return name[i:] + } + return "" +} + +// isRegularFollowingSymlinks implements Path.is_file(): true for a regular +// file, and for a symlink that RESOLVES to one. Directories, broken symlinks, +// sockets, fifos and devices are all false. +func isRegularFollowingSymlinks(path string, d fs.DirEntry) bool { + if d.Type()&os.ModeSymlink != 0 { + st, err := os.Stat(path) // follows the link + return err == nil && st.Mode().IsRegular() + } + return d.Type().IsRegular() +} + +// countPythonLines counts lines the way iterating a Python text file opened +// with the default newline=None (universal newlines) does: LF, CRLF and a lone +// CR each end a line, and trailing content with no terminator counts as one +// more. An empty file has zero lines. +// +// Streamed in 64 KiB chunks rather than read whole so a pathologically large +// file cannot blow up the process the way Python's line-at-a-time loop never +// would. +func countPythonLines(path string) (int, error) { + f, err := os.Open(path) + if err != nil { + return 0, err + } + defer f.Close() + + buf := make([]byte, 64*1024) + lines := 0 + pending := false // saw content since the last terminator + sawCR := false // previous byte was CR; a following LF joins it + for { + n, readErr := f.Read(buf) + for i := 0; i < n; i++ { + c := buf[i] + if sawCR { + sawCR = false + if c == '\n' { + continue // CRLF: the CR already counted the line + } + } + switch c { + case '\n': + lines++ + pending = false + case '\r': + lines++ + pending = false + sawCR = true + default: + pending = true + } + } + if readErr != nil { + if readErr == io.EOF { + break + } + return 0, readErr + } + } + if pending { + lines++ + } + return lines, nil +} diff --git a/go/internal/agents/recon/metrics_test.go b/go/internal/agents/recon/metrics_test.go new file mode 100644 index 0000000..56f3b89 --- /dev/null +++ b/go/internal/agents/recon/metrics_test.go @@ -0,0 +1,263 @@ +package recon + +// Validation contract for _repo_metrics: +// +// - A missing repository reports (0, 0). +// - Any path whose components include a _SKIP_DIRS name is invisible — +// including when the component comes from repo_path itself. +// - file_count counts EVERY surviving regular file (following symlinks), +// whatever its extension; line_count adds lines only for _CODE_EXTS files. +// - The extension test uses pathlib's suffix rule, not filepath.Ext. +// - Lines are counted the way Python text-mode iteration counts them: LF, +// CRLF and a lone CR each end a line, a final unterminated line still +// counts, and an empty file has none. + +import ( + "encoding/base64" + "os" + "path/filepath" + "sort" + "testing" +) + +// repoMetricsGolden mirrors testdata/golden/repo_metrics.json. +type repoMetricsGolden struct { + Files map[string]string `json:"files"` // relpath -> base64 content + Symlinks map[string]string `json:"symlinks"` // relpath -> target + LinesOfCode int `json:"lines_of_code"` + FileCount int `json:"file_count"` +} + +// materialize writes the golden's tree under root. Directories are created on +// demand, exactly as gen_golden.py does on the Python side. +func materialize(t *testing.T, root string, g repoMetricsGolden) { + t.Helper() + // Sorted so the (rare) case of a symlink target that must exist first is + // deterministic; content files are all written before any link. + rels := make([]string, 0, len(g.Files)) + for rel := range g.Files { + rels = append(rels, rel) + } + sort.Strings(rels) + for _, rel := range rels { + data, err := base64.StdEncoding.DecodeString(g.Files[rel]) + if err != nil { + t.Fatalf("decode %s: %v", rel, err) + } + path := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir for %s: %v", rel, err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + + links := make([]string, 0, len(g.Symlinks)) + for rel := range g.Symlinks { + links = append(links, rel) + } + sort.Strings(links) + for _, rel := range links { + path := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir for symlink %s: %v", rel, err) + } + if err := os.Symlink(g.Symlinks[rel], path); err != nil { + t.Fatalf("symlink %s -> %s: %v", rel, g.Symlinks[rel], err) + } + } +} + +// TestRepoMetricsGolden runs RepoMetrics over the exact tree gen_golden.py fed +// to the real Python _repo_metrics and asserts the same tuple. +func TestRepoMetricsGolden(t *testing.T) { + var g repoMetricsGolden + goldenJSON(t, "repo_metrics", &g) + if len(g.Files) == 0 { + t.Fatal("golden tree is empty") + } + + root := t.TempDir() + materialize(t, root, g) + + lines, files := RepoMetrics(root) + if lines != g.LinesOfCode || files != g.FileCount { + t.Errorf("RepoMetrics = (lines %d, files %d), want (lines %d, files %d) from Python", + lines, files, g.LinesOfCode, g.FileCount) + } +} + +// TestRepoMetricsMissingRoot ports `if not root.exists(): return 0, 0`. +func TestRepoMetricsMissingRoot(t *testing.T) { + missing := filepath.Join(t.TempDir(), "does-not-exist") + if lines, files := RepoMetrics(missing); lines != 0 || files != 0 { + t.Errorf("RepoMetrics(missing) = (%d, %d), want (0, 0)", lines, files) + } +} + +// TestRepoMetricsSkipsWhenRepoPathItselfIsSkipped pins the Python quirk that +// the _SKIP_DIRS test runs over path.parts — the components of the FULL path — +// so a repository living under a directory named "vendor" is entirely +// invisible. This is a faithful port of a bug, not a bug in the port. +func TestRepoMetricsSkipsWhenRepoPathItselfIsSkipped(t *testing.T) { + base := t.TempDir() + for _, skipped := range []string{"vendor", "venv", ".git", "node_modules"} { + repo := filepath.Join(base, skipped, "myrepo") + if err := os.MkdirAll(repo, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "main.py"), []byte("a\nb\n"), 0o644); err != nil { + t.Fatal(err) + } + if lines, files := RepoMetrics(repo); lines != 0 || files != 0 { + t.Errorf("RepoMetrics under %q = (%d, %d), want (0, 0)", skipped, lines, files) + } + } +} + +// TestCountPythonLines pins the universal-newline line count against CPython +// ground truth, produced by iterating each byte string through +// `open(path, "r", encoding="utf-8", errors="ignore")` under the pinned +// interpreter (~/.agentfield/packages/sec-af/venv/bin/python, 3.11.12). +func TestCountPythonLines(t *testing.T) { + cases := []struct { + name string + data []byte + want int + }{ + {"lf_terminated", []byte("a\nb\nc\n"), 3}, + {"lf_unterminated", []byte("a\nb\nc"), 3}, + {"empty", []byte(""), 0}, + {"single_newline", []byte("\n"), 1}, + {"crlf", []byte("a\r\nb\r\n"), 2}, + {"cr_only_terminated", []byte("a\rb\r"), 2}, + {"cr_only_unterminated", []byte("a\rb"), 2}, + {"invalid_utf8", []byte("\xff\xfe bad bytes\nsecond\n"), 2}, + {"blank_lines", []byte("a\n\n\nb"), 4}, + {"cr_at_eof_only", []byte("\r"), 1}, + {"crlf_then_content", []byte("a\r\nb"), 2}, + } + dir := t.TempDir() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(dir, tc.name) + if err := os.WriteFile(path, tc.data, 0o644); err != nil { + t.Fatal(err) + } + got, err := countPythonLines(path) + if err != nil { + t.Fatalf("countPythonLines: %v", err) + } + if got != tc.want { + t.Errorf("countPythonLines(%q) = %d, want %d", tc.data, got, tc.want) + } + }) + } +} + +// TestCountPythonLinesAcrossChunkBoundary pins that the streaming reader's +// CR/LF state survives a buffer boundary: a CRLF split across two 64 KiB reads +// must still count as ONE line terminator. +func TestCountPythonLinesAcrossChunkBoundary(t *testing.T) { + const chunk = 64 * 1024 + data := make([]byte, 0, chunk+8) + for len(data) < chunk-1 { + data = append(data, 'x') + } + data = append(data, '\r', '\n', 'y', '\n') // the CR is the last byte of chunk 1 + path := filepath.Join(t.TempDir(), "boundary.py") + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + got, err := countPythonLines(path) + if err != nil { + t.Fatal(err) + } + if got != 2 { + t.Errorf("countPythonLines across a chunk boundary = %d, want 2", got) + } +} + +// TestPySuffix pins pathlib's suffix rule, which filepath.Ext does not share. +// Ground truth from `Path(name).suffix` under the pinned interpreter. +func TestPySuffix(t *testing.T) { + cases := map[string]string{ + "a.py": ".py", + ".gitignore": "", + "foo.": "", + "foo.tar.gz": ".gz", + "Makefile": "", + "a.PY": ".PY", + ".config.yaml": ".yaml", + "x.YML": ".YML", + "": "", + ".": "", + } + for name, want := range cases { + if got := pySuffix(name); got != want { + t.Errorf("pySuffix(%q) = %q, want %q", name, got, want) + } + } +} + +// TestPathParts pins pathlib.PurePosixPath(p).parts, which drives the skip-dir +// test. Ground truth from `Path(p).parts` under the pinned interpreter. +func TestPathParts(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"/a/b/c", []string{"/", "a", "b", "c"}}, + {"a/b", []string{"a", "b"}}, + {"./a/b", []string{"a", "b"}}, + {"/a//b/", []string{"/", "a", "b"}}, + {"../a", []string{"..", "a"}}, + {"/", []string{"/"}}, + {"a", []string{"a"}}, + {"//a", []string{"//", "a"}}, + {"///a", []string{"/", "a"}}, + } + for _, tc := range cases { + got := pathParts(tc.in) + if len(got) != len(tc.want) { + t.Errorf("pathParts(%q) = %q, want %q", tc.in, got, tc.want) + continue + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("pathParts(%q) = %q, want %q", tc.in, got, tc.want) + break + } + } + } +} + +// TestRepoMetricsCountsNonCodeFilesButNotTheirLines pins the split between the +// two counters: every surviving regular file bumps file_count, only _CODE_EXTS +// files contribute lines. +func TestRepoMetricsCountsNonCodeFilesButNotTheirLines(t *testing.T) { + root := t.TempDir() + write := func(rel, content string) { + t.Helper() + path := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + write("a.py", "1\n2\n3\n") // 3 lines, code + write("README.md", "x\ny\n") // 0 lines, not code + write("LICENSE", "long text\n") // 0 lines, no suffix at all + write("b/c.go", "package b\n") // 1 line, code + + lines, files := RepoMetrics(root) + if files != 4 { + t.Errorf("file_count = %d, want 4", files) + } + if lines != 4 { + t.Errorf("lines_of_code = %d, want 4 (3 from a.py + 1 from b/c.go)", lines) + } +} diff --git a/go/internal/agents/recon/parsers.go b/go/internal/agents/recon/parsers.go new file mode 100644 index 0000000..774e1c7 --- /dev/null +++ b/go/internal/agents/recon/parsers.go @@ -0,0 +1,590 @@ +package recon + +// Ports src/sec_af/agents/recon/_parsers.py in full. +// +// Every RECON mapper asks the harness for a FLAT model (`ArchitectureMapRaw`, +// `DataFlowMapRaw`, ...) whose fields are `list[str]` of pipe-delimited rows, +// because LLMs produce flat rows far more reliably than nested JSON. The +// functions here turn those rows into the structured schemas the rest of the +// pipeline consumes. They are total: a malformed row never errors, it degrades +// into empty strings / zero lines / nil optionals, exactly as in Python. + +import ( + "strconv" + "strings" + "unicode" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// --------------------------------------------------------------------------- +// CPython string/number primitives +// --------------------------------------------------------------------------- + +// pyStrip reproduces Python's str.strip() with no argument. +// +// Python parity: CPython strips every code point for which Py_UNICODE_ISSPACE +// is true. That set is Go's unicode.IsSpace PLUS the four C0 information +// separators U+001C..U+001F (FS, GS, RS, US), which Go does not consider +// whitespace. The extra clause below closes that gap so a row separated with an +// exotic control character strips identically in both runtimes. +func pyStrip(s string) string { + return strings.TrimFunc(s, func(r rune) bool { + return unicode.IsSpace(r) || (r >= 0x1C && r <= 0x1F) + }) +} + +// splitPipe ports _split_pipe: +// +// def _split_pipe(s: str, expected: int) -> list[str]: +// parts = [p.strip() for p in s.split("|", maxsplit=expected - 1)] +// while len(parts) < expected: +// parts.append("") +// return parts +// +// Python parity: `maxsplit=expected-1` caps the result at `expected` fields, so +// a row with MORE pipes than the format allows keeps the surplus (pipes and +// all) in the LAST field — "a | b | c | d | e" with expected=4 yields +// ["a", "b", "c", "d | e"], not a dropped tail. Go's strings.SplitN(s, "|", n) +// has exactly that cap semantics for n >= 1, and every call site passes +// expected in 4..7. Short rows are right-padded with "". +func splitPipe(s string, expected int) []string { + parts := strings.SplitN(s, "|", expected) + out := make([]string, 0, expected) + for _, p := range parts { + out = append(out, pyStrip(p)) + } + for len(out) < expected { + out = append(out, "") + } + return out +} + +// parseBool ports _parse_bool: "true"/"yes"/"1" -> true, "false"/"no"/"0" -> +// false, anything else -> None (nil). +// +// Python parity: Python lowercases FIRST and strips SECOND +// (`s.lower().strip()`); the order is immaterial for this alphabet but is +// preserved anyway. Go's strings.ToLower is simple per-rune lowering while +// CPython's str.lower() applies full Unicode case mappings; the two differ only +// for code points whose lowering expands (e.g. U+0130), none of which can occur +// in the six accepted literals. +func parseBool(s string) *bool { + switch pyStrip(strings.ToLower(s)) { + case "true", "yes", "1": + t := true + return &t + case "false", "no", "0": + f := false + return &f + } + return nil +} + +// parseInt ports _parse_int(s, default=0) — `int(s.strip())` with the given +// fallback on ValueError/TypeError. +// +// Python parity: CPython's int() accepts an optional sign, ASCII digits, and +// underscores used as digit separators ("1_0" is 10; "_1", "1_" and "1__0" are +// all errors). Documented deviations, both unreachable for the pipe-delimited +// line numbers this parses: non-ASCII decimal digits (CPython accepts them, +// this does not) and values beyond int64 (CPython has arbitrary precision, this +// returns the fallback). +func parseInt(s string, def int) int { + t, ok := stripUnderscores(pyStrip(s)) + if !ok { + return def + } + n, err := strconv.Atoi(t) + if err != nil { + return def + } + return n +} + +// parseFloat ports _parse_float — `float(s.strip())`, None on failure. +// +// Python parity: the underscore rule is the same as int()'s. Two guards keep Go +// from being MORE permissive than CPython: hexadecimal float literals +// ("0x1p-2") are rejected here because float() rejects them while +// strconv.ParseFloat accepts them. The special spellings CPython does accept — +// "inf", "-inf", "infinity", "nan", any case — are accepted by ParseFloat too, +// so they need no special handling. +func parseFloat(s string) *float64 { + t, ok := stripUnderscores(pyStrip(s)) + if !ok { + return nil + } + mantissa := strings.TrimLeft(t, "+-") + if len(mantissa) > 1 && mantissa[0] == '0' && (mantissa[1] == 'x' || mantissa[1] == 'X') { + return nil // CPython: float("0x1p-2") raises ValueError + } + f, err := strconv.ParseFloat(t, 64) + if err != nil { + return nil + } + return &f +} + +// stripUnderscores validates CPython's numeric-literal underscore rule — every +// "_" must sit BETWEEN two ASCII digits — and returns the string with the +// underscores removed. ok is false when the rule is violated, which is the +// ValueError CPython raises. +func stripUnderscores(s string) (string, bool) { + if !strings.ContainsRune(s, '_') { + return s, true + } + b := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + if s[i] != '_' { + b = append(b, s[i]) + continue + } + if i == 0 || i == len(s)-1 || !isASCIIDigit(s[i-1]) || !isASCIIDigit(s[i+1]) { + return "", false + } + } + return string(b), true +} + +func isASCIIDigit(c byte) bool { return c >= '0' && c <= '9' } + +// parseFileLine ports _parse_file_line: +// +// s = s.strip() +// if ":" in s: +// idx = s.rfind(":") +// path = s[:idx] +// line = _parse_int(s[idx + 1 :], 0) +// if line > 0: +// return path, line +// return s, 0 +// +// Python parity: three details survive verbatim. (1) The LAST colon splits, so +// "a:b:12" is path "a:b" line 12 and a Windows-style "C:/x.py:9" still parses. +// (2) A non-positive line number makes the whole string the path, colon +// included ("src/x.py:0" -> path "src/x.py:0", line 0). (3) Only the WHOLE +// string is stripped, never the split halves, so " a.py : 4 " yields the path +// "a.py " with its trailing space intact. +func parseFileLine(s string) (string, int) { + t := pyStrip(s) + if idx := strings.LastIndexByte(t, ':'); idx >= 0 { + path := t[:idx] + line := parseInt(t[idx+1:], 0) + if line > 0 { + return path, line + } + } + return t, 0 +} + +// isNA ports _is_na — the "the model had nothing to say here" sentinel test +// applied to optional fields before they become nil. +func isNA(s string) bool { + switch pyStrip(strings.ToLower(s)) { + case "", "na", "n/a", "none", "unknown": + return true + } + return false +} + +// --------------------------------------------------------------------------- +// small optional-field helpers +// --------------------------------------------------------------------------- + +// strOrNil ports the `parts[i] or None` idiom: "" becomes nil, anything else a +// pointer to the value. +func strOrNil(s string) *string { + if s == "" { + return nil + } + return &s +} + +// nilIfNA ports the `None if _is_na(parts[i]) else parts[i]` idiom. +func nilIfNA(s string) *string { + if isNA(s) { + return nil + } + return &s +} + +// boolOrFalse ports the `_parse_bool(x) or False` idiom, where Python's `or` +// collapses BOTH None and False to False. +func boolOrFalse(b *bool) bool { return b != nil && *b } + +// splitCSV ports the `[p.strip() for p in field.split(",") if p.strip()]` +// idiom: comma-separated, stripped, empties dropped. Always non-nil so it +// serializes as `[]` like a pydantic default_factory=list field. +func splitCSV(s string) []string { + out := []string{} + for _, p := range strings.Split(s, ",") { + if v := pyStrip(p); v != "" { + out = append(out, v) + } + } + return out +} + +// --------------------------------------------------------------------------- +// parse_architecture_raw +// --------------------------------------------------------------------------- + +// ParseArchitectureRaw ports _parsers.parse_architecture_raw. +// +// Row formats (from the ArchitectureMapRaw field descriptions): +// +// modules name | path | language | description +// entry_points kind | route_or_id | file_path:line | auth_required +// trust_bounds name | source_zone | target_zone | description +// services name | type | endpoint | auth_mechanism +// api_endpoints method | path | handler | file_path:line | auth_required | rate_limited +// +// Python parity: EntryPoint.route is derived, not parsed — the identifier is +// reused as the route IFF it contains a "/", so "POST /api/login" becomes a +// route while "migrate" does not. EntryPoint.method, Module.dependencies, +// TrustBoundary.enforcement and Service.purpose are never populated here; they +// keep their pydantic defaults. +func ParseArchitectureRaw(raw schemas.ArchitectureMapRaw) schemas.ArchitectureMap { + modules := make([]schemas.Module, 0, len(raw.Modules)) + for _, entry := range raw.Modules { + parts := splitPipe(entry, 4) + m := schemas.NewModule() + m.Name = parts[0] + m.Path = parts[1] + m.Language = parts[2] + m.Description = strOrNil(parts[3]) + modules = append(modules, m) + } + + entryPoints := make([]schemas.EntryPoint, 0, len(raw.EntryPoints)) + for _, entry := range raw.EntryPoints { + parts := splitPipe(entry, 4) + filePath, line := parseFileLine(parts[2]) + ident := parts[1] + var route *string + if strings.Contains(ident, "/") { + r := ident + route = &r + } + entryPoints = append(entryPoints, schemas.EntryPoint{ + Kind: parts[0], + Identifier: ident, + FilePath: filePath, + Line: line, + Route: route, + AuthRequired: parseBool(parts[3]), + }) + } + + trustBoundaries := make([]schemas.TrustBoundary, 0, len(raw.TrustBoundaries)) + for _, entry := range raw.TrustBoundaries { + parts := splitPipe(entry, 4) + tb := schemas.NewTrustBoundary() + tb.Name = parts[0] + tb.SourceZone = parts[1] + tb.TargetZone = parts[2] + tb.Description = parts[3] + trustBoundaries = append(trustBoundaries, tb) + } + + services := make([]schemas.Service, 0, len(raw.Services)) + for _, entry := range raw.Services { + parts := splitPipe(entry, 4) + services = append(services, schemas.Service{ + Name: parts[0], + ServiceType: parts[1], + Endpoint: nilIfNA(parts[2]), + AuthMechanism: nilIfNA(parts[3]), + }) + } + + apiEndpoints := make([]schemas.APIEndpoint, 0, len(raw.APIEndpoints)) + for _, entry := range raw.APIEndpoints { + parts := splitPipe(entry, 6) + filePath, line := parseFileLine(parts[3]) + apiEndpoints = append(apiEndpoints, schemas.APIEndpoint{ + Method: parts[0], + Path: parts[1], + Handler: parts[2], + FilePath: filePath, + Line: line, + AuthRequired: parseBool(parts[4]), + RateLimited: parseBool(parts[5]), + }) + } + + // Python parity: ArchitectureMapRaw.app_type is a plain `str` (default + // "unknown") while ArchitectureMap.app_type is `str | None`, so the pointer + // is ALWAYS non-nil after a parse — never null, even for an empty string. + appType := raw.AppType + return schemas.ArchitectureMap{ + AppType: &appType, + Modules: modules, + EntryPoints: entryPoints, + TrustBoundaries: trustBoundaries, + Services: services, + APISurface: apiEndpoints, + } +} + +// --------------------------------------------------------------------------- +// parse_data_flow_raw +// --------------------------------------------------------------------------- + +// ParseDataFlowRaw ports _parsers.parse_data_flow_raw. +// +// Row formats: +// +// flows source | sink | sanitized | file1, file2, ... +// sanitization_points file_path:line | function_name | type | protects_against +// sinks sink_type | file_path:line | function_name | notes +// +// Python parity: `sanitized=_parse_bool(parts[2]) or False` collapses an +// UNPARSEABLE value to false, so an ambiguous row is treated as unsanitized — +// the conservative direction for a security tool. DataFlow.path is never +// populated here and keeps its `[]` default. +func ParseDataFlowRaw(raw schemas.DataFlowMapRaw) schemas.DataFlowMap { + flows := make([]schemas.DataFlow, 0, len(raw.Flows)) + for _, entry := range raw.Flows { + parts := splitPipe(entry, 4) + f := schemas.NewDataFlow() + f.Source = parts[0] + f.Sink = parts[1] + f.Sanitized = boolOrFalse(parseBool(parts[2])) + f.Files = splitCSV(parts[3]) + flows = append(flows, f) + } + + sanitizationPoints := make([]schemas.SanitizationPoint, 0, len(raw.SanitizationPoints)) + for _, entry := range raw.SanitizationPoints { + parts := splitPipe(entry, 4) + filePath, line := parseFileLine(parts[0]) + sp := schemas.NewSanitizationPoint() + sp.FilePath = filePath + sp.Line = line + sp.FunctionName = strOrNil(parts[1]) + sp.SanitizationType = parts[2] + sp.ProtectsAgainst = splitCSV(parts[3]) + sanitizationPoints = append(sanitizationPoints, sp) + } + + sinks := make([]schemas.Sink, 0, len(raw.Sinks)) + for _, entry := range raw.Sinks { + parts := splitPipe(entry, 4) + filePath, line := parseFileLine(parts[1]) + sinks = append(sinks, schemas.Sink{ + SinkType: parts[0], + FilePath: filePath, + Line: line, + FunctionName: strOrNil(parts[2]), + ExploitabilityNotes: strOrNil(parts[3]), + }) + } + + return schemas.DataFlowMap{Flows: flows, SanitizationPoints: sanitizationPoints, Sinks: sinks} +} + +// --------------------------------------------------------------------------- +// parse_dependency_report_raw +// --------------------------------------------------------------------------- + +// ParseDependencyReportRaw ports _parsers.parse_dependency_report_raw. +// +// Row formats: +// +// sbom name | version | ecosystem | direct | license +// known_cves cve_id | package | installed_version | fixed_version | cvss_score | direct | reachable +// outdated package | current_version | latest_version | direct +// +// Python parity: direct_count / transitive_count are derived ONLY from the sbom +// rows (`if is_direct: direct_count += 1 else: transitive_count += 1`), so +// every sbom row lands in exactly one bucket and an unparseable `direct` field +// counts as transitive. KnownCVE.epss_score is never populated here. +func ParseDependencyReportRaw(raw schemas.DependencyReportRaw) schemas.DependencyReport { + sbom := make([]schemas.Dependency, 0, len(raw.Sbom)) + directCount, transitiveCount := 0, 0 + for _, entry := range raw.Sbom { + parts := splitPipe(entry, 5) + isDirect := boolOrFalse(parseBool(parts[3])) + if isDirect { + directCount++ + } else { + transitiveCount++ + } + sbom = append(sbom, schemas.Dependency{ + Name: parts[0], + Version: parts[1], + Ecosystem: parts[2], + Direct: isDirect, + License: nilIfNA(parts[4]), + }) + } + + knownCves := make([]schemas.KnownCVE, 0, len(raw.KnownCves)) + for _, entry := range raw.KnownCves { + parts := splitPipe(entry, 7) + knownCves = append(knownCves, schemas.KnownCVE{ + CveID: parts[0], + Package: parts[1], + InstalledVersion: parts[2], + FixedVersion: nilIfNA(parts[3]), + CvssV4Score: parseFloat(parts[4]), + Direct: boolOrFalse(parseBool(parts[5])), + Reachable: parseBool(parts[6]), + }) + } + + outdated := make([]schemas.OutdatedDep, 0, len(raw.Outdated)) + for _, entry := range raw.Outdated { + parts := splitPipe(entry, 4) + outdated = append(outdated, schemas.OutdatedDep{ + Package: parts[0], + CurrentVersion: parts[1], + LatestVersion: parts[2], + Direct: boolOrFalse(parseBool(parts[3])), + }) + } + + return schemas.DependencyReport{ + Sbom: sbom, + KnownCves: knownCves, + Outdated: outdated, + DirectCount: directCount, + TransitiveCount: transitiveCount, + } +} + +// --------------------------------------------------------------------------- +// parse_config_report_raw +// --------------------------------------------------------------------------- + +// ParseConfigReportRaw ports _parsers.parse_config_report_raw. +// +// Row formats: +// +// secrets type | file_path:line | match_preview | confidence | is_test +// misconfigs category | file_path:line | key | risk | remediation +// +// Python parity: SecretFinding.confidence falls back to the literal "medium" +// when the row leaves it empty, while MisconfigFinding.line is `int | None` and +// becomes nil (not 0) when no positive line was parsed — the two models spell +// "unknown" differently and the port keeps both spellings. +// MisconfigFinding.value is never populated here. +// +// Both models mint a fresh uuid4 `id` per row (schemas.NewSecretFinding / +// NewMisconfigFinding), so repeated parses of identical input are NOT equal — +// same as Python. +func ParseConfigReportRaw(raw schemas.ConfigReportRaw) schemas.ConfigReport { + secrets := make([]schemas.SecretFinding, 0, len(raw.Secrets)) + for _, entry := range raw.Secrets { + parts := splitPipe(entry, 5) + filePath, line := parseFileLine(parts[1]) + s := schemas.NewSecretFinding() + s.SecretType = parts[0] + s.FilePath = filePath + s.Line = line + s.Match = parts[2] + s.Confidence = parts[3] + if s.Confidence == "" { + s.Confidence = "medium" + } + s.IsTestValue = parseBool(parts[4]) + secrets = append(secrets, s) + } + + misconfigs := make([]schemas.MisconfigFinding, 0, len(raw.Misconfigs)) + for _, entry := range raw.Misconfigs { + parts := splitPipe(entry, 5) + filePath, line := parseFileLine(parts[1]) + m := schemas.NewMisconfigFinding() + m.Category = parts[0] + m.FilePath = filePath + if line > 0 { + l := line + m.Line = &l + } + m.Key = nilIfNA(parts[2]) + m.Risk = parts[3] + m.Remediation = nilIfNA(parts[4]) + misconfigs = append(misconfigs, m) + } + + return schemas.ConfigReport{Secrets: secrets, Misconfigs: misconfigs} +} + +// --------------------------------------------------------------------------- +// parse_security_context_raw +// --------------------------------------------------------------------------- + +// headerTerms / deployTerms port _parsers._HEADER_TERMS / _DEPLOY_TERMS — the +// substring tables that bucket a free-form security signal into one of +// SecurityContext's three signal lists. +var ( + headerTerms = []string{"header", "csp", "hsts", "x-frame", "x-content-type", "cors"} + deployTerms = []string{"deploy", "docker", "kubernetes", "cloud", "ssl", "tls", "https", "container", "k8s"} +) + +// ParseSecurityContextRaw ports _parsers.parse_security_context_raw. +// +// crypto_usage rows are "algorithm | key_size | mode | usage_context | is_weak". +// security_signals are unstructured one-liners routed into +// security_headers / deployment_signals / framework_security by substring +// match, first-match-wins in that order (headers beat deployment beats the +// framework catch-all). Order WITHIN each bucket is the model's emission order. +// +// Python parity: key_size uses `_parse_int(parts[1]) if not _is_na(parts[1]) +// else None`, so a non-numeric-but-not-NA value like "notanint" yields 0 rather +// than nil — the `_parse_int` default leaks through. +func ParseSecurityContextRaw(raw schemas.SecurityContextRaw) schemas.SecurityContext { + cryptoUsage := make([]schemas.CryptoUsage, 0, len(raw.CryptoUsage)) + for _, entry := range raw.CryptoUsage { + parts := splitPipe(entry, 5) + var keySize *int + if !isNA(parts[1]) { + k := parseInt(parts[1], 0) + keySize = &k + } + cryptoUsage = append(cryptoUsage, schemas.CryptoUsage{ + Algorithm: parts[0], + KeySize: keySize, + Mode: nilIfNA(parts[2]), + UsageContext: nilIfNA(parts[3]), + IsWeak: parseBool(parts[4]), + }) + } + + frameworkSecurity := []string{} + securityHeaders := []string{} + deploymentSignals := []string{} + for _, signal := range raw.SecuritySignals { + lowered := strings.ToLower(signal) + switch { + case containsAny(lowered, headerTerms): + securityHeaders = append(securityHeaders, signal) + case containsAny(lowered, deployTerms): + deploymentSignals = append(deploymentSignals, signal) + default: + frameworkSecurity = append(frameworkSecurity, signal) + } + } + + return schemas.SecurityContext{ + AuthModel: raw.AuthModel, + AuthDetails: raw.AuthDetails, + CryptoUsage: cryptoUsage, + FrameworkSecurity: frameworkSecurity, + SecurityHeaders: securityHeaders, + DeploymentSignals: deploymentSignals, + } +} + +// containsAny ports `any(term in lowered for term in TERMS)`. +func containsAny(s string, terms []string) bool { + for _, t := range terms { + if strings.Contains(s, t) { + return true + } + } + return false +} diff --git a/go/internal/agents/recon/parsers_test.go b/go/internal/agents/recon/parsers_test.go new file mode 100644 index 0000000..2e5ff63 --- /dev/null +++ b/go/internal/agents/recon/parsers_test.go @@ -0,0 +1,288 @@ +package recon + +// Validation contract for src/sec_af/agents/recon/_parsers.py, checked against +// fixtures produced by the REAL Python helpers (go/scripts/gen_golden.py): +// +// - splitPipe caps the field count, keeping surplus pipes in the last field, +// strips each field, and right-pads short rows with "". +// - parseBool/parseInt/parseFloat/parseFileLine/isNA reproduce CPython's +// coercions exactly, including the underscore digit separator, the +// rfind(":") split and the "non-positive line means no line" rule. +// - Each parse_*_raw turns a flat *Raw model into the structured model with +// the same field values Python produces, byte for byte after JSON encoding. + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// primitivesGolden mirrors testdata/golden/parse_primitives.json. +type primitivesGolden struct { + SplitPipe []struct { + S string `json:"s"` + Expected int `json:"expected"` + Want []string `json:"want"` + } `json:"split_pipe"` + ParseBool []struct { + S string `json:"s"` + Want *bool `json:"want"` + } `json:"parse_bool"` + ParseInt []struct { + S string `json:"s"` + Want int `json:"want"` + } `json:"parse_int"` + ParseIntDefault9 []struct { + S string `json:"s"` + Want int `json:"want"` + } `json:"parse_int_default9"` + ParseFloat []struct { + S string `json:"s"` + Want *string `json:"want"` // Python repr(), or null + } `json:"parse_float"` + ParseFileLine []struct { + S string `json:"s"` + Path string `json:"path"` + Line int `json:"line"` + } `json:"parse_file_line"` + IsNA []struct { + S string `json:"s"` + Want bool `json:"want"` + } `json:"is_na"` +} + +// TestParsePrimitives ports the behavior of _split_pipe, _parse_bool, +// _parse_int, _parse_float, _parse_file_line and _is_na. +func TestParsePrimitives(t *testing.T) { + var g primitivesGolden + goldenJSON(t, "parse_primitives", &g) + + t.Run("split_pipe", func(t *testing.T) { + if len(g.SplitPipe) == 0 { + t.Fatal("golden has no split_pipe cases") + } + for _, c := range g.SplitPipe { + got := splitPipe(c.S, c.Expected) + if !reflect.DeepEqual(got, c.Want) { + t.Errorf("splitPipe(%q, %d) = %q, want %q", c.S, c.Expected, got, c.Want) + } + } + }) + + t.Run("parse_bool", func(t *testing.T) { + for _, c := range g.ParseBool { + got := parseBool(c.S) + if !equalBoolPtr(got, c.Want) { + t.Errorf("parseBool(%q) = %s, want %s", c.S, fmtBoolPtr(got), fmtBoolPtr(c.Want)) + } + } + }) + + t.Run("parse_int", func(t *testing.T) { + for _, c := range g.ParseInt { + if got := parseInt(c.S, 0); got != c.Want { + t.Errorf("parseInt(%q, 0) = %d, want %d", c.S, got, c.Want) + } + } + for _, c := range g.ParseIntDefault9 { + if got := parseInt(c.S, 9); got != c.Want { + t.Errorf("parseInt(%q, 9) = %d, want %d", c.S, got, c.Want) + } + } + }) + + t.Run("parse_float", func(t *testing.T) { + for _, c := range g.ParseFloat { + got := parseFloat(c.S) + switch { + case c.Want == nil && got != nil: + t.Errorf("parseFloat(%q) = %v, want nil", c.S, *got) + case c.Want != nil && got == nil: + t.Errorf("parseFloat(%q) = nil, want %s", c.S, *c.Want) + case c.Want != nil && got != nil: + // Compared through pyfmt.FormatFloat (an exact port of Python's + // str(float)) so inf/nan compare by spelling instead of by an + // equality operator that NaN fails. + if spelled := pyfmt.FormatFloat(*got); spelled != *c.Want { + t.Errorf("parseFloat(%q) = %s, want %s", c.S, spelled, *c.Want) + } + } + } + }) + + t.Run("parse_file_line", func(t *testing.T) { + for _, c := range g.ParseFileLine { + path, line := parseFileLine(c.S) + if path != c.Path || line != c.Line { + t.Errorf("parseFileLine(%q) = (%q, %d), want (%q, %d)", c.S, path, line, c.Path, c.Line) + } + } + }) + + t.Run("is_na", func(t *testing.T) { + for _, c := range g.IsNA { + if got := isNA(c.S); got != c.Want { + t.Errorf("isNA(%q) = %v, want %v", c.S, got, c.Want) + } + } + }) +} + +// parseGolden is the {input, want} envelope every parse_.json uses. +type parseGolden struct { + Input json.RawMessage `json:"input"` + Want any `json:"want"` +} + +// runParserGolden decodes the golden's flat *Raw input into T, runs parse, and +// compares the JSON shape of the result with Python's model_dump(). +func runParserGolden[Raw any, Out any](t *testing.T, name string, parse func(Raw) Out) { + t.Helper() + var g parseGolden + goldenJSON(t, name, &g) + + var raw Raw + if err := json.Unmarshal(g.Input, &raw); err != nil { + t.Fatalf("%s: decode input into %T: %v", name, raw, err) + } + + got := scrubIDs(jsonTree(t, parse(raw))) + if !reflect.DeepEqual(got, g.Want) { + t.Errorf("%s mismatch with Python%s", name, diffJSON(t, got, g.Want)) + } +} + +// TestParseArchitectureRaw ports parse_architecture_raw. +func TestParseArchitectureRaw(t *testing.T) { + runParserGolden(t, "parse_architecture", ParseArchitectureRaw) +} + +// TestParseDataFlowRaw ports parse_data_flow_raw. +func TestParseDataFlowRaw(t *testing.T) { + runParserGolden(t, "parse_data_flow", ParseDataFlowRaw) +} + +// TestParseDependencyReportRaw ports parse_dependency_report_raw. +func TestParseDependencyReportRaw(t *testing.T) { + runParserGolden(t, "parse_dependency_report", ParseDependencyReportRaw) +} + +// TestParseConfigReportRaw ports parse_config_report_raw. +func TestParseConfigReportRaw(t *testing.T) { + runParserGolden(t, "parse_config_report", ParseConfigReportRaw) +} + +// TestParseSecurityContextRaw ports parse_security_context_raw. +func TestParseSecurityContextRaw(t *testing.T) { + runParserGolden(t, "parse_security_context", ParseSecurityContextRaw) +} + +// TestParsersEmitEmptyListsNotNull pins that every list a parser produces is +// non-nil, so it serializes as `[]` like a pydantic default_factory=list field +// and never as `null`. The orchestrator round-trips these models through the +// control plane as JSON, so a null where Python sends [] would break the +// receiving model_validate. +func TestParsersEmitEmptyListsNotNull(t *testing.T) { + arch := ParseArchitectureRaw(schemas.NewArchitectureMapRaw()) + df := ParseDataFlowRaw(schemas.NewDataFlowMapRaw()) + dep := ParseDependencyReportRaw(schemas.NewDependencyReportRaw()) + cfg := ParseConfigReportRaw(schemas.NewConfigReportRaw()) + sec := ParseSecurityContextRaw(schemas.NewSecurityContextRaw()) + + for _, tc := range []struct { + name string + v any + want string + }{ + {"ArchitectureMap", arch, `{"app_type":"unknown","modules":[],"entry_points":[],"trust_boundaries":[],"services":[],"api_surface":[]}`}, + {"DataFlowMap", df, `{"flows":[],"sanitization_points":[],"sinks":[]}`}, + {"DependencyReport", dep, `{"sbom":[],"known_cves":[],"outdated":[],"direct_count":0,"transitive_count":0}`}, + {"ConfigReport", cfg, `{"secrets":[],"misconfigs":[]}`}, + {"SecurityContext", sec, `{"auth_model":"","auth_details":"","crypto_usage":[],"framework_security":[],"security_headers":[],"deployment_signals":[]}`}, + } { + b, err := json.Marshal(tc.v) + if err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if string(b) != tc.want { + t.Errorf("%s = %s, want %s", tc.name, b, tc.want) + } + } +} + +// TestParseArchitectureRawRouteDerivation pins the EntryPoint.route rule, which +// is derived rather than parsed: the identifier doubles as the route only when +// it contains a "/". +func TestParseArchitectureRawRouteDerivation(t *testing.T) { + raw := schemas.NewArchitectureMapRaw() + raw.EntryPoints = []string{ + "http | POST /api/login | src/routes.py:42 | false", + "cli | migrate | src/cli.py:8 | true", + } + got := ParseArchitectureRaw(raw) + + if got.EntryPoints[0].Route == nil || *got.EntryPoints[0].Route != "POST /api/login" { + t.Errorf("route for an identifier containing '/' = %v, want the identifier", got.EntryPoints[0].Route) + } + if got.EntryPoints[1].Route != nil { + t.Errorf("route for an identifier without '/' = %q, want nil", *got.EntryPoints[1].Route) + } + // method is never populated by the parser; it keeps the pydantic default. + if got.EntryPoints[0].Method != nil { + t.Errorf("method = %q, want nil (never parsed)", *got.EntryPoints[0].Method) + } +} + +// TestParseDependencyReportRawCounts pins that direct/transitive counts come +// only from the sbom rows and that an unparseable `direct` flag counts as +// transitive. +func TestParseDependencyReportRawCounts(t *testing.T) { + raw := schemas.NewDependencyReportRaw() + raw.Sbom = []string{ + "a | 1 | pypi | true | MIT", + "b | 1 | pypi | false | MIT", + "c | 1 | pypi | garbage | MIT", + } + // known_cves rows carry their own `direct` flag but must not move the + // counters. + raw.KnownCves = []string{"CVE-1 | a | 1 | 2 | 9.8 | true | true"} + + got := ParseDependencyReportRaw(raw) + if got.DirectCount != 1 || got.TransitiveCount != 2 { + t.Errorf("counts = (direct %d, transitive %d), want (1, 2)", got.DirectCount, got.TransitiveCount) + } +} + +// TestSecuritySignalBucketingIsFirstMatchWins pins the ordering of the two +// substring tables: a signal matching BOTH a header term and a deployment term +// lands in security_headers, because the header test runs first. +func TestSecuritySignalBucketingIsFirstMatchWins(t *testing.T) { + raw := schemas.NewSecurityContextRaw() + raw.SecuritySignals = []string{"HSTS header set by the Docker ingress"} + + got := ParseSecurityContextRaw(raw) + if len(got.SecurityHeaders) != 1 || len(got.DeploymentSignals) != 0 || len(got.FrameworkSecurity) != 0 { + t.Errorf("buckets = headers %v, deployment %v, framework %v; want the signal in headers only", + got.SecurityHeaders, got.DeploymentSignals, got.FrameworkSecurity) + } +} + +func equalBoolPtr(a, b *bool) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + +func fmtBoolPtr(b *bool) string { + if b == nil { + return "nil" + } + if *b { + return "true" + } + return "false" +} diff --git a/go/internal/agents/recon/prompts_test.go b/go/internal/agents/recon/prompts_test.go new file mode 100644 index 0000000..d21789d --- /dev/null +++ b/go/internal/agents/recon/prompts_test.go @@ -0,0 +1,177 @@ +package recon + +// Validation contract for the five RECON prompt builders and for +// architecture_context_block: +// +// - Each mapper's prompt is its template followed by a literal CONTEXT block +// naming the repository path. The three repo-only mappers share one suffix +// ("start by listing files"); the two architecture-aware mappers share a +// different one ("take multiple turns to explore"). +// - The architecture-aware templates have {{ARCHITECTURE_MAP_JSON}} replaced +// by json.dumps(architecture.model_dump(), indent=2) BEFORE the suffix is +// appended. +// - Every byte of all of the above reaches the LLM, so all of it is compared +// against fixtures captured from the real Python builders. + +import ( + "strings" + "testing" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// fixtureRepo is the stable repository path gen_golden.py interpolates. +const fixtureRepo = "/fixtures/demo-repo" + +// archRich mirrors gen_golden.py's arch_rich(): every optional field +// populated, plus the characters where CPython's json.dumps and Go's +// encoding/json disagree — `<`, `>` and `&` (Go escapes them, CPython does +// not) and non-ASCII (CPython escapes as \uXXXX, Go does not). +func archRich() schemas.ArchitectureMap { + str := func(s string) *string { return &s } + b := func(v bool) *bool { return &v } + + return schemas.ArchitectureMap{ + AppType: str("web_api"), + Modules: []schemas.Module{ + {Name: "auth", Path: "src/auth/", Language: "Python", Description: str("Sessions & tokens"), Dependencies: []string{"db", "cache"}}, + {Name: "ui", Path: "web/", Language: "TypeScript", Description: nil, Dependencies: []string{}}, + }, + EntryPoints: []schemas.EntryPoint{ + {Kind: "http", Identifier: "POST /api/login", FilePath: "src/routes.py", Line: 42, Method: str("POST"), Route: str("/api/login"), AuthRequired: b(false)}, + {Kind: "cli", Identifier: "migrate", FilePath: "src/cli.py", Line: 8}, + }, + TrustBoundaries: []schemas.TrustBoundary{ + {Name: "API Gateway", SourceZone: "external", TargetZone: "internal", Description: "Rate limiting auth — café → app", Enforcement: []string{"waf"}}, + }, + Services: []schemas.Service{ + {Name: "PostgreSQL", ServiceType: "database", Endpoint: str("localhost:5432"), Purpose: str("primary store"), AuthMechanism: str("password")}, + }, + APISurface: []schemas.APIEndpoint{ + {Method: "GET", Path: "/api/users", Handler: "get_users", FilePath: "src/api.py", Line: 15, AuthRequired: b(true), RateLimited: b(false)}, + }, + } +} + +// archEmpty mirrors gen_golden.py's arch_empty() — `ArchitectureMap()`, i.e. +// every pydantic default. +func archEmpty() schemas.ArchitectureMap { return schemas.NewArchitectureMap() } + +// TestArchitectureContextBlock pins architecture_context_block against +// CPython's json.dumps(model_dump(), indent=2). +func TestArchitectureContextBlock(t *testing.T) { + for _, tc := range []struct { + name string + arch schemas.ArchitectureMap + golden string + }{ + {"rich", archRich(), "architecture_context_block_A"}, + {"empty", archEmpty(), "architecture_context_block_B"}, + } { + t.Run(tc.name, func(t *testing.T) { + got := ArchitectureContextBlock(tc.arch) + want := goldenText(t, tc.golden) + if got != want { + t.Errorf("ArchitectureContextBlock mismatch\n--- got ---\n%s\n--- want (python) ---\n%s", got, want) + } + }) + } +} + +// TestArchitectureContextBlockNilSliceRendering pins the one documented place +// where pyfmt.Dumps and CPython diverge, so a future reader knows it is +// deliberate: a nil Go slice renders as `null`, whereas pydantic's +// `Field(default_factory=list)` always dumps as `[]`. +// +// The divergence is unreachable through the pipeline — every ArchitectureMap +// here comes from ParseArchitectureRaw (non-nil slices) or from JSON via +// ArchitectureMap.UnmarshalJSON (seeds the `[]` defaults) — so the test asserts +// BOTH: the seeded value matches Python's ArchitectureMap(), and the bare Go +// literal does not. +func TestArchitectureContextBlockNilSliceRendering(t *testing.T) { + pythonDefaults := goldenText(t, "architecture_context_block_B") + + if got := ArchitectureContextBlock(schemas.NewArchitectureMap()); got != pythonDefaults { + t.Errorf("NewArchitectureMap() block mismatch\n--- got ---\n%s\n--- want (python ArchitectureMap()) ---\n%s", + got, pythonDefaults) + } + + bare := ArchitectureContextBlock(schemas.ArchitectureMap{}) + if bare == pythonDefaults { + t.Fatal("a bare ArchitectureMap{} now renders like the pydantic defaults; " + + "pyfmt.Dumps' nil-slice rule changed and this test's premise is stale") + } + if !strings.Contains(bare, `"modules": null`) { + t.Errorf("bare ArchitectureMap{} should render nil slices as null, got:\n%s", bare) + } +} + +// TestReconPrompts pins every prompt string the five mappers hand to +// app.harness. +func TestReconPrompts(t *testing.T) { + for _, tc := range []struct { + name string + got string + golden string + }{ + {"architecture", architecturePrompt(fixtureRepo), "architecture_prompt"}, + {"dependencies", dependenciesPrompt(fixtureRepo), "dependencies_prompt"}, + {"config_scanner", configScannerPrompt(fixtureRepo), "config_scanner_prompt"}, + {"data_flow/rich", dataFlowPrompt(fixtureRepo, archRich()), "data_flow_prompt_A"}, + {"data_flow/empty", dataFlowPrompt(fixtureRepo, archEmpty()), "data_flow_prompt_B"}, + {"security_context/rich", securityContextPrompt(fixtureRepo, archRich()), "security_context_prompt_A"}, + {"security_context/empty", securityContextPrompt(fixtureRepo, archEmpty()), "security_context_prompt_B"}, + } { + t.Run(tc.name, func(t *testing.T) { + want := goldenText(t, tc.golden) + if tc.got != want { + t.Errorf("prompt mismatch (%d bytes vs %d)\n--- got ---\n%s\n--- want (python) ---\n%s", + len(tc.got), len(want), tc.got, want) + } + }) + } +} + +// TestPromptsSubstitutePlaceholder pins that no {{ARCHITECTURE_MAP_JSON}} +// token survives into a prompt, and that the substituted block is really the +// context block. +func TestPromptsSubstitutePlaceholder(t *testing.T) { + arch := archRich() + block := ArchitectureContextBlock(arch) + for _, tc := range []struct { + name string + prompt string + }{ + {"data_flow", dataFlowPrompt(fixtureRepo, arch)}, + {"security_context", securityContextPrompt(fixtureRepo, arch)}, + } { + t.Run(tc.name, func(t *testing.T) { + if strings.Contains(tc.prompt, architectureMapPlaceholder) { + t.Errorf("prompt still contains %s", architectureMapPlaceholder) + } + if !strings.Contains(tc.prompt, block) { + t.Error("prompt does not contain the architecture context block") + } + }) + } +} + +// TestContextSuffixes pins the two literal CONTEXT blocks, which are the only +// part of a RECON prompt the Go code composes rather than loads. +func TestContextSuffixes(t *testing.T) { + wantListing := "\n\nCONTEXT:\n" + + "- Repository path: /repo\n" + + "- Start by listing files in the repository path above.\n" + + "- After gathering evidence, write the JSON output file using your Write tool." + if got := fileListingContextSuffix("/repo"); got != wantListing { + t.Errorf("fileListingContextSuffix = %q, want %q", got, wantListing) + } + + wantExploration := "\n\nCONTEXT:\n" + + "- Repository path: /repo\n" + + "- Take multiple turns to explore the codebase first, then build your analysis.\n" + + "- Write final JSON only when analysis is complete." + if got := explorationContextSuffix("/repo"); got != wantExploration { + t.Errorf("explorationContextSuffix = %q, want %q", got, wantExploration) + } +} diff --git a/go/internal/agents/recon/recon.go b/go/internal/agents/recon/recon.go new file mode 100644 index 0000000..50aa8ea --- /dev/null +++ b/go/internal/agents/recon/recon.go @@ -0,0 +1,253 @@ +package recon + +// Ports the orchestration half of src/sec_af/agents/recon/__init__.py: +// _normalize_depth, _quick_defaults, run_recon, run_fast_recon, run_deep_recon. + +import ( + "context" + "sort" + "strings" + "time" + + "golang.org/x/sync/errgroup" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/config" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// QuickDefaults ports _quick_defaults — the placeholder pair the QUICK depth +// profile substitutes for the two expensive architecture-aware mappers. +// +// Python parity: `SecurityContext(auth_model="unknown", auth_details="unknown")` +// uses the literal "unknown" for BOTH fields. That differs from the +// `{"auth_model": "unknown", "auth_details": ""}` default that +// reasoners/phases.py `_recon_model` seeds; do not conflate them. +func QuickDefaults() (schemas.DataFlowMap, schemas.SecurityContext) { + dataFlows := schemas.NewDataFlowMap() + securityContext := schemas.NewSecurityContext() + securityContext.AuthModel = "unknown" + securityContext.AuthDetails = "unknown" + return dataFlows, securityContext +} + +// runBaseMappers runs the three repo-only mappers concurrently, which is +// Python's +// +// architecture, dependencies, config = await asyncio.gather( +// run_architecture_mapper(app, repo_path), +// run_dependency_auditor(app, repo_path), +// run_config_scanner(app, repo_path), +// ) +// +// Concurrency parity, and its one deliberate difference: +// +// - errgroup.Group is used WITHOUT WithContext, so a failing mapper does not +// cancel its siblings — matching asyncio.gather(return_exceptions=False), +// which never cancels the other awaitables either. +// - Wait() returns the FIRST error by completion time (errgroup guards the +// slot with a sync.Once), which is the same error asyncio.gather surfaces. +// - DIFFERENCE: Wait() blocks until all three goroutines have finished, while +// `await gather(...)` resumes the caller as soon as the first exception +// fires and leaves the rest running detached. The value returned is +// identical; only the moment of return differs, and the caller's next act +// on error is to propagate a 500 either way. +func runBaseMappers(ctx context.Context, app appx.Harnesser, repoPath string) ( + schemas.ArchitectureMap, schemas.DependencyReport, schemas.ConfigReport, error, +) { + var ( + architecture schemas.ArchitectureMap + dependencies schemas.DependencyReport + configReport schemas.ConfigReport + ) + var g errgroup.Group + g.Go(func() error { + var err error + architecture, err = RunArchitectureMapper(ctx, app, repoPath) + return err + }) + g.Go(func() error { + var err error + dependencies, err = RunDependencyAuditor(ctx, app, repoPath) + return err + }) + g.Go(func() error { + var err error + configReport, err = RunConfigScanner(ctx, app, repoPath) + return err + }) + if err := g.Wait(); err != nil { + return architecture, dependencies, configReport, err + } + return architecture, dependencies, configReport, nil +} + +// RunDeepRecon ports run_deep_recon — the second gather, over the two mappers +// that need the architecture map as input. +// +// data_flows, security_context = await asyncio.gather( +// run_data_flow_mapper(app, repo_path, architecture), +// run_security_context_profiler(app, repo_path, architecture), +// ) +// +// Same errgroup contract as runBaseMappers. +func RunDeepRecon(ctx context.Context, app appx.Harnesser, repoPath string, architecture schemas.ArchitectureMap) ( + schemas.DataFlowMap, schemas.SecurityContext, error, +) { + var ( + dataFlows schemas.DataFlowMap + securityContext schemas.SecurityContext + ) + var g errgroup.Group + g.Go(func() error { + var err error + dataFlows, err = RunDataFlowMapper(ctx, app, repoPath, architecture) + return err + }) + g.Go(func() error { + var err error + securityContext, err = RunSecurityContextProfiler(ctx, app, repoPath, architecture) + return err + }) + if err := g.Wait(); err != nil { + return dataFlows, securityContext, err + } + return dataFlows, securityContext, nil +} + +// RunRecon ports run_recon — the full RECON phase. +// +// started = time.monotonic() +// profile = _normalize_depth(depth) +// architecture, dependencies, config = await gather(3 mappers) +// if profile == QUICK: data_flows, security_context = _quick_defaults() +// else: data_flows, security_context = await gather(2 mappers) +// languages = sorted({m.language.lower() for m in architecture.modules if m.language}) +// frameworks = sorted({s for s in security_context.framework_security if s}) +// lines_of_code, file_count = _repo_metrics(repo_path) +// return ReconResult(..., recon_duration_seconds=time.monotonic() - started) +// +// Python parity: +// +// - depth is normalized LENIENTLY (config.NormalizeDepth): anything that is +// not quick/standard/thorough silently becomes standard, so only the exact +// string "quick" (any case) skips the deep mappers. +// - languages are lowercased and deduplicated; frameworks keep their original +// case. Both are sorted, so the Python `set` iteration order — the only +// nondeterminism in this function — is erased in both runtimes. +// - recon_duration_seconds is set HERE and only here; run_fast_recon leaves it +// at its 0.0 default. +func RunRecon(ctx context.Context, app appx.Harnesser, repoPath string, depth string) (schemas.ReconResult, error) { + started := time.Now() + profile := config.NormalizeDepth(depth) + + architecture, dependencies, configReport, err := runBaseMappers(ctx, app, repoPath) + if err != nil { + return schemas.ReconResult{}, err + } + + var ( + dataFlows schemas.DataFlowMap + securityContext schemas.SecurityContext + ) + if profile == config.DepthQuick { + dataFlows, securityContext = QuickDefaults() + } else { + dataFlows, securityContext, err = RunDeepRecon(ctx, app, repoPath, architecture) + if err != nil { + return schemas.ReconResult{}, err + } + } + + linesOfCode, fileCount := RepoMetrics(repoPath) + + return schemas.ReconResult{ + Architecture: architecture, + DataFlows: dataFlows, + Dependencies: dependencies, + Config: configReport, + SecurityContext: securityContext, + Languages: moduleLanguages(architecture), + Frameworks: sortedNonEmpty(securityContext.FrameworkSecurity), + LinesOfCode: linesOfCode, + FileCount: fileCount, + ReconDurationSeconds: time.Since(started).Seconds(), + }, nil +} + +// RunFastRecon ports run_fast_recon — the three cheap mappers plus the QUICK +// placeholders, used by the orchestrator's streaming path. +// +// Python parity: it differs from RunRecon(depth="quick") in exactly two ways, +// both of which look like oversights but are reproduced faithfully — +// `frameworks` is hard-coded to the empty list instead of being derived from +// the (placeholder) security context, and `recon_duration_seconds` is never +// set, so it keeps its 0.0 pydantic default. +func RunFastRecon(ctx context.Context, app appx.Harnesser, repoPath string) (schemas.ReconResult, error) { + architecture, dependencies, configReport, err := runBaseMappers(ctx, app, repoPath) + if err != nil { + return schemas.ReconResult{}, err + } + + dataFlows, securityContext := QuickDefaults() + linesOfCode, fileCount := RepoMetrics(repoPath) + + return schemas.ReconResult{ + Architecture: architecture, + DataFlows: dataFlows, + Dependencies: dependencies, + Config: configReport, + SecurityContext: securityContext, + Languages: moduleLanguages(architecture), + Frameworks: []string{}, + LinesOfCode: linesOfCode, + FileCount: fileCount, + }, nil +} + +// moduleLanguages ports +// `sorted({module.language.lower() for module in architecture.modules if getattr(module, "language", None)})`. +// +// Python parity: the `if getattr(...)` guard is a truthiness test, so a module +// whose language is the empty string is dropped BEFORE lowering — an empty +// string never appears in the result. +func moduleLanguages(architecture schemas.ArchitectureMap) []string { + seen := make(map[string]struct{}, len(architecture.Modules)) + out := []string{} + for _, module := range architecture.Modules { + if module.Language == "" { + continue + } + lang := strings.ToLower(module.Language) + if _, dup := seen[lang]; dup { + continue + } + seen[lang] = struct{}{} + out = append(out, lang) + } + sort.Strings(out) + return out +} + +// sortedNonEmpty ports `sorted({item for item in values if item})` — drop empty +// strings, deduplicate, sort. +// +// sort.Strings orders by byte, and Python's sorted() orders strings by code +// point; UTF-8 preserves code-point order under byte comparison, so the two +// agree for every input. +func sortedNonEmpty(values []string) []string { + seen := make(map[string]struct{}, len(values)) + out := []string{} + for _, v := range values { + if v == "" { + continue + } + if _, dup := seen[v]; dup { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + sort.Strings(out) + return out +} diff --git a/go/internal/agents/recon/recon_test.go b/go/internal/agents/recon/recon_test.go new file mode 100644 index 0000000..4c28d67 --- /dev/null +++ b/go/internal/agents/recon/recon_test.go @@ -0,0 +1,704 @@ +package recon + +// Validation contract for run_recon / run_fast_recon / run_deep_recon and the +// five mapper wrappers: +// +// - RECON issues exactly five harness calls at standard/thorough depth and +// exactly three at quick depth; there are no .call() reasoner invocations +// inside this package (the DAG fan-out lives in reasoners/phases). +// - The first three mappers run CONCURRENTLY (one asyncio.gather), and so do +// the two architecture-aware ones (a second gather). +// - Each mapper runs with Cwd set to a fresh private temp dir named +// `secaf--*` that is removed afterwards, and ProjectDir set to +// the repository. +// - Each mapper's harness schema is the pydantic schema of its *Raw model. +// - A mapper failure surfaces as an error naming the agent the way +// extract_harness_result names it. +// - depth normalization is lenient: only "quick" (any case) skips the deep +// mappers; anything unrecognized behaves like "standard". +// - languages are lowercased/deduplicated/sorted from the architecture's +// modules; frameworks are deduplicated/sorted from the security context — +// except in run_fast_recon, which hard-codes them empty. +// - recon_duration_seconds is set by run_recon only. + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// mapperOf identifies which RECON mapper a captured prompt belongs to, by the +// template it starts with. Returns "" for anything unrecognized. +func mapperOf(prompt string) string { + for _, m := range []struct{ name, path string }{ + {"architecture", architecturePromptPath}, + {"dependencies", dependenciesPromptPath}, + {"config_scanner", configScannerPromptPath}, + {"data_flow", dataFlowPromptPath}, + {"security_context", securityContextPromptPath}, + } { + template := prompts.MustLoad(m.path) + // The two architecture-aware templates carry a placeholder that is + // substituted before the prompt is sent, so compare on the prefix up to + // the placeholder. + if head, _, found := strings.Cut(template, architectureMapPlaceholder); found { + if strings.HasPrefix(prompt, head) { + return m.name + } + continue + } + if strings.HasPrefix(prompt, template) { + return m.name + } + } + return "" +} + +// reconGolden mirrors testdata/golden/run_recon.json — the end-to-end phase +// fixture: one canned flat harness payload per mapper, plus the ReconResult +// the REAL Python run_recon / run_fast_recon build from it. +type reconGolden struct { + RepoPath string `json:"repo_path"` + Canned map[string]json.RawMessage `json:"canned"` + Standard map[string]any `json:"standard"` + Quick map[string]any `json:"quick"` + Fast map[string]any `json:"fast"` +} + +// loadReconGolden reads the fixture once per test. +func loadReconGolden(t *testing.T) reconGolden { + t.Helper() + var g reconGolden + goldenJSON(t, "run_recon", &g) + if len(g.Canned) != 5 { + t.Fatalf("golden has %d canned mapper payloads, want 5", len(g.Canned)) + } + return g +} + +// barrier releases all its waiters once n of them have arrived, or lets them +// through individually after timeout. It turns "these calls overlap" into a +// deterministic assertion instead of a sleep-and-hope race. +type barrier struct { + mu sync.Mutex + n int + count int + ch chan struct{} +} + +func newBarrier(n int) *barrier { return &barrier{n: n, ch: make(chan struct{})} } + +func (b *barrier) wait(timeout time.Duration) { + b.mu.Lock() + b.count++ + if b.count == b.n { + close(b.ch) + } + b.mu.Unlock() + select { + case <-b.ch: + case <-time.After(timeout): + } +} + +// newReconFake builds a Fake that answers every mapper with cannedRaw. When +// bar is non-nil each harness call parks on it first, so a test can prove the +// calls really are in flight together. +func newReconFake(t *testing.T, bar *barrier) *appx.Fake { + t.Helper() + canned := loadReconGolden(t).Canned + f := &appx.Fake{} + f.HarnessFn = func(_ context.Context, prompt string, _ map[string]any, dest any, _ harness.Options) (*harness.Result, error) { + if bar != nil { + bar.wait(3 * time.Second) + } + name := mapperOf(prompt) + raw, ok := canned[name] + if !ok { + t.Errorf("harness called with an unrecognized prompt (first 120 bytes): %.120q", prompt) + return &harness.Result{IsError: true, ErrorMessage: "unrecognized prompt"}, nil + } + if err := json.Unmarshal(raw, dest); err != nil { + t.Fatalf("canned %s output does not fit its *Raw model: %v", name, err) + } + return &harness.Result{Parsed: dest, Result: string(raw)}, nil + } + return f +} + +// harnessMappers lists the mappers a Fake was asked to run, in call order. +func harnessMappers(f *appx.Fake) []string { + out := make([]string, 0, len(f.Harnesses)) + for _, h := range f.Harnesses { + out = append(out, mapperOf(h.Prompt)) + } + return out +} + +func sortedCopy(in []string) []string { + out := append([]string(nil), in...) + for i := range out { + for j := i + 1; j < len(out); j++ { + if out[j] < out[i] { + out[i], out[j] = out[j], out[i] + } + } + } + return out +} + +// TestRunReconStandardRunsAllFiveMappers pins the standard-depth shape: three +// mappers, then two more that consume the architecture. +func TestRunReconStandardRunsAllFiveMappers(t *testing.T) { + f := newReconFake(t, nil) + got, err := RunRecon(context.Background(), f, "/repo", "standard") + if err != nil { + t.Fatalf("RunRecon: %v", err) + } + + mappers := harnessMappers(f) + if len(mappers) != 5 { + t.Fatalf("harness calls = %v, want 5", mappers) + } + // The first gather's three may complete in any order; the last two are the + // architecture-aware pair and cannot start before it finishes. + first := sortedCopy(mappers[:3]) + wantFirst := []string{"architecture", "config_scanner", "dependencies"} + for i := range wantFirst { + if first[i] != wantFirst[i] { + t.Fatalf("first gather = %v, want %v", first, wantFirst) + } + } + last := sortedCopy(mappers[3:]) + if last[0] != "data_flow" || last[1] != "security_context" { + t.Fatalf("second gather = %v, want [data_flow security_context]", last) + } + + // Every mapper sees the repository as its project dir. + for i, h := range f.Harnesses { + if h.Opts.ProjectDir != "/repo" { + t.Errorf("harness[%d].ProjectDir = %q, want /repo", i, h.Opts.ProjectDir) + } + } + + // The architecture-aware prompts carry the architecture the first gather + // produced, not an empty one. + block := ArchitectureContextBlock(got.Architecture) + for _, h := range f.Harnesses[3:] { + if !strings.Contains(h.Prompt, block) { + t.Error("a deep mapper prompt does not embed the parsed architecture map") + } + } + + // No .call() reasoner invocations happen inside this package. + if len(f.Calls) != 0 { + t.Errorf("Call targets = %v, want none (the DAG fan-out lives in phases)", f.CallTargets()) + } +} + +// TestRunReconQuickSkipsDeepMappers ports the `if profile == DepthProfile.QUICK` +// branch: the two expensive mappers are replaced by _quick_defaults(). +func TestRunReconQuickSkipsDeepMappers(t *testing.T) { + f := newReconFake(t, nil) + got, err := RunRecon(context.Background(), f, "/repo", "quick") + if err != nil { + t.Fatalf("RunRecon: %v", err) + } + + mappers := sortedCopy(harnessMappers(f)) + want := []string{"architecture", "config_scanner", "dependencies"} + if len(mappers) != 3 { + t.Fatalf("harness calls = %v, want exactly the three cheap mappers", mappers) + } + for i := range want { + if mappers[i] != want[i] { + t.Fatalf("harness calls = %v, want %v", mappers, want) + } + } + + wantFlows, wantContext := QuickDefaults() + if !equalJSON(t, got.DataFlows, wantFlows) { + t.Errorf("data_flows = %s, want the quick default", mustJSON(t, got.DataFlows)) + } + if !equalJSON(t, got.SecurityContext, wantContext) { + t.Errorf("security_context = %s, want the quick default", mustJSON(t, got.SecurityContext)) + } + // Both auth fields are the literal "unknown" — NOT the {"unknown", ""} + // pair reasoners/phases.py seeds. + if got.SecurityContext.AuthModel != "unknown" || got.SecurityContext.AuthDetails != "unknown" { + t.Errorf("quick defaults auth = (%q, %q), want (unknown, unknown)", + got.SecurityContext.AuthModel, got.SecurityContext.AuthDetails) + } + // frameworks derive from the (empty) placeholder context. + if len(got.Frameworks) != 0 { + t.Errorf("frameworks = %v, want empty", got.Frameworks) + } +} + +// TestRunReconDepthNormalization ports _normalize_depth: lowercase first, and +// anything unrecognized becomes standard. +func TestRunReconDepthNormalization(t *testing.T) { + cases := []struct { + depth string + wantCalls int + }{ + {"quick", 3}, + {"QUICK", 3}, + {" quick", 5}, // not stripped: Python only lowercases + {"standard", 5}, + {"thorough", 5}, + {"", 5}, + {"nonsense", 5}, + } + for _, tc := range cases { + t.Run("depth="+tc.depth, func(t *testing.T) { + f := newReconFake(t, nil) + if _, err := RunRecon(context.Background(), f, "/repo", tc.depth); err != nil { + t.Fatalf("RunRecon: %v", err) + } + if len(f.Harnesses) != tc.wantCalls { + t.Errorf("depth %q -> %d harness calls, want %d", tc.depth, len(f.Harnesses), tc.wantCalls) + } + }) + } +} + +// TestRunReconFirstGatherIsConcurrent pins asyncio.gather over the three cheap +// mappers. +func TestRunReconFirstGatherIsConcurrent(t *testing.T) { + f := newReconFake(t, newBarrier(3)) + if _, err := RunRecon(context.Background(), f, "/repo", "quick"); err != nil { + t.Fatalf("RunRecon: %v", err) + } + if got := f.MaxConcurrentHarness(); got != 3 { + t.Errorf("max concurrent harness calls = %d, want 3 (one gather over three mappers)", got) + } +} + +// TestRunDeepReconIsConcurrent pins the second asyncio.gather. +func TestRunDeepReconIsConcurrent(t *testing.T) { + f := newReconFake(t, newBarrier(2)) + flows, secCtx, err := RunDeepRecon(context.Background(), f, "/repo", schemas.NewArchitectureMap()) + if err != nil { + t.Fatalf("RunDeepRecon: %v", err) + } + if got := f.MaxConcurrentHarness(); got != 2 { + t.Errorf("max concurrent harness calls = %d, want 2", got) + } + if len(flows.Flows) != 1 { + t.Errorf("flows = %d, want 1 (parsed from the canned data-flow output)", len(flows.Flows)) + } + if secCtx.AuthModel != "jwt" { + t.Errorf("auth_model = %q, want jwt", secCtx.AuthModel) + } +} + +// TestMapperTempDirsAreIsolatedAndRemoved pins the +// `tempfile.mkdtemp(prefix=f"secaf-{agent_name}-")` / +// `shutil.rmtree(..., ignore_errors=True)` pair, including the exact agent +// names that appear in the prefix. +func TestMapperTempDirsAreIsolatedAndRemoved(t *testing.T) { + f := newReconFake(t, nil) + if _, err := RunRecon(context.Background(), f, "/repo", "standard"); err != nil { + t.Fatalf("RunRecon: %v", err) + } + + wantPrefix := map[string]string{ + "architecture": "secaf-recon-architecture-", + "dependencies": "secaf-recon-dependencies-", + "config_scanner": "secaf-recon-config-scanner-", + "data_flow": "secaf-recon-data-flow-", + "security_context": "secaf-recon-security-context-", + } + + seen := map[string]bool{} + for i, h := range f.Harnesses { + name := mapperOf(h.Prompt) + if h.Opts.Cwd == "" { + t.Fatalf("harness[%d] (%s) ran with no Cwd", i, name) + } + base := filepath.Base(h.Opts.Cwd) + if !strings.HasPrefix(base, wantPrefix[name]) { + t.Errorf("harness[%d] (%s) Cwd base = %q, want prefix %q", i, name, base, wantPrefix[name]) + } + if seen[h.Opts.Cwd] { + t.Errorf("two mappers shared the temp dir %q", h.Opts.Cwd) + } + seen[h.Opts.Cwd] = true + if _, err := os.Stat(h.Opts.Cwd); !os.IsNotExist(err) { + t.Errorf("temp dir %q still exists after the mapper returned (err=%v)", h.Opts.Cwd, err) + } + } +} + +// TestMapperHarnessSchemas pins that each mapper asks for its own *Raw model's +// pydantic schema — the Go-type-name -> fixture contract harnessx relies on. +func TestMapperHarnessSchemas(t *testing.T) { + f := newReconFake(t, nil) + if _, err := RunRecon(context.Background(), f, "/repo", "standard"); err != nil { + t.Fatalf("RunRecon: %v", err) + } + + wantKeys := map[string][]string{ + "architecture": {"app_type", "modules", "entry_points", "trust_boundaries", "services", "api_endpoints"}, + "dependencies": {"sbom", "known_cves", "outdated"}, + "config_scanner": {"secrets", "misconfigs"}, + "data_flow": {"flows", "sanitization_points", "sinks"}, + "security_context": {"auth_model", "auth_details", "crypto_usage", "security_signals"}, + } + for i, h := range f.Harnesses { + name := mapperOf(h.Prompt) + props, _ := h.Schema["properties"].(map[string]any) + if props == nil { + t.Errorf("harness[%d] (%s) schema has no properties: %v", i, name, h.Schema) + continue + } + for _, key := range wantKeys[name] { + if _, ok := props[key]; !ok { + t.Errorf("harness[%d] (%s) schema is missing property %q", i, name, key) + } + } + if len(props) != len(wantKeys[name]) { + t.Errorf("harness[%d] (%s) schema has %d properties, want %d", i, name, len(props), len(wantKeys[name])) + } + } +} + +// TestMapperErrorNames pins the agent names extract_harness_result puts in the +// error string, which is what an operator sees when a mapper fails. +func TestMapperErrorNames(t *testing.T) { + cases := []struct { + name string + run func(context.Context, appx.Harnesser) error + want string + }{ + {"architecture", func(ctx context.Context, a appx.Harnesser) error { + _, err := RunArchitectureMapper(ctx, a, "/repo") + return err + }, "Architecture mapper harness error: boom"}, + {"dependencies", func(ctx context.Context, a appx.Harnesser) error { + _, err := RunDependencyAuditor(ctx, a, "/repo") + return err + }, "Dependency auditor harness error: boom"}, + {"config_scanner", func(ctx context.Context, a appx.Harnesser) error { + _, err := RunConfigScanner(ctx, a, "/repo") + return err + }, "Config scanner harness error: boom"}, + {"data_flow", func(ctx context.Context, a appx.Harnesser) error { + _, err := RunDataFlowMapper(ctx, a, "/repo", schemas.NewArchitectureMap()) + return err + }, "Data flow mapper harness error: boom"}, + {"security_context", func(ctx context.Context, a appx.Harnesser) error { + _, err := RunSecurityContextProfiler(ctx, a, "/repo", schemas.NewArchitectureMap()) + return err + }, "Security context profiler harness error: boom"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := &appx.Fake{HarnessFn: func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return &harness.Result{IsError: true, ErrorMessage: "boom"}, nil + }} + err := tc.run(context.Background(), f) + if err == nil || err.Error() != tc.want { + t.Errorf("error = %v, want %q", err, tc.want) + } + // The temp dir is still removed on the failure path (Python's + // `finally`). + if len(f.Harnesses) == 1 { + if _, statErr := os.Stat(f.Harnesses[0].Opts.Cwd); !os.IsNotExist(statErr) { + t.Errorf("temp dir survived the failure path: %v", statErr) + } + } + }) + } +} + +// TestRunReconPropagatesMapperError pins that a failure in either gather aborts +// the phase with that error. +func TestRunReconPropagatesMapperError(t *testing.T) { + for _, tc := range []struct { + name string + failOn string + depth string + want string + }{ + {"first gather", "dependencies", "quick", "Dependency auditor harness error: nope"}, + {"second gather", "data_flow", "standard", "Data flow mapper harness error: nope"}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newReconFake(t, nil) + inner := f.HarnessFn + f.HarnessFn = func(ctx context.Context, prompt string, schema map[string]any, dest any, opts harness.Options) (*harness.Result, error) { + if mapperOf(prompt) == tc.failOn { + return &harness.Result{IsError: true, ErrorMessage: "nope"}, nil + } + return inner(ctx, prompt, schema, dest, opts) + } + _, err := RunRecon(context.Background(), f, "/repo", tc.depth) + if err == nil || err.Error() != tc.want { + t.Errorf("error = %v, want %q", err, tc.want) + } + }) + } +} + +// TestRunReconPropagatesTransportError pins that a transport-level failure from +// the SDK (a non-nil error, not a Result with IsError) also aborts. +func TestRunReconPropagatesTransportError(t *testing.T) { + sentinel := errors.New("provider unreachable") + f := &appx.Fake{HarnessFn: func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return nil, sentinel + }} + if _, err := RunRecon(context.Background(), f, "/repo", "quick"); !errors.Is(err, sentinel) { + t.Errorf("error = %v, want the transport error", err) + } +} + +// TestRunReconLanguagesAndFrameworks pins the two derived, deduplicated, +// sorted lists. +func TestRunReconLanguagesAndFrameworks(t *testing.T) { + f := newReconFake(t, nil) + got, err := RunRecon(context.Background(), f, "/repo", "standard") + if err != nil { + t.Fatalf("RunRecon: %v", err) + } + + // The canned architecture has Python, TypeScript, python (a case-duplicate) + // and a module with an EMPTY language, which the truthiness guard drops. + wantLanguages := []string{"python", "typescript"} + if len(got.Languages) != len(wantLanguages) { + t.Fatalf("languages = %v, want %v", got.Languages, wantLanguages) + } + for i := range wantLanguages { + if got.Languages[i] != wantLanguages[i] { + t.Fatalf("languages = %v, want %v", got.Languages, wantLanguages) + } + } + + // The canned security signals contain "Uses Flask-Login" twice; only the + // framework bucket feeds frameworks, and the set collapses the duplicate. + wantFrameworks := []string{"Uses Flask-Login"} + if len(got.Frameworks) != len(wantFrameworks) || got.Frameworks[0] != wantFrameworks[0] { + t.Errorf("frameworks = %v, want %v", got.Frameworks, wantFrameworks) + } +} + +// TestModuleLanguagesAndSortedNonEmpty pins the two set-derivations directly, +// including the sort and the empty-string drop. +func TestModuleLanguagesAndSortedNonEmpty(t *testing.T) { + arch := schemas.NewArchitectureMap() + arch.Modules = []schemas.Module{ + {Name: "b", Language: "Zig"}, + {Name: "a", Language: "GO"}, + {Name: "c", Language: "go"}, + {Name: "d", Language: ""}, + } + got := moduleLanguages(arch) + want := []string{"go", "zig"} + if len(got) != 2 || got[0] != want[0] || got[1] != want[1] { + t.Errorf("moduleLanguages = %v, want %v", got, want) + } + + gotFw := sortedNonEmpty([]string{"beta", "alpha", "", "beta"}) + wantFw := []string{"alpha", "beta"} + if len(gotFw) != 2 || gotFw[0] != wantFw[0] || gotFw[1] != wantFw[1] { + t.Errorf("sortedNonEmpty = %v, want %v", gotFw, wantFw) + } + + if got := moduleLanguages(schemas.NewArchitectureMap()); got == nil { + t.Error("moduleLanguages returned nil, want an empty slice (Python sorted() gives [])") + } + if got := sortedNonEmpty(nil); got == nil { + t.Error("sortedNonEmpty returned nil, want an empty slice") + } +} + +// TestRunReconSetsDurationAndMetrics pins that run_recon times itself and folds +// in _repo_metrics. +func TestRunReconSetsDurationAndMetrics(t *testing.T) { + repo := t.TempDir() + if err := os.WriteFile(filepath.Join(repo, "main.py"), []byte("a\nb\nc\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("x\n"), 0o644); err != nil { + t.Fatal(err) + } + + f := newReconFake(t, nil) + got, err := RunRecon(context.Background(), f, repo, "quick") + if err != nil { + t.Fatalf("RunRecon: %v", err) + } + if got.ReconDurationSeconds <= 0 { + t.Errorf("recon_duration_seconds = %v, want > 0", got.ReconDurationSeconds) + } + if got.LinesOfCode != 3 || got.FileCount != 2 { + t.Errorf("metrics = (lines %d, files %d), want (3, 2)", got.LinesOfCode, got.FileCount) + } +} + +// TestRunFastRecon pins the two deliberate differences from +// RunRecon(depth="quick"): frameworks is hard-coded empty and the duration is +// never set. +func TestRunFastRecon(t *testing.T) { + f := newReconFake(t, nil) + got, err := RunFastRecon(context.Background(), f, "/repo") + if err != nil { + t.Fatalf("RunFastRecon: %v", err) + } + if len(f.Harnesses) != 3 { + t.Errorf("harness calls = %v, want the three cheap mappers", harnessMappers(f)) + } + if got.ReconDurationSeconds != 0 { + t.Errorf("recon_duration_seconds = %v, want 0 (run_fast_recon never sets it)", got.ReconDurationSeconds) + } + if got.Frameworks == nil || len(got.Frameworks) != 0 { + t.Errorf("frameworks = %v, want an empty (non-nil) list", got.Frameworks) + } + if got.SecurityContext.AuthModel != "unknown" { + t.Errorf("security_context.auth_model = %q, want the quick default", got.SecurityContext.AuthModel) + } + // Languages ARE still derived, unlike frameworks. + if len(got.Languages) != 2 { + t.Errorf("languages = %v, want the two derived from the architecture", got.Languages) + } +} + +// TestRunFastReconIsConcurrent pins that run_fast_recon uses the same one +// gather over three mappers. +func TestRunFastReconIsConcurrent(t *testing.T) { + f := newReconFake(t, newBarrier(3)) + if _, err := RunFastRecon(context.Background(), f, "/repo"); err != nil { + t.Fatalf("RunFastRecon: %v", err) + } + if got := f.MaxConcurrentHarness(); got != 3 { + t.Errorf("max concurrent harness calls = %d, want 3", got) + } +} + +// TestQuickDefaults pins _quick_defaults exactly. +func TestQuickDefaults(t *testing.T) { + flows, ctx := QuickDefaults() + if got := mustJSON(t, flows); got != `{"flows":[],"sanitization_points":[],"sinks":[]}` { + t.Errorf("quick DataFlowMap = %s", got) + } + want := `{"auth_model":"unknown","auth_details":"unknown","crypto_usage":[],` + + `"framework_security":[],"security_headers":[],"deployment_signals":[]}` + if got := mustJSON(t, ctx); got != want { + t.Errorf("quick SecurityContext = %s, want %s", got, want) + } +} + +// TestRunReconResultIsFullyPopulated pins that every ReconResult field the +// pipeline downstream reads is set, and that the JSON round-trips (the +// orchestrator sends this over the control plane). +func TestRunReconResultIsFullyPopulated(t *testing.T) { + f := newReconFake(t, nil) + got, err := RunRecon(context.Background(), f, "/repo", "thorough") + if err != nil { + t.Fatalf("RunRecon: %v", err) + } + if got.Architecture.AppType == nil || *got.Architecture.AppType != "web_api" { + t.Errorf("architecture.app_type = %v", got.Architecture.AppType) + } + if len(got.Dependencies.Sbom) != 1 || got.Dependencies.DirectCount != 1 { + t.Errorf("dependencies = %+v", got.Dependencies) + } + if len(got.Config.Secrets) != 1 || len(got.Config.Misconfigs) != 1 { + t.Errorf("config = %+v", got.Config) + } + if len(got.DataFlows.Flows) != 1 { + t.Errorf("data_flows = %+v", got.DataFlows) + } + if got.SecurityContext.AuthModel != "jwt" { + t.Errorf("security_context.auth_model = %q", got.SecurityContext.AuthModel) + } + + var round schemas.ReconResult + if err := json.Unmarshal([]byte(mustJSON(t, got)), &round); err != nil { + t.Fatalf("ReconResult does not round-trip through JSON: %v", err) + } + if mustJSON(t, round) != mustJSON(t, got) { + t.Errorf("ReconResult JSON round-trip is lossy:\n%s\n%s", mustJSON(t, round), mustJSON(t, got)) + } +} + +func mustJSON(t *testing.T, v any) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return string(b) +} + +func equalJSON(t *testing.T, a, b any) bool { + t.Helper() + return mustJSON(t, a) == mustJSON(t, b) +} + +// TestRunReconMatchesPythonEndToEnd is the strongest parity check in this +// package: it feeds the Go phase the SAME canned harness output the REAL +// Python run_recon / run_fast_recon were fed by go/scripts/gen_golden.py, and +// compares the whole ReconResult. +// +// The two nondeterministic parts are normalized on both sides — uuid4 `id` +// fields (SecretFinding / MisconfigFinding mint one per parse) and +// recon_duration_seconds (a wall-clock measurement). +func TestRunReconMatchesPythonEndToEnd(t *testing.T) { + g := loadReconGolden(t) + + cases := []struct { + name string + run func(context.Context, appx.Harnesser) (schemas.ReconResult, error) + want map[string]any + }{ + {"standard", func(ctx context.Context, a appx.Harnesser) (schemas.ReconResult, error) { + return RunRecon(ctx, a, g.RepoPath, "standard") + }, g.Standard}, + {"quick", func(ctx context.Context, a appx.Harnesser) (schemas.ReconResult, error) { + return RunRecon(ctx, a, g.RepoPath, "quick") + }, g.Quick}, + {"fast", func(ctx context.Context, a appx.Harnesser) (schemas.ReconResult, error) { + return RunFastRecon(ctx, a, g.RepoPath) + }, g.Fast}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if len(tc.want) == 0 { + t.Fatalf("golden has no %q result", tc.name) + } + f := newReconFake(t, nil) + got, err := tc.run(context.Background(), f) + if err != nil { + t.Fatalf("run: %v", err) + } + // The fixture repo path does not exist, so both runtimes report + // (0, 0) metrics — assert that rather than letting it pass silently. + if got.LinesOfCode != 0 || got.FileCount != 0 { + t.Fatalf("fixture repo unexpectedly exists on this machine: metrics = (%d, %d)", + got.LinesOfCode, got.FileCount) + } + got.ReconDurationSeconds = 0 + + tree, _ := scrubIDs(jsonTree(t, got)).(map[string]any) + if !reflect.DeepEqual(tree, tc.want) { + t.Errorf("ReconResult differs from Python%s", diffJSON(t, tree, tc.want)) + } + }) + } +} diff --git a/go/internal/agents/recon/security_context.go b/go/internal/agents/recon/security_context.go new file mode 100644 index 0000000..f4e9850 --- /dev/null +++ b/go/internal/agents/recon/security_context.go @@ -0,0 +1,55 @@ +package recon + +// Ports src/sec_af/agents/recon/security_context.py. + +import ( + "context" + "os" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/harnessx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +const ( + securityContextPromptPath = "recon/security_context.txt" + securityContextAgentName = "recon-security-context" + securityContextExtractName = "Security context profiler" +) + +// securityContextPrompt builds the exact prompt run_security_context_profiler +// sends. Same two-step shape as the data-flow prompt: placeholder substitution +// first, exploration CONTEXT suffix second. +func securityContextPrompt(repoPath string, architecture schemas.ArchitectureMap) string { + template := prompts.MustLoad(securityContextPromptPath) + return strings.ReplaceAll(template, architectureMapPlaceholder, ArchitectureContextBlock(architecture)) + + explorationContextSuffix(repoPath) +} + +// RunSecurityContextProfiler ports security_context.py +// run_security_context_profiler: substitute the architecture map, harness for a +// SecurityContextRaw, then parse_security_context_raw (which is where the flat +// `security_signals` list gets bucketed into headers / deployment / framework). +func RunSecurityContextProfiler(ctx context.Context, app appx.Harnesser, repoPath string, architecture schemas.ArchitectureMap) (schemas.SecurityContext, error) { + prompt := securityContextPrompt(repoPath, architecture) + + harnessCwd, err := os.MkdirTemp("", "secaf-"+securityContextAgentName+"-") + if err != nil { + return schemas.SecurityContext{}, err + } + defer os.RemoveAll(harnessCwd) + + raw, err := harnessx.RunExtract[schemas.SecurityContextRaw]( + ctx, app, prompt, + harness.Options{Cwd: harnessCwd, ProjectDir: repoPath}, + securityContextExtractName, + ) + if err != nil { + return schemas.SecurityContext{}, err + } + return ParseSecurityContextRaw(raw), nil +} diff --git a/go/internal/agents/recon/testdata/golden/architecture_context_block_A.txt b/go/internal/agents/recon/testdata/golden/architecture_context_block_A.txt new file mode 100644 index 0000000..13831c0 --- /dev/null +++ b/go/internal/agents/recon/testdata/golden/architecture_context_block_A.txt @@ -0,0 +1,73 @@ +{ + "app_type": "web_api", + "modules": [ + { + "name": "auth", + "path": "src/auth/", + "language": "Python", + "description": "Sessions & tokens", + "dependencies": [ + "db", + "cache" + ] + }, + { + "name": "ui", + "path": "web/", + "language": "TypeScript", + "description": null, + "dependencies": [] + } + ], + "entry_points": [ + { + "kind": "http", + "identifier": "POST /api/login", + "file_path": "src/routes.py", + "line": 42, + "method": "POST", + "route": "/api/login", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "migrate", + "file_path": "src/cli.py", + "line": 8, + "method": null, + "route": null, + "auth_required": null + } + ], + "trust_boundaries": [ + { + "name": "API Gateway", + "source_zone": "external", + "target_zone": "internal", + "description": "Rate limiting auth \u2014 caf\u00e9 \u2192 app", + "enforcement": [ + "waf" + ] + } + ], + "services": [ + { + "name": "PostgreSQL", + "service_type": "database", + "endpoint": "localhost:5432", + "purpose": "primary store", + "auth_mechanism": "password" + } + ], + "api_surface": [ + { + "method": "GET", + "path": "/api/users", + "handler": "get_users", + "file_path": "src/api.py", + "line": 15, + "auth_required": true, + "rate_limited": false + } + ] +} \ No newline at end of file diff --git a/go/internal/agents/recon/testdata/golden/architecture_context_block_B.txt b/go/internal/agents/recon/testdata/golden/architecture_context_block_B.txt new file mode 100644 index 0000000..84c8066 --- /dev/null +++ b/go/internal/agents/recon/testdata/golden/architecture_context_block_B.txt @@ -0,0 +1,8 @@ +{ + "app_type": null, + "modules": [], + "entry_points": [], + "trust_boundaries": [], + "services": [], + "api_surface": [] +} \ No newline at end of file diff --git a/go/internal/agents/recon/testdata/golden/architecture_prompt.txt b/go/internal/agents/recon/testdata/golden/architecture_prompt.txt new file mode 100644 index 0000000..56b7789 --- /dev/null +++ b/go/internal/agents/recon/testdata/golden/architecture_prompt.txt @@ -0,0 +1,34 @@ +ROLE: +You are a software architecture analyst specializing in security-relevant codebase mapping. + +TASK: +Map architecture, modules, entry points, trust boundaries, service integrations, and full API surface of the repository. + +WORKFLOW (follow these steps in order): +1. List the top-level directory to understand project structure. +2. Read key files: package.json, requirements.txt, setup.py, go.mod, Dockerfile, docker-compose.yml, or similar config files to identify the tech stack. +3. Find entry points by searching for route definitions, HTTP handlers, CLI commands, and main/app files. +4. Read the identified entry point files to extract routes, methods, handlers, and line numbers. +5. Identify trust boundaries (e.g., public vs. internal, user input vs. database) and service dependencies. +6. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +SCOPE: +- Analyze source code and runtime configuration files. +- Include HTTP routes, CLI entry points, worker/event handlers, and RPC endpoints. +- Exclude tests, generated artifacts, and dependency vendor folders unless they expose runtime entry points. + +OUTPUT: +- Populate modules, entry_points, trust_boundaries, services, and api_surface arrays. +- Use file paths and line numbers from the actual files you read. +- Keep all booleans explicit where known, otherwise null. +- Empty arrays are acceptable if no items are found for a category. + +CONSTRAINTS: +- Base all findings on actual file content you read. Do not speculate. +- Do not include markdown, prose, or code fences in the output file. JSON only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Start by listing files in the repository path above. +- After gathering evidence, write the JSON output file using your Write tool. \ No newline at end of file diff --git a/go/internal/agents/recon/testdata/golden/config_scanner_prompt.txt b/go/internal/agents/recon/testdata/golden/config_scanner_prompt.txt new file mode 100644 index 0000000..404746d --- /dev/null +++ b/go/internal/agents/recon/testdata/golden/config_scanner_prompt.txt @@ -0,0 +1,32 @@ +ROLE: +You are an application security configuration analyst focused on secrets and misconfigurations. + +TASK: +Find hardcoded secrets and insecure configuration defaults in the repository. + +WORKFLOW (follow these steps in order): +1. List the top-level directory to understand project structure. +2. Search for environment files (.env, .env.example, .env.local), config files (config.py, settings.py, application.yml, etc.), and infrastructure files (Dockerfile, docker-compose.yml, nginx.conf, etc.). +3. Read each config/env file and look for: hardcoded API keys, passwords, tokens, database URLs, debug flags set to true, permissive CORS origins, weak crypto settings. +4. Search source code for patterns like "password", "secret", "api_key", "token", "DEBUG = True", "CORS_ALLOW_ALL". +5. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +SCOPE: +- Inspect environment files, infrastructure configs, framework configs, deployment descriptors, and startup scripts. +- Detect exposed secrets, permissive CORS, debug mode in production paths, weak crypto settings, and missing security controls. + +OUTPUT: +- Populate secrets and misconfigs arrays with concrete file path and line number evidence. +- Provide practical risk descriptions and remediation text when possible. +- Empty arrays are acceptable if no issues are found. + +CONSTRAINTS: +- Do not treat obvious test placeholders as real secrets unless uncertainty remains. +- Base all findings on actual file content you read. Do not speculate. +- Do not include markdown, prose, or code fences in the output file. JSON only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Start by listing files in the repository path above. +- After gathering evidence, write the JSON output file using your Write tool. \ No newline at end of file diff --git a/go/internal/agents/recon/testdata/golden/data_flow_prompt_A.txt b/go/internal/agents/recon/testdata/golden/data_flow_prompt_A.txt new file mode 100644 index 0000000..e26cd7d --- /dev/null +++ b/go/internal/agents/recon/testdata/golden/data_flow_prompt_A.txt @@ -0,0 +1,105 @@ +ROLE: +You are a data-flow security analyst focused on tracing attacker-controlled input through code. + +CONTEXT: +You are in SEC-AF RECON Phase and must produce the required JSON output. +Architecture context is provided below. + +ARCHITECTURE_MAP: +{ + "app_type": "web_api", + "modules": [ + { + "name": "auth", + "path": "src/auth/", + "language": "Python", + "description": "Sessions & tokens", + "dependencies": [ + "db", + "cache" + ] + }, + { + "name": "ui", + "path": "web/", + "language": "TypeScript", + "description": null, + "dependencies": [] + } + ], + "entry_points": [ + { + "kind": "http", + "identifier": "POST /api/login", + "file_path": "src/routes.py", + "line": 42, + "method": "POST", + "route": "/api/login", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "migrate", + "file_path": "src/cli.py", + "line": 8, + "method": null, + "route": null, + "auth_required": null + } + ], + "trust_boundaries": [ + { + "name": "API Gateway", + "source_zone": "external", + "target_zone": "internal", + "description": "Rate limiting auth \u2014 caf\u00e9 \u2192 app", + "enforcement": [ + "waf" + ] + } + ], + "services": [ + { + "name": "PostgreSQL", + "service_type": "database", + "endpoint": "localhost:5432", + "purpose": "primary store", + "auth_mechanism": "password" + } + ], + "api_surface": [ + { + "method": "GET", + "path": "/api/users", + "handler": "get_users", + "file_path": "src/api.py", + "line": 15, + "auth_required": true, + "rate_limited": false + } + ] +} + +TASK: +Trace user-controlled inputs from sources through transformations to security-critical sinks. + +SCOPE: +- Start from known entry points and input parsers. +- Follow processing layers, validation, and sanitization functions. +- Identify sinks such as SQL execution, command execution, template rendering, eval/deserialization, and external calls. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate flows, sanitization_points, and sinks. +- For each flow include source, ordered path, sink, sanitized, and involved files. + +CONSTRAINTS: +- Take multiple turns to explore the codebase first, then build your analysis. +- Only mark sanitized=true when evidence is clear. +- No markdown/code fences; JSON output only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Take multiple turns to explore the codebase first, then build your analysis. +- Write final JSON only when analysis is complete. \ No newline at end of file diff --git a/go/internal/agents/recon/testdata/golden/data_flow_prompt_B.txt b/go/internal/agents/recon/testdata/golden/data_flow_prompt_B.txt new file mode 100644 index 0000000..06d4714 --- /dev/null +++ b/go/internal/agents/recon/testdata/golden/data_flow_prompt_B.txt @@ -0,0 +1,40 @@ +ROLE: +You are a data-flow security analyst focused on tracing attacker-controlled input through code. + +CONTEXT: +You are in SEC-AF RECON Phase and must produce the required JSON output. +Architecture context is provided below. + +ARCHITECTURE_MAP: +{ + "app_type": null, + "modules": [], + "entry_points": [], + "trust_boundaries": [], + "services": [], + "api_surface": [] +} + +TASK: +Trace user-controlled inputs from sources through transformations to security-critical sinks. + +SCOPE: +- Start from known entry points and input parsers. +- Follow processing layers, validation, and sanitization functions. +- Identify sinks such as SQL execution, command execution, template rendering, eval/deserialization, and external calls. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Populate flows, sanitization_points, and sinks. +- For each flow include source, ordered path, sink, sanitized, and involved files. + +CONSTRAINTS: +- Take multiple turns to explore the codebase first, then build your analysis. +- Only mark sanitized=true when evidence is clear. +- No markdown/code fences; JSON output only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Take multiple turns to explore the codebase first, then build your analysis. +- Write final JSON only when analysis is complete. \ No newline at end of file diff --git a/go/internal/agents/recon/testdata/golden/dependencies_prompt.txt b/go/internal/agents/recon/testdata/golden/dependencies_prompt.txt new file mode 100644 index 0000000..dfba5fb --- /dev/null +++ b/go/internal/agents/recon/testdata/golden/dependencies_prompt.txt @@ -0,0 +1,34 @@ +ROLE: +You are a software supply-chain security analyst focused on dependency risk. + +TASK: +Build an SBOM, identify known CVE exposure, and report outdated dependencies. + +WORKFLOW (follow these steps in order): +1. List the top-level directory to find dependency manifests. +2. Read dependency files: package.json, package-lock.json, yarn.lock, requirements.txt, Pipfile.lock, go.mod, go.sum, Gemfile.lock, pom.xml, build.gradle, Cargo.lock, etc. +3. For each dependency found, record: name, version, ecosystem (npm/pip/go/etc.), and whether it is direct or transitive. +4. Check for known CVEs affecting the identified packages and versions based on your knowledge. +5. Identify outdated dependencies where the installed version significantly lags behind known latest versions. +6. Once you have gathered enough evidence, write the final JSON output file as specified in the OUTPUT REQUIREMENTS below. + +SCOPE: +- Parse dependency manifests and lock files across ecosystems. +- Distinguish direct vs transitive dependencies. +- Record known CVEs with available severity/reachability hints. + +OUTPUT: +- Populate sbom, known_cves, outdated, direct_count, and transitive_count. +- Include package ecosystem and version information whenever available. +- Empty arrays are acceptable if no CVEs or outdated deps are found. + +CONSTRAINTS: +- Do not invent CVEs or versions. Only report what you can verify from the dependency files. +- Base all findings on actual file content you read. Do not speculate. +- Do not include markdown, prose, or code fences in the output file. JSON only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Start by listing files in the repository path above. +- After gathering evidence, write the JSON output file using your Write tool. \ No newline at end of file diff --git a/go/internal/agents/recon/testdata/golden/parse_architecture.json b/go/internal/agents/recon/testdata/golden/parse_architecture.json new file mode 100644 index 0000000..800451c --- /dev/null +++ b/go/internal/agents/recon/testdata/golden/parse_architecture.json @@ -0,0 +1,160 @@ +{ + "input": { + "app_type": "web_api", + "modules": [ + "auth | src/auth/ | Python | Authentication and session management", + "ui|web/|TypeScript|", + "orphan", + " a | b | c | d | e " + ], + "entry_points": [ + "http | POST /api/login | src/routes.py:42 | false", + "cli | migrate | src/cli.py | yes", + "event | queue:jobs | src/worker.py:0 | maybe" + ], + "trust_boundaries": [ + "API Gateway | external | internal | Rate limiting and auth", + "edge|dmz" + ], + "services": [ + "PostgreSQL | database | localhost:5432 | password", + "Stripe | payments | n/a | NONE", + "Redis | cache | | unknown" + ], + "api_endpoints": [ + "GET | /api/users | get_users | src/api.py:15 | true | false", + "POST | /api/users | create | src/api.py | 1 | 0", + "PUT | /x | h | a:b:12 | | " + ] + }, + "want": { + "app_type": "web_api", + "modules": [ + { + "name": "auth", + "path": "src/auth/", + "language": "Python", + "description": "Authentication and session management", + "dependencies": [] + }, + { + "name": "ui", + "path": "web/", + "language": "TypeScript", + "description": null, + "dependencies": [] + }, + { + "name": "orphan", + "path": "", + "language": "", + "description": null, + "dependencies": [] + }, + { + "name": "a", + "path": "b", + "language": "c", + "description": "d | e", + "dependencies": [] + } + ], + "entry_points": [ + { + "kind": "http", + "identifier": "POST /api/login", + "file_path": "src/routes.py", + "line": 42, + "method": null, + "route": "POST /api/login", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "migrate", + "file_path": "src/cli.py", + "line": 0, + "method": null, + "route": null, + "auth_required": true + }, + { + "kind": "event", + "identifier": "queue:jobs", + "file_path": "src/worker.py:0", + "line": 0, + "method": null, + "route": null, + "auth_required": null + } + ], + "trust_boundaries": [ + { + "name": "API Gateway", + "source_zone": "external", + "target_zone": "internal", + "description": "Rate limiting and auth", + "enforcement": [] + }, + { + "name": "edge", + "source_zone": "dmz", + "target_zone": "", + "description": "", + "enforcement": [] + } + ], + "services": [ + { + "name": "PostgreSQL", + "service_type": "database", + "endpoint": "localhost:5432", + "purpose": null, + "auth_mechanism": "password" + }, + { + "name": "Stripe", + "service_type": "payments", + "endpoint": null, + "purpose": null, + "auth_mechanism": null + }, + { + "name": "Redis", + "service_type": "cache", + "endpoint": null, + "purpose": null, + "auth_mechanism": null + } + ], + "api_surface": [ + { + "method": "GET", + "path": "/api/users", + "handler": "get_users", + "file_path": "src/api.py", + "line": 15, + "auth_required": true, + "rate_limited": false + }, + { + "method": "POST", + "path": "/api/users", + "handler": "create", + "file_path": "src/api.py", + "line": 0, + "auth_required": true, + "rate_limited": false + }, + { + "method": "PUT", + "path": "/x", + "handler": "h", + "file_path": "a:b", + "line": 12, + "auth_required": null, + "rate_limited": null + } + ] + } +} diff --git a/go/internal/agents/recon/testdata/golden/parse_config_report.json b/go/internal/agents/recon/testdata/golden/parse_config_report.json new file mode 100644 index 0000000..43c3a3f --- /dev/null +++ b/go/internal/agents/recon/testdata/golden/parse_config_report.json @@ -0,0 +1,56 @@ +{ + "input": { + "secrets": [ + "api_key | src/config.py:7 | API_KEY = \"sk-live-123\" | high | false", + "password | src/settings.py | pw=hunter2 | | " + ], + "misconfigs": [ + "dangerous_config | deploy/prod.yaml:22 | DEBUG | Debug mode enabled in production | Set DEBUG=false", + "cors | deploy/nginx.conf | N/A | Wildcard origin | unknown" + ] + }, + "want": { + "secrets": [ + { + "id": "", + "secret_type": "api_key", + "file_path": "src/config.py", + "line": 7, + "match": "API_KEY = \"sk-live-123\"", + "confidence": "high", + "is_test_value": false + }, + { + "id": "", + "secret_type": "password", + "file_path": "src/settings.py", + "line": 0, + "match": "pw=hunter2", + "confidence": "medium", + "is_test_value": null + } + ], + "misconfigs": [ + { + "id": "", + "category": "dangerous_config", + "file_path": "deploy/prod.yaml", + "line": 22, + "key": "DEBUG", + "value": null, + "risk": "Debug mode enabled in production", + "remediation": "Set DEBUG=false" + }, + { + "id": "", + "category": "cors", + "file_path": "deploy/nginx.conf", + "line": null, + "key": null, + "value": null, + "risk": "Wildcard origin", + "remediation": null + } + ] + } +} diff --git a/go/internal/agents/recon/testdata/golden/parse_data_flow.json b/go/internal/agents/recon/testdata/golden/parse_data_flow.json new file mode 100644 index 0000000..5bd1f36 --- /dev/null +++ b/go/internal/agents/recon/testdata/golden/parse_data_flow.json @@ -0,0 +1,83 @@ +{ + "input": { + "flows": [ + "request.body | sql.execute | false | src/db.py, src/routes.py", + "argv | os.system | TRUE | ", + "env | log | garbage | a, , b ," + ], + "sanitization_points": [ + "src/valid.py:12 | sanitize | escape | sqli, xss", + "src/valid.py | | strip | " + ], + "sinks": [ + "sql | src/db.py:88 | execute | user-controlled query string", + "exec | src/run.py | | " + ] + }, + "want": { + "flows": [ + { + "source": "request.body", + "path": [], + "sink": "sql.execute", + "sanitized": false, + "files": [ + "src/db.py", + "src/routes.py" + ] + }, + { + "source": "argv", + "path": [], + "sink": "os.system", + "sanitized": true, + "files": [] + }, + { + "source": "env", + "path": [], + "sink": "log", + "sanitized": false, + "files": [ + "a", + "b" + ] + } + ], + "sanitization_points": [ + { + "file_path": "src/valid.py", + "line": 12, + "function_name": "sanitize", + "sanitization_type": "escape", + "protects_against": [ + "sqli", + "xss" + ] + }, + { + "file_path": "src/valid.py", + "line": 0, + "function_name": null, + "sanitization_type": "strip", + "protects_against": [] + } + ], + "sinks": [ + { + "sink_type": "sql", + "file_path": "src/db.py", + "line": 88, + "function_name": "execute", + "exploitability_notes": "user-controlled query string" + }, + { + "sink_type": "exec", + "file_path": "src/run.py", + "line": 0, + "function_name": null, + "exploitability_notes": null + } + ] + } +} diff --git a/go/internal/agents/recon/testdata/golden/parse_dependency_report.json b/go/internal/agents/recon/testdata/golden/parse_dependency_report.json new file mode 100644 index 0000000..07312c9 --- /dev/null +++ b/go/internal/agents/recon/testdata/golden/parse_dependency_report.json @@ -0,0 +1,80 @@ +{ + "input": { + "sbom": [ + "django | 3.2.1 | pypi | true | BSD-3-Clause", + "urllib3 | 1.26.5 | pypi | false | n/a", + "left-pad | 1.0.0 | npm | notabool | " + ], + "known_cves": [ + "CVE-2021-1 | django | 3.2.1 | 3.2.13 | 9.8 | true | true", + "CVE-2021-2 | urllib3 | 1.26.5 | none | notafloat | 0 | " + ], + "outdated": [ + "django | 3.2.1 | 5.0.0 | true", + "requests | 2.0 | 2.31 | 0" + ] + }, + "want": { + "sbom": [ + { + "name": "django", + "version": "3.2.1", + "ecosystem": "pypi", + "direct": true, + "license": "BSD-3-Clause" + }, + { + "name": "urllib3", + "version": "1.26.5", + "ecosystem": "pypi", + "direct": false, + "license": null + }, + { + "name": "left-pad", + "version": "1.0.0", + "ecosystem": "npm", + "direct": false, + "license": null + } + ], + "known_cves": [ + { + "cve_id": "CVE-2021-1", + "package": "django", + "installed_version": "3.2.1", + "fixed_version": "3.2.13", + "cvss_v4_score": 9.8, + "epss_score": null, + "direct": true, + "reachable": true + }, + { + "cve_id": "CVE-2021-2", + "package": "urllib3", + "installed_version": "1.26.5", + "fixed_version": null, + "cvss_v4_score": null, + "epss_score": null, + "direct": false, + "reachable": null + } + ], + "outdated": [ + { + "package": "django", + "current_version": "3.2.1", + "latest_version": "5.0.0", + "direct": true + }, + { + "package": "requests", + "current_version": "2.0", + "latest_version": "2.31", + "direct": false + } + ], + "direct_count": 1, + "transitive_count": 2 + } +} diff --git a/go/internal/agents/recon/testdata/golden/parse_primitives.json b/go/internal/agents/recon/testdata/golden/parse_primitives.json new file mode 100644 index 0000000..91462fe --- /dev/null +++ b/go/internal/agents/recon/testdata/golden/parse_primitives.json @@ -0,0 +1,361 @@ +{ + "split_pipe": [ + { + "s": "a | b | c | d", + "expected": 4, + "want": [ + "a", + "b", + "c", + "d" + ] + }, + { + "s": "a|b", + "expected": 4, + "want": [ + "a", + "b", + "", + "" + ] + }, + { + "s": "", + "expected": 4, + "want": [ + "", + "", + "", + "" + ] + }, + { + "s": " a | b | c | d | e ", + "expected": 4, + "want": [ + "a", + "b", + "c", + "d | e" + ] + }, + { + "s": "a|b|c|d|e|f|g", + "expected": 6, + "want": [ + "a", + "b", + "c", + "d", + "e", + "f|g" + ] + }, + { + "s": "only", + "expected": 1, + "want": [ + "only" + ] + }, + { + "s": "a|b|c", + "expected": 1, + "want": [ + "a|b|c" + ] + }, + { + "s": "|||", + "expected": 4, + "want": [ + "", + "", + "", + "" + ] + }, + { + "s": " x ", + "expected": 2, + "want": [ + "x", + "" + ] + } + ], + "parse_bool": [ + { + "s": "true", + "want": true + }, + { + "s": "TRUE", + "want": true + }, + { + "s": " True ", + "want": true + }, + { + "s": "yes", + "want": true + }, + { + "s": "1", + "want": true + }, + { + "s": "false", + "want": false + }, + { + "s": "No", + "want": false + }, + { + "s": "0", + "want": false + }, + { + "s": "", + "want": null + }, + { + "s": "maybe", + "want": null + }, + { + "s": "n/a", + "want": null + }, + { + "s": " 01 ", + "want": null + } + ], + "parse_int": [ + { + "s": "0", + "want": 0 + }, + { + "s": "12", + "want": 12 + }, + { + "s": " 42 ", + "want": 42 + }, + { + "s": "-7", + "want": -7 + }, + { + "s": "+3", + "want": 3 + }, + { + "s": "1_0", + "want": 10 + }, + { + "s": "abc", + "want": 0 + }, + { + "s": "", + "want": 0 + }, + { + "s": "3.5", + "want": 0 + }, + { + "s": "0x10", + "want": 0 + }, + { + "s": " ", + "want": 0 + } + ], + "parse_int_default9": [ + { + "s": "0", + "want": 0 + }, + { + "s": "12", + "want": 12 + }, + { + "s": " 42 ", + "want": 42 + }, + { + "s": "-7", + "want": -7 + }, + { + "s": "+3", + "want": 3 + }, + { + "s": "1_0", + "want": 10 + }, + { + "s": "abc", + "want": 9 + }, + { + "s": "", + "want": 9 + }, + { + "s": "3.5", + "want": 9 + }, + { + "s": "0x10", + "want": 9 + }, + { + "s": " ", + "want": 9 + } + ], + "parse_float": [ + { + "s": "1.5", + "want": "1.5" + }, + { + "s": " 9.8 ", + "want": "9.8" + }, + { + "s": "0", + "want": "0.0" + }, + { + "s": "-2", + "want": "-2.0" + }, + { + "s": "1e3", + "want": "1000.0" + }, + { + "s": "abc", + "want": null + }, + { + "s": "", + "want": null + }, + { + "s": "inf", + "want": "inf" + }, + { + "s": "nan", + "want": "nan" + }, + { + "s": "1_0.5", + "want": "10.5" + }, + { + "s": "+.5", + "want": "0.5" + } + ], + "parse_file_line": [ + { + "s": "src/api.py:15", + "path": "src/api.py", + "line": 15 + }, + { + "s": "src/api.py", + "path": "src/api.py", + "line": 0 + }, + { + "s": "a:b:12", + "path": "a:b", + "line": 12 + }, + { + "s": "src/x.py:0", + "path": "src/x.py:0", + "line": 0 + }, + { + "s": "src/x.py:-3", + "path": "src/x.py:-3", + "line": 0 + }, + { + "s": ":", + "path": ":", + "line": 0 + }, + { + "s": "", + "path": "", + "line": 0 + }, + { + "s": " a.py : 4 ", + "path": "a.py ", + "line": 4 + }, + { + "s": "C:/x.py:9", + "path": "C:/x.py", + "line": 9 + } + ], + "is_na": [ + { + "s": "", + "want": true + }, + { + "s": " ", + "want": true + }, + { + "s": "na", + "want": true + }, + { + "s": "N/A", + "want": true + }, + { + "s": "None", + "want": true + }, + { + "s": "UNKNOWN", + "want": true + }, + { + "s": "unknown ", + "want": true + }, + { + "s": "value", + "want": false + }, + { + "s": "0", + "want": false + } + ] +} diff --git a/go/internal/agents/recon/testdata/golden/parse_security_context.json b/go/internal/agents/recon/testdata/golden/parse_security_context.json new file mode 100644 index 0000000..2c237c9 --- /dev/null +++ b/go/internal/agents/recon/testdata/golden/parse_security_context.json @@ -0,0 +1,58 @@ +{ + "input": { + "auth_model": "jwt", + "auth_details": "Bearer token validated by middleware", + "crypto_usage": [ + "AES | 256 | GCM | data encryption | false", + "TLSv1.0 | n/a | none | legacy tls terminator | true", + "MD5 | notanint | | | TRUE" + ], + "security_signals": [ + "CSRF protection enabled", + "HSTS header present", + "Runs in Docker", + "CSP configured", + "Uses Kubernetes secrets", + "Input validation via pydantic" + ] + }, + "want": { + "auth_model": "jwt", + "auth_details": "Bearer token validated by middleware", + "crypto_usage": [ + { + "algorithm": "AES", + "key_size": 256, + "mode": "GCM", + "usage_context": "data encryption", + "is_weak": false + }, + { + "algorithm": "TLSv1.0", + "key_size": null, + "mode": null, + "usage_context": "legacy tls terminator", + "is_weak": true + }, + { + "algorithm": "MD5", + "key_size": 0, + "mode": null, + "usage_context": null, + "is_weak": true + } + ], + "framework_security": [ + "CSRF protection enabled", + "Input validation via pydantic" + ], + "security_headers": [ + "HSTS header present", + "CSP configured" + ], + "deployment_signals": [ + "Runs in Docker", + "Uses Kubernetes secrets" + ] + } +} diff --git a/go/internal/agents/recon/testdata/golden/repo_metrics.json b/go/internal/agents/recon/testdata/golden/repo_metrics.json new file mode 100644 index 0000000..5a859d3 --- /dev/null +++ b/go/internal/agents/recon/testdata/golden/repo_metrics.json @@ -0,0 +1,36 @@ +{ + "files": { + "main.py": "aW1wb3J0IG9zCnByaW50KDEpCg==", + "trailing_none.go": "cGFja2FnZSBtYWluCmZ1bmMgbWFpbigpIHt9", + "crlf.ts": "Y29uc3QgYSA9IDE7DQpjb25zdCBiID0gMjsNCg==", + "cr_only.rb": "cHV0cyAxDXB1dHMgMg0=", + "empty.py": "", + "just_newline.sql": "Cg==", + "invalid_utf8.js": "dmFyIGEgPSAn//4nOwp2YXIgYiA9IDI7Cg==", + "UPPER.PY": "YQpiCg==", + "archive.tar.gz": "bm90IHJlYWxseSBnemlwCg==", + ".gitignore": "bm9kZV9tb2R1bGVzCi52ZW52Cg==", + "Makefile": "YWxsOgoJZ28gYnVpbGQK", + "trailingdot.": "eAo=", + "README.md": "IyBkb2NzCm5vdCBjb2RlCg==", + "pkg/lib.go": "cGFja2FnZSBwa2cKCmZ1bmMgRigpIHt9Cg==", + "pkg/deep/nested/util.rs": "Zm4gbWFpbigpIHt9Cg==", + "conf/app.yaml": "YTogMQpiOiAyCmM6IDMK", + "conf/app.YML": "eDogMQo=", + ".git/config": "W2NvcmVdCg==", + "node_modules/left-pad/index.js": "bW9kdWxlLmV4cG9ydHMgPSAxOwo=", + "vendor/dep/dep.go": "cGFja2FnZSBkZXAK", + ".venv/lib/site.py": "cGFzcwo=", + "venv/lib/site.py": "cGFzcwo=", + "__pycache__/main.cpython-311.pyc": "AAE=", + "src/.hg/store.py": "cGFzcwo=", + "src/.svn/entries.py": "cGFzcwo=" + }, + "symlinks": { + "link_to_main.py": "main.py", + "broken_link.py": "does_not_exist.py", + "link_to_pkg": "pkg" + }, + "lines_of_code": 23, + "file_count": 18 +} diff --git a/go/internal/agents/recon/testdata/golden/run_recon.json b/go/internal/agents/recon/testdata/golden/run_recon.json new file mode 100644 index 0000000..1b489b5 --- /dev/null +++ b/go/internal/agents/recon/testdata/golden/run_recon.json @@ -0,0 +1,576 @@ +{ + "repo_path": "/fixtures/demo-repo", + "canned": { + "architecture": { + "app_type": "web_api", + "modules": [ + "auth | src/auth | Python | sessions", + "ui | web | TypeScript | ", + "api | src/api | python | dup-language", + "legacy | old | | no language" + ], + "entry_points": [ + "http | POST /login | src/routes.py:42 | false" + ], + "trust_boundaries": [ + "edge | external | internal | tls" + ], + "services": [ + "pg | database | localhost:5432 | password" + ], + "api_endpoints": [ + "GET | /users | list | src/api.py:15 | true | false" + ] + }, + "dependencies": { + "sbom": [ + "django | 3.2 | pypi | true | BSD" + ], + "known_cves": [ + "CVE-1 | django | 3.2 | 3.3 | 9.8 | true | true" + ], + "outdated": [ + "django | 3.2 | 5.0 | true" + ] + }, + "config_scanner": { + "secrets": [ + "api_key | src/config.py:7 | KEY=1 | high | false" + ], + "misconfigs": [ + "debug | deploy/prod.yaml:22 | DEBUG | on in prod | turn it off" + ] + }, + "data_flow": { + "flows": [ + "request.body | sql.execute | false | src/db.py" + ], + "sanitization_points": [ + "src/valid.py:12 | clean | escape | sqli" + ], + "sinks": [ + "sql | src/db.py:88 | execute | tainted" + ] + }, + "security_context": { + "auth_model": "jwt", + "auth_details": "bearer", + "crypto_usage": [ + "AES | 256 | GCM | data | false" + ], + "security_signals": [ + "Uses Flask-Login", + "HSTS header present", + "Runs in Docker", + "Uses Flask-Login" + ] + } + }, + "standard": { + "architecture": { + "app_type": "web_api", + "modules": [ + { + "name": "auth", + "path": "src/auth", + "language": "Python", + "description": "sessions", + "dependencies": [] + }, + { + "name": "ui", + "path": "web", + "language": "TypeScript", + "description": null, + "dependencies": [] + }, + { + "name": "api", + "path": "src/api", + "language": "python", + "description": "dup-language", + "dependencies": [] + }, + { + "name": "legacy", + "path": "old", + "language": "", + "description": "no language", + "dependencies": [] + } + ], + "entry_points": [ + { + "kind": "http", + "identifier": "POST /login", + "file_path": "src/routes.py", + "line": 42, + "method": null, + "route": "POST /login", + "auth_required": false + } + ], + "trust_boundaries": [ + { + "name": "edge", + "source_zone": "external", + "target_zone": "internal", + "description": "tls", + "enforcement": [] + } + ], + "services": [ + { + "name": "pg", + "service_type": "database", + "endpoint": "localhost:5432", + "purpose": null, + "auth_mechanism": "password" + } + ], + "api_surface": [ + { + "method": "GET", + "path": "/users", + "handler": "list", + "file_path": "src/api.py", + "line": 15, + "auth_required": true, + "rate_limited": false + } + ] + }, + "data_flows": { + "flows": [ + { + "source": "request.body", + "path": [], + "sink": "sql.execute", + "sanitized": false, + "files": [ + "src/db.py" + ] + } + ], + "sanitization_points": [ + { + "file_path": "src/valid.py", + "line": 12, + "function_name": "clean", + "sanitization_type": "escape", + "protects_against": [ + "sqli" + ] + } + ], + "sinks": [ + { + "sink_type": "sql", + "file_path": "src/db.py", + "line": 88, + "function_name": "execute", + "exploitability_notes": "tainted" + } + ] + }, + "dependencies": { + "sbom": [ + { + "name": "django", + "version": "3.2", + "ecosystem": "pypi", + "direct": true, + "license": "BSD" + } + ], + "known_cves": [ + { + "cve_id": "CVE-1", + "package": "django", + "installed_version": "3.2", + "fixed_version": "3.3", + "cvss_v4_score": 9.8, + "epss_score": null, + "direct": true, + "reachable": true + } + ], + "outdated": [ + { + "package": "django", + "current_version": "3.2", + "latest_version": "5.0", + "direct": true + } + ], + "direct_count": 1, + "transitive_count": 0 + }, + "config": { + "secrets": [ + { + "id": "", + "secret_type": "api_key", + "file_path": "src/config.py", + "line": 7, + "match": "KEY=1", + "confidence": "high", + "is_test_value": false + } + ], + "misconfigs": [ + { + "id": "", + "category": "debug", + "file_path": "deploy/prod.yaml", + "line": 22, + "key": "DEBUG", + "value": null, + "risk": "on in prod", + "remediation": "turn it off" + } + ] + }, + "security_context": { + "auth_model": "jwt", + "auth_details": "bearer", + "crypto_usage": [ + { + "algorithm": "AES", + "key_size": 256, + "mode": "GCM", + "usage_context": "data", + "is_weak": false + } + ], + "framework_security": [ + "Uses Flask-Login", + "Uses Flask-Login" + ], + "security_headers": [ + "HSTS header present" + ], + "deployment_signals": [ + "Runs in Docker" + ] + }, + "languages": [ + "python", + "typescript" + ], + "frameworks": [ + "Uses Flask-Login" + ], + "lines_of_code": 0, + "file_count": 0, + "recon_duration_seconds": 0.0 + }, + "quick": { + "architecture": { + "app_type": "web_api", + "modules": [ + { + "name": "auth", + "path": "src/auth", + "language": "Python", + "description": "sessions", + "dependencies": [] + }, + { + "name": "ui", + "path": "web", + "language": "TypeScript", + "description": null, + "dependencies": [] + }, + { + "name": "api", + "path": "src/api", + "language": "python", + "description": "dup-language", + "dependencies": [] + }, + { + "name": "legacy", + "path": "old", + "language": "", + "description": "no language", + "dependencies": [] + } + ], + "entry_points": [ + { + "kind": "http", + "identifier": "POST /login", + "file_path": "src/routes.py", + "line": 42, + "method": null, + "route": "POST /login", + "auth_required": false + } + ], + "trust_boundaries": [ + { + "name": "edge", + "source_zone": "external", + "target_zone": "internal", + "description": "tls", + "enforcement": [] + } + ], + "services": [ + { + "name": "pg", + "service_type": "database", + "endpoint": "localhost:5432", + "purpose": null, + "auth_mechanism": "password" + } + ], + "api_surface": [ + { + "method": "GET", + "path": "/users", + "handler": "list", + "file_path": "src/api.py", + "line": 15, + "auth_required": true, + "rate_limited": false + } + ] + }, + "data_flows": { + "flows": [], + "sanitization_points": [], + "sinks": [] + }, + "dependencies": { + "sbom": [ + { + "name": "django", + "version": "3.2", + "ecosystem": "pypi", + "direct": true, + "license": "BSD" + } + ], + "known_cves": [ + { + "cve_id": "CVE-1", + "package": "django", + "installed_version": "3.2", + "fixed_version": "3.3", + "cvss_v4_score": 9.8, + "epss_score": null, + "direct": true, + "reachable": true + } + ], + "outdated": [ + { + "package": "django", + "current_version": "3.2", + "latest_version": "5.0", + "direct": true + } + ], + "direct_count": 1, + "transitive_count": 0 + }, + "config": { + "secrets": [ + { + "id": "", + "secret_type": "api_key", + "file_path": "src/config.py", + "line": 7, + "match": "KEY=1", + "confidence": "high", + "is_test_value": false + } + ], + "misconfigs": [ + { + "id": "", + "category": "debug", + "file_path": "deploy/prod.yaml", + "line": 22, + "key": "DEBUG", + "value": null, + "risk": "on in prod", + "remediation": "turn it off" + } + ] + }, + "security_context": { + "auth_model": "unknown", + "auth_details": "unknown", + "crypto_usage": [], + "framework_security": [], + "security_headers": [], + "deployment_signals": [] + }, + "languages": [ + "python", + "typescript" + ], + "frameworks": [], + "lines_of_code": 0, + "file_count": 0, + "recon_duration_seconds": 0.0 + }, + "fast": { + "architecture": { + "app_type": "web_api", + "modules": [ + { + "name": "auth", + "path": "src/auth", + "language": "Python", + "description": "sessions", + "dependencies": [] + }, + { + "name": "ui", + "path": "web", + "language": "TypeScript", + "description": null, + "dependencies": [] + }, + { + "name": "api", + "path": "src/api", + "language": "python", + "description": "dup-language", + "dependencies": [] + }, + { + "name": "legacy", + "path": "old", + "language": "", + "description": "no language", + "dependencies": [] + } + ], + "entry_points": [ + { + "kind": "http", + "identifier": "POST /login", + "file_path": "src/routes.py", + "line": 42, + "method": null, + "route": "POST /login", + "auth_required": false + } + ], + "trust_boundaries": [ + { + "name": "edge", + "source_zone": "external", + "target_zone": "internal", + "description": "tls", + "enforcement": [] + } + ], + "services": [ + { + "name": "pg", + "service_type": "database", + "endpoint": "localhost:5432", + "purpose": null, + "auth_mechanism": "password" + } + ], + "api_surface": [ + { + "method": "GET", + "path": "/users", + "handler": "list", + "file_path": "src/api.py", + "line": 15, + "auth_required": true, + "rate_limited": false + } + ] + }, + "data_flows": { + "flows": [], + "sanitization_points": [], + "sinks": [] + }, + "dependencies": { + "sbom": [ + { + "name": "django", + "version": "3.2", + "ecosystem": "pypi", + "direct": true, + "license": "BSD" + } + ], + "known_cves": [ + { + "cve_id": "CVE-1", + "package": "django", + "installed_version": "3.2", + "fixed_version": "3.3", + "cvss_v4_score": 9.8, + "epss_score": null, + "direct": true, + "reachable": true + } + ], + "outdated": [ + { + "package": "django", + "current_version": "3.2", + "latest_version": "5.0", + "direct": true + } + ], + "direct_count": 1, + "transitive_count": 0 + }, + "config": { + "secrets": [ + { + "id": "", + "secret_type": "api_key", + "file_path": "src/config.py", + "line": 7, + "match": "KEY=1", + "confidence": "high", + "is_test_value": false + } + ], + "misconfigs": [ + { + "id": "", + "category": "debug", + "file_path": "deploy/prod.yaml", + "line": 22, + "key": "DEBUG", + "value": null, + "risk": "on in prod", + "remediation": "turn it off" + } + ] + }, + "security_context": { + "auth_model": "unknown", + "auth_details": "unknown", + "crypto_usage": [], + "framework_security": [], + "security_headers": [], + "deployment_signals": [] + }, + "languages": [ + "python", + "typescript" + ], + "frameworks": [], + "lines_of_code": 0, + "file_count": 0, + "recon_duration_seconds": 0.0 + } +} diff --git a/go/internal/agents/recon/testdata/golden/security_context_prompt_A.txt b/go/internal/agents/recon/testdata/golden/security_context_prompt_A.txt new file mode 100644 index 0000000..4c13d5d --- /dev/null +++ b/go/internal/agents/recon/testdata/golden/security_context_prompt_A.txt @@ -0,0 +1,105 @@ +ROLE: +You are a security context profiler focused on authentication, crypto posture, and deployment security signals. + +CONTEXT: +You are in SEC-AF RECON Phase and must produce the required JSON output. +Architecture context is provided below. + +ARCHITECTURE_MAP: +{ + "app_type": "web_api", + "modules": [ + { + "name": "auth", + "path": "src/auth/", + "language": "Python", + "description": "Sessions & tokens", + "dependencies": [ + "db", + "cache" + ] + }, + { + "name": "ui", + "path": "web/", + "language": "TypeScript", + "description": null, + "dependencies": [] + } + ], + "entry_points": [ + { + "kind": "http", + "identifier": "POST /api/login", + "file_path": "src/routes.py", + "line": 42, + "method": "POST", + "route": "/api/login", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "migrate", + "file_path": "src/cli.py", + "line": 8, + "method": null, + "route": null, + "auth_required": null + } + ], + "trust_boundaries": [ + { + "name": "API Gateway", + "source_zone": "external", + "target_zone": "internal", + "description": "Rate limiting auth \u2014 caf\u00e9 \u2192 app", + "enforcement": [ + "waf" + ] + } + ], + "services": [ + { + "name": "PostgreSQL", + "service_type": "database", + "endpoint": "localhost:5432", + "purpose": "primary store", + "auth_mechanism": "password" + } + ], + "api_surface": [ + { + "method": "GET", + "path": "/api/users", + "handler": "get_users", + "file_path": "src/api.py", + "line": 15, + "auth_required": true, + "rate_limited": false + } + ] +} + +TASK: +Profile the security model of this codebase: auth approach, crypto usage, framework protections, security headers, deployment signals. + +SCOPE: +- Identify auth model (JWT/session/OAuth2/API key/none/unknown) and evidence. +- Extract crypto algorithms, key sizes, modes, and weak usage where present. +- Capture framework-level protections and missing/used security headers. +- Infer deployment signals from infrastructure and runtime config. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Fill auth_model and auth_details, plus crypto_usage, framework_security, security_headers, deployment_signals. + +CONSTRAINTS: +- Take multiple turns to explore the codebase first, then build your analysis. +- Prefer explicit evidence over assumptions; if uncertain, state unknown in fields. +- No markdown/code fences; JSON output only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Take multiple turns to explore the codebase first, then build your analysis. +- Write final JSON only when analysis is complete. \ No newline at end of file diff --git a/go/internal/agents/recon/testdata/golden/security_context_prompt_B.txt b/go/internal/agents/recon/testdata/golden/security_context_prompt_B.txt new file mode 100644 index 0000000..bb880a9 --- /dev/null +++ b/go/internal/agents/recon/testdata/golden/security_context_prompt_B.txt @@ -0,0 +1,40 @@ +ROLE: +You are a security context profiler focused on authentication, crypto posture, and deployment security signals. + +CONTEXT: +You are in SEC-AF RECON Phase and must produce the required JSON output. +Architecture context is provided below. + +ARCHITECTURE_MAP: +{ + "app_type": null, + "modules": [], + "entry_points": [], + "trust_boundaries": [], + "services": [], + "api_surface": [] +} + +TASK: +Profile the security model of this codebase: auth approach, crypto usage, framework protections, security headers, deployment signals. + +SCOPE: +- Identify auth model (JWT/session/OAuth2/API key/none/unknown) and evidence. +- Extract crypto algorithms, key sizes, modes, and weak usage where present. +- Capture framework-level protections and missing/used security headers. +- Infer deployment signals from infrastructure and runtime config. + +OUTPUT: +- Return JSON that strictly matches the schema provided below. +- Fill auth_model and auth_details, plus crypto_usage, framework_security, security_headers, deployment_signals. + +CONSTRAINTS: +- Take multiple turns to explore the codebase first, then build your analysis. +- Prefer explicit evidence over assumptions; if uncertain, state unknown in fields. +- No markdown/code fences; JSON output only. + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Take multiple turns to explore the codebase first, then build your analysis. +- Write final JSON only when analysis is complete. \ No newline at end of file diff --git a/go/internal/agents/remediation/remediation.go b/go/internal/agents/remediation/remediation.go new file mode 100644 index 0000000..bb86aed --- /dev/null +++ b/go/internal/agents/remediation/remediation.go @@ -0,0 +1,251 @@ +// Package remediation ports src/sec_af/agents/remediation.py — the agent that +// turns a finding into a concrete patch by running the coding harness against +// the repository. +// +// The Python module exposes two entry points that build the SAME prompt +// template (src/sec_af/prompts/remediation.txt) from two DIFFERENT finding +// shapes: +// +// - run_remediation(app, repo_path, finding: RawFinding, verdict, rationale) +// — the HUNT-shaped finding plus an externally supplied verdict/rationale +// (reasoners/prove.py run_remediation_agent); +// - generate_remediation(app, repo_path, finding: VerifiedFinding) — the +// PROVE-shaped finding, whose location/proof/verdict/rationale are read off +// the model itself (reasoners/prove.py run_remediation). +// +// Both then run the harness in a throwaway cwd with project_dir=repo_path and +// funnel the result through extract_harness_result(..., "RemediationAgent"). +package remediation + +import ( + "context" + "os" + "strconv" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/harnessx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// promptRel is the Go form of remediation.py's +// +// PROMPT_PATH = Path(__file__).resolve().parents[1] / "prompts" / "remediation.txt" +// +// i.e. src/sec_af/prompts/remediation.txt, embedded by internal/prompts. +const promptRel = "remediation.txt" + +// agentName is the label extract_harness_result is called with. Python passes +// the literal "RemediationAgent" from BOTH entry points (note it differs from +// the "remediation" used for the tempdir prefix). +const agentName = "RemediationAgent" + +// replacement is one {{PLACEHOLDER}} -> value substitution. +// +// Order matters and is preserved: Python iterates a dict literal, which is +// insertion-ordered, and applies str.replace one key at a time, so a value that +// happens to contain a later placeholder WOULD itself be substituted. Reusing +// the same ordered list keeps that (admittedly pathological) behaviour. +type replacement struct { + needle string + value string +} + +func applyReplacements(template string, reps []replacement) string { + prompt := template + for _, r := range reps { + prompt = strings.ReplaceAll(prompt, r.needle, r.value) + } + return prompt +} + +// contextSuffix ports the identical trailer both entry points append: +// +// "\n\nCONTEXT:\n" + f"- Repository path: {repo_path}\n" +// + "- Use the repository path to inspect the actual source code for accurate patch generation." +func contextSuffix(repoPath string) string { + return "\n\nCONTEXT:\n" + + "- Repository path: " + repoPath + "\n" + + "- Use the repository path to inspect the actual source code for accurate patch generation." +} + +// buildPrompt ports src/sec_af/agents/remediation.py _build_prompt (the RawFinding form). +func buildPrompt(template string, finding schemas.RawFinding, verdict, rationale string) string { + return applyReplacements(template, []replacement{ + {"{{TITLE}}", finding.Title}, + {"{{DESCRIPTION}}", finding.Description}, + {"{{CWE_ID}}", finding.CweID}, + {"{{CWE_NAME}}", finding.CweName}, + {"{{FILE_PATH}}", finding.FilePath}, + {"{{START_LINE}}", strconv.Itoa(finding.StartLine)}, + {"{{CODE_SNIPPET}}", finding.CodeSnippet}, + {"{{FINDING_TYPE}}", string(finding.FindingType)}, + {"{{VERDICT}}", verdict}, + {"{{RATIONALE}}", rationale}, + {"{{RELATED_FILES}}", jsonDumpsStrings(finding.RelatedFiles, 2)}, + }) +} + +// buildVerifiedPrompt ports the inline replacement table inside +// generate_remediation — the VerifiedFinding adaptation. +// +// Python reaches every value through getattr with a default, because it types +// the parameter `Any` and wants to tolerate a RawFinding too. VerifiedFinding +// is a concrete Go struct, so the getattr chain collapses to direct field reads; +// the two places where the Python defaults are observable are commented below. +func buildVerifiedPrompt(template string, finding schemas.VerifiedFinding) string { + // Python: `location = getattr(finding, "location", None)`, then + // `getattr(location, "file_path", "") if location else getattr(finding, "file_path", "")`. + // VerifiedFinding.location is a REQUIRED pydantic model, and a BaseModel + // instance is always truthy, so the location branch always wins for the + // VerifiedFinding this function is called with. + filePath := finding.Location.FilePath + startLine := finding.Location.StartLine + + // Python: `(proof.vulnerable_code or "") if proof else ""` — a missing proof + // and a proof with a null vulnerable_code both yield "". + codeSnippet := "" + if finding.Proof != nil && finding.Proof.VulnerableCode != nil { + codeSnippet = *finding.Proof.VulnerableCode + } + + // Python: `[loc.file_path for loc in related_locs] if related_locs else []`. + relatedFiles := make([]string, 0, len(finding.RelatedLocations)) + for _, loc := range finding.RelatedLocations { + relatedFiles = append(relatedFiles, loc.FilePath) + } + + // Python: `str(verdict_val.value) if hasattr(verdict_val, "value") else str(verdict_val)`, + // over `getattr(finding, "verdict", "confirmed")`. The "confirmed" default + // only fires for an object with no verdict attribute at all, which a + // VerifiedFinding never is; the Go enum already IS its value. + verdictStr := string(finding.Verdict) + + // Python: `str(finding_type_val.value) if hasattr(...) else str(finding_type_val or "")`. + findingTypeStr := string(finding.FindingType) + + return applyReplacements(template, []replacement{ + {"{{TITLE}}", finding.Title}, + {"{{DESCRIPTION}}", finding.Description}, + {"{{CWE_ID}}", finding.CweID}, + {"{{CWE_NAME}}", finding.CweName}, + {"{{FILE_PATH}}", filePath}, + {"{{START_LINE}}", strconv.Itoa(startLine)}, + {"{{CODE_SNIPPET}}", codeSnippet}, + {"{{FINDING_TYPE}}", findingTypeStr}, + {"{{VERDICT}}", verdictStr}, + {"{{RATIONALE}}", finding.Rationale}, + {"{{RELATED_FILES}}", jsonDumpsStrings(relatedFiles, 2)}, + }) +} + +// RunRemediation ports src/sec_af/agents/remediation.py run_remediation. +// +// async def run_remediation(app, repo_path, finding: RawFinding, verdict, rationale) -> RemediationSuggestion +// +// The tempdir prefix is Python's f"secaf-{agent_name}-" with agent_name = +// "remediation", i.e. "secaf-remediation-" — the same prefix generate_remediation +// hardcodes. +func RunRemediation( + ctx context.Context, + app appx.Harnesser, + repoPath string, + finding schemas.RawFinding, + verdict string, + rationale string, +) (schemas.RemediationSuggestion, error) { + template, err := prompts.Load(promptRel) + if err != nil { + // Python: PROMPT_PATH.read_text() raises OSError out of the reasoner. + return schemas.RemediationSuggestion{}, err + } + prompt := buildPrompt(template, finding, verdict, rationale) + contextSuffix(repoPath) + + harnessCwd, err := os.MkdirTemp("", "secaf-remediation-") + if err != nil { + return schemas.RemediationSuggestion{}, err + } + // Ports `finally: shutil.rmtree(harness_cwd, ignore_errors=True)`. + defer os.RemoveAll(harnessCwd) + + return harnessx.RunExtract[schemas.RemediationSuggestion]( + ctx, app, prompt, + harness.Options{Cwd: harnessCwd, ProjectDir: repoPath}, + agentName, + ) +} + +// GenerateRemediation ports src/sec_af/agents/remediation.py generate_remediation. +// +// async def generate_remediation(app, repo_path, finding: Any) -> RemediationSuggestion +// """Adapts VerifiedFinding (location-based) to the prompt template expected by run_remediation.""" +// +// It is NOT a wrapper around RunRemediation in Python (the replacement table is +// duplicated inline) and is not one here either, so the two prompt builders can +// drift independently exactly as the Python ones can. +func GenerateRemediation( + ctx context.Context, + app appx.Harnesser, + repoPath string, + finding schemas.VerifiedFinding, +) (schemas.RemediationSuggestion, error) { + template, err := prompts.Load(promptRel) + if err != nil { + return schemas.RemediationSuggestion{}, err + } + prompt := buildVerifiedPrompt(template, finding) + contextSuffix(repoPath) + + harnessCwd, err := os.MkdirTemp("", "secaf-remediation-") + if err != nil { + return schemas.RemediationSuggestion{}, err + } + defer os.RemoveAll(harnessCwd) + + return harnessx.RunExtract[schemas.RemediationSuggestion]( + ctx, app, prompt, + harness.Options{Cwd: harnessCwd, ProjectDir: repoPath}, + agentName, + ) +} + +// jsonDumpsStrings reproduces Python's `json.dumps(list_of_str, indent=indent)` +// for the one shape remediation.py needs: a flat list of strings. +// +// encoding/json cannot be used directly, because the two libraries disagree on +// escaping in both directions: +// +// - Python defaults to ensure_ascii=True, so every code point >= 0x7f becomes +// \uXXXX (a surrogate PAIR above the BMP); Go emits it as raw UTF-8. +// - Go's Marshal HTML-escapes <, > and & into <, >, &; Python +// leaves them alone. +// +// The related_files values are file paths, so in practice both quirks are +// invisible — but the prompt reaches the LLM byte-for-byte and the golden tests +// compare bytes, so the port matches Python exactly rather than nearly. +// +// A nil slice renders as "[]": Python's related_files is +// `Field(default_factory=list)` and can never be None, so Go's nil is the empty +// list, not JSON null. +// +// The encoding itself is pyfmt.Dumps, the port's single CPython json.dumps +// implementation (DESIGN §2b) — this wrapper only pins the list-vs-None +// question below. It replaces an earlier package-local copy of the same +// escaping rules, written before pyfmt.Dumps existed; TestJSONDumpsStrings +// (whose expectations were taken from the venv interpreter) is unchanged and +// still passes, which is what proves the two encoders agree. +func jsonDumpsStrings(values []string, indent int) string { + // pyfmt.Dumps renders a NIL Go slice as `null` (encoding/json parity). The + // two Python fields this feeds — RawFinding.related_files and the list + // built from VerifiedFinding.related_locations — are pydantic + // `Field(default_factory=list)` and a list comprehension respectively, so + // Python has `[]` there and never None. Normalizing is what keeps the + // prompt bytes equal. + if values == nil { + values = []string{} + } + return pyfmt.Dumps(values, indent) +} diff --git a/go/internal/agents/remediation/remediation_test.go b/go/internal/agents/remediation/remediation_test.go new file mode 100644 index 0000000..942a188 --- /dev/null +++ b/go/internal/agents/remediation/remediation_test.go @@ -0,0 +1,386 @@ +package remediation + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/prompts" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// --------------------------------------------------------------------------- +// fixtures / helpers +// --------------------------------------------------------------------------- + +const fixtureRepo = "/fixtures/demo-repo" + +func str(s string) *string { return &s } + +// rawFinding mirrors gen_golden.py's _s6_raw_finding defaults. +func rawFinding(mut func(f *schemas.RawFinding)) schemas.RawFinding { + f := schemas.NewRawFinding() + f.ID = "F1" + f.HunterStrategy = "injection" + f.Title = "SQL injection in user lookup" + f.Description = "User-controlled `user_id` flows into a raw SQL string." + f.FindingType = schemas.FindingTypeSast + f.CweID = "CWE-89" + f.CweName = "SQL Injection" + f.FilePath = "src/db/users.py" + f.StartLine = 42 + f.EndLine = 44 + f.CodeSnippet = `cur.execute("SELECT * FROM users WHERE id = " + user_id)` + f.EstimatedSeverity = schemas.SeverityCritical + f.Confidence = schemas.ConfidenceHigh + f.RelatedFiles = []string{} + f.Fingerprint = "fp-F1" + if mut != nil { + mut(&f) + } + return f +} + +// verifiedFinding mirrors gen_golden.py's _s6_verified_finding. +func verifiedFinding(withProof bool) schemas.VerifiedFinding { + v := schemas.NewVerifiedFinding() + v.ID = "V1" + v.Fingerprint = "fp-V1" + v.Title = "SQL injection in user lookup" + v.Description = "User-controlled `user_id` flows into a raw SQL string." + v.FindingType = schemas.FindingTypeSast + v.CweID = "CWE-89" + v.CweName = "SQL Injection" + v.Verdict = schemas.VerdictConfirmed + v.EvidenceLevel = schemas.EvidenceLevelExploitScenarioValidated + v.Rationale = "Tainted parameter reaches the sink with no sanitization." + v.Severity = schemas.SeverityCritical + v.ExploitabilityScore = 8.5 + v.Location = schemas.Location{FilePath: "src/db/users.py", StartLine: 42, EndLine: 44} + v.SarifRuleID = "secaf/cwe-89" + v.SarifSecuritySeverity = 9.0 + if withProof { + v.Proof = &schemas.Proof{ + ExploitHypothesis: "Attacker supplies `1 OR 1=1` to dump the users table.", + VerificationMethod: "static", + EvidenceLevel: schemas.EvidenceLevelExploitScenarioValidated, + VulnerableCode: str(`cur.execute("SELECT * FROM users WHERE id = " + user_id)`), + } + v.RelatedLocations = []schemas.Location{ + {FilePath: "src/api/routes.py", StartLine: 10, EndLine: 12}, + {FilePath: `src/db/naïve_"cache".py`, StartLine: 3, EndLine: 3}, + } + } + return v +} + +// suggestionFake answers every harness call with a canned RemediationSuggestion. +func suggestionFake() *appx.Fake { + return &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return json.RawMessage(`{"fix_description":"Use a parameterized query.","patch_diff":"--- a/x\n+++ b/x\n","confidence":"high"}`), nil + })} +} + +func golden(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "golden", name+".txt")) + if err != nil { + t.Fatalf("read golden %s: %v", name, err) + } + return string(b) +} + +func assertGolden(t *testing.T, name, got string) { + t.Helper() + if want := golden(t, name); got != want { + t.Errorf("prompt does not match golden %s.txt\n--- got (%d bytes) ---\n%s\n--- want (%d bytes) ---\n%s", + name, len(got), got, len(want), want) + } +} + +// capture runs fn and returns the single prompt the harness was handed. +func capture(t *testing.T, fake *appx.Fake, fn func() error) string { + t.Helper() + if err := fn(); err != nil { + t.Fatalf("call: %v", err) + } + if len(fake.Harnesses) != 1 { + t.Fatalf("want 1 harness call, got %d", len(fake.Harnesses)) + } + return fake.Harnesses[0].Prompt +} + +// --------------------------------------------------------------------------- +// golden prompts +// --------------------------------------------------------------------------- +// +// Regenerate with: +// +// PYTHONPATH=src ~/.agentfield/packages/sec-af/venv/bin/python go/scripts/gen_golden.py +// +// (section "S6" of that script drives the real Python run_remediation / +// generate_remediation with these exact fixtures).// +// NOTE (integration): the "S6" section named above is NO LONGER PRESENT in +// go/scripts/gen_golden.py — it was lost when several agents rewrote that file +// concurrently during the port. Running the script does NOT refresh these +// files. The committed goldens ARE the ones that section produced from the real +// Python functions, and this test still guards them; but if the Python prompt +// builder changes, re-derive them by hand from the fixtures below (or restore +// the section) rather than trusting the script. See the COVERAGE GAP comment in +// gen_golden.py. + +func TestGolden_RunRemediationPrompt(t *testing.T) { + fake := suggestionFake() + finding := rawFinding(func(f *schemas.RawFinding) { + f.RelatedFiles = []string{"src/api/routes.py", `src/db/naïve_"cache".py`, "src/&co.py"} + }) + got := capture(t, fake, func() error { + _, err := RunRemediation(context.Background(), fake, fixtureRepo, finding, + "confirmed", "Tainted parameter reaches the sink with no sanitization.") + return err + }) + assertGolden(t, "run_prompt", got) +} + +func TestGolden_RunRemediationPromptEmptyRelatedFiles(t *testing.T) { + fake := suggestionFake() + got := capture(t, fake, func() error { + _, err := RunRemediation(context.Background(), fake, fixtureRepo, rawFinding(nil), "likely", "") + return err + }) + assertGolden(t, "run_prompt_empty", got) +} + +func TestGolden_GenerateRemediationPrompt(t *testing.T) { + fake := suggestionFake() + got := capture(t, fake, func() error { + _, err := GenerateRemediation(context.Background(), fake, fixtureRepo, verifiedFinding(true)) + return err + }) + assertGolden(t, "generate_prompt", got) +} + +func TestGolden_GenerateRemediationPromptMinimal(t *testing.T) { + fake := suggestionFake() + got := capture(t, fake, func() error { + _, err := GenerateRemediation(context.Background(), fake, fixtureRepo, verifiedFinding(false)) + return err + }) + assertGolden(t, "generate_prompt_min", got) +} + +// --------------------------------------------------------------------------- +// prompt construction +// --------------------------------------------------------------------------- + +func TestBuildPrompt_SubstitutesEveryPlaceholder(t *testing.T) { + template := prompts.MustLoad(promptRel) + got := buildPrompt(template, rawFinding(nil), "confirmed", "because") + if strings.Contains(got, "{{") { + t.Errorf("unsubstituted placeholder left in prompt:\n%s", got) + } + for _, want := range []string{ + "Finding: SQL injection in user lookup", + "CWE: CWE-89 (SQL Injection)", + "Type: sast", + "File: src/db/users.py:42", + "Verdict: confirmed", + "Rationale: because", + } { + if !strings.Contains(got, want) { + t.Errorf("prompt missing %q", want) + } + } +} + +func TestBuildVerifiedPrompt_ReadsLocationProofAndRelatedLocations(t *testing.T) { + template := prompts.MustLoad(promptRel) + got := buildVerifiedPrompt(template, verifiedFinding(true)) + if strings.Contains(got, "{{") { + t.Errorf("unsubstituted placeholder left in prompt:\n%s", got) + } + for _, want := range []string{ + "File: src/db/users.py:42", + "Verdict: confirmed", + "Rationale: Tainted parameter reaches the sink with no sanitization.", + `cur.execute("SELECT * FROM users WHERE id = " + user_id)`, + `"src/api/routes.py"`, + } { + if !strings.Contains(got, want) { + t.Errorf("prompt missing %q", want) + } + } +} + +// Python: `(proof.vulnerable_code or "") if proof else ""`. +func TestBuildVerifiedPrompt_NilProofYieldsEmptySnippet(t *testing.T) { + template := prompts.MustLoad(promptRel) + got := buildVerifiedPrompt(template, verifiedFinding(false)) + if !strings.Contains(got, "Vulnerable code:\n\n") { + t.Errorf("want an empty vulnerable-code block, got:\n%s", got) + } + if !strings.Contains(got, "Related files:\n[]\n") { + t.Errorf("want an empty related-files list, got:\n%s", got) + } +} + +// A proof present but with a null vulnerable_code is Python's `or ""` branch. +func TestBuildVerifiedPrompt_NullVulnerableCodeYieldsEmptySnippet(t *testing.T) { + v := verifiedFinding(true) + v.Proof.VulnerableCode = nil + got := buildVerifiedPrompt(prompts.MustLoad(promptRel), v) + if !strings.Contains(got, "Vulnerable code:\n\n") { + t.Errorf("want an empty vulnerable-code block, got:\n%s", got) + } +} + +func TestContextSuffix(t *testing.T) { + want := "\n\nCONTEXT:\n" + + "- Repository path: /repo\n" + + "- Use the repository path to inspect the actual source code for accurate patch generation." + if got := contextSuffix("/repo"); got != want { + t.Errorf("contextSuffix = %q, want %q", got, want) + } +} + +// --------------------------------------------------------------------------- +// harness interaction +// --------------------------------------------------------------------------- + +func assertTempDirLifecycle(t *testing.T, run func(app appx.Harnesser) error) { + t.Helper() + var seenCwd string + var existedDuringCall bool + fake := &appx.Fake{HarnessFn: func(_ context.Context, _ string, _ map[string]any, dest any, opts harness.Options) (*harness.Result, error) { + seenCwd = opts.Cwd + if st, err := os.Stat(opts.Cwd); err == nil && st.IsDir() { + existedDuringCall = true + } + if err := json.Unmarshal([]byte(`{"fix_description":"f","patch_diff":"d","confidence":"high"}`), dest); err != nil { + return nil, err + } + return &harness.Result{Parsed: dest}, nil + }} + if err := run(fake); err != nil { + t.Fatalf("call: %v", err) + } + if !existedDuringCall { + t.Error("harness cwd did not exist during the call") + } + if base := filepath.Base(seenCwd); !strings.HasPrefix(base, "secaf-remediation-") { + t.Errorf("harness cwd %q does not use the secaf-remediation- prefix", seenCwd) + } + if _, err := os.Stat(seenCwd); !os.IsNotExist(err) { + t.Errorf("harness cwd %q was not removed after the call (err=%v)", seenCwd, err) + } + if got, want := fake.Harnesses[0].Opts.ProjectDir, fixtureRepo; got != want { + t.Errorf("project_dir = %q, want %q", got, want) + } +} + +func TestRunRemediation_TempDirAndOptions(t *testing.T) { + assertTempDirLifecycle(t, func(app appx.Harnesser) error { + _, err := RunRemediation(context.Background(), app, fixtureRepo, rawFinding(nil), "confirmed", "r") + return err + }) +} + +func TestGenerateRemediation_TempDirAndOptions(t *testing.T) { + assertTempDirLifecycle(t, func(app appx.Harnesser) error { + _, err := GenerateRemediation(context.Background(), app, fixtureRepo, verifiedFinding(true)) + return err + }) +} + +func TestRunRemediation_ReturnsParsedSuggestion(t *testing.T) { + fake := suggestionFake() + got, err := RunRemediation(context.Background(), fake, fixtureRepo, rawFinding(nil), "confirmed", "r") + if err != nil { + t.Fatalf("RunRemediation: %v", err) + } + if got.FixDescription != "Use a parameterized query." || got.Confidence != "high" { + t.Errorf("suggestion = %+v", got) + } +} + +// extract_harness_result(..., "RemediationAgent") turns an is_error result into +// `RuntimeError(f"{agent_name} harness error: {error_message}")`. +func TestRemediation_HarnessErrorMapsToRemediationAgentError(t *testing.T) { + fake := &appx.Fake{HarnessFn: func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return &harness.Result{IsError: true, ErrorMessage: "provider exploded"}, nil + }} + for name, call := range map[string]func() error{ + "RunRemediation": func() error { + _, err := RunRemediation(context.Background(), fake, fixtureRepo, rawFinding(nil), "confirmed", "r") + return err + }, + "GenerateRemediation": func() error { + _, err := GenerateRemediation(context.Background(), fake, fixtureRepo, verifiedFinding(true)) + return err + }, + } { + err := call() + if err == nil { + t.Fatalf("%s: want an error", name) + } + if got, want := err.Error(), "RemediationAgent harness error: provider exploded"; got != want { + t.Errorf("%s: error = %q, want %q", name, got, want) + } + } +} + +// A result that neither errored nor parsed is Python's TypeError branch. +func TestRemediation_UnparsedResultMapsToTypeError(t *testing.T) { + fake := &appx.Fake{HarnessFn: func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return &harness.Result{Result: "not json"}, nil + }} + _, err := RunRemediation(context.Background(), fake, fixtureRepo, rawFinding(nil), "confirmed", "r") + if err == nil { + t.Fatal("want an error") + } + if got, want := err.Error(), "RemediationAgent did not return a valid RemediationSuggestion"; got != want { + t.Errorf("error = %q, want %q", got, want) + } +} + +// --------------------------------------------------------------------------- +// json.dumps parity +// --------------------------------------------------------------------------- + +// Ground truth captured from the sec-af venv interpreter: +// +// json.dumps(value, indent=2) +func TestJSONDumpsStrings(t *testing.T) { + cases := []struct { + in []string + want string + }{ + {nil, "[]"}, + {[]string{}, "[]"}, + {[]string{"a/b.py"}, "[\n \"a/b.py\"\n]"}, + {[]string{"a/b.py", "c.py"}, "[\n \"a/b.py\",\n \"c.py\"\n]"}, + // ensure_ascii=True: non-ASCII escaped, <>& left alone (Go's Marshal + // does the exact opposite on both counts). + {[]string{"café"}, "[\n \"caf\\u00e9\"\n]"}, + {[]string{"&y"}, "[\n \"&y\"\n]"}, + {[]string{"/slash"}, "[\n \"/slash\"\n]"}, + {[]string{`a"b\c`}, "[\n \"a\\\"b\\\\c\"\n]"}, + {[]string{"\n\t"}, "[\n \"\\n\\t\"\n]"}, + {[]string{"\x00"}, "[\n \"\\u0000\"\n]"}, + {[]string{"\x1f"}, "[\n \"\\u001f\"\n]"}, + {[]string{"\x7f"}, "[\n \"\\u007f\"\n]"}, + // Non-BMP: Python emits a surrogate PAIR. + {[]string{"😀"}, "[\n \"\\ud83d\\ude00\"\n]"}, + } + for _, c := range cases { + if got := jsonDumpsStrings(c.in, 2); got != c.want { + t.Errorf("jsonDumpsStrings(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/go/internal/agents/remediation/testdata/golden/generate_prompt.txt b/go/internal/agents/remediation/testdata/golden/generate_prompt.txt new file mode 100644 index 0000000..e9289c1 --- /dev/null +++ b/go/internal/agents/remediation/testdata/golden/generate_prompt.txt @@ -0,0 +1,48 @@ +ROLE: +You are a security remediation specialist for SEC-AF. You generate precise, production-ready code fixes. + +CONTEXT: +A security vulnerability has been identified and verified: + +Finding: SQL injection in user lookup +Description: User-controlled `user_id` flows into a raw SQL string. +CWE: CWE-89 (SQL Injection) +Type: sast +File: src/db/users.py:42 +Verdict: confirmed +Rationale: Tainted parameter reaches the sink with no sanitization. + +Vulnerable code: +cur.execute("SELECT * FROM users WHERE id = " + user_id) + +Related files: +[ + "src/api/routes.py", + "src/db/na\u00efve_\"cache\".py" +] + +TASK: +Generate a remediation suggestion with a concrete code patch: +1. Read the actual source file to understand full context +2. Determine the minimal fix that addresses the vulnerability +3. Generate a unified diff patch showing exact changes needed +4. Ensure the fix preserves existing functionality + +OUTPUT: +Return a JSON object matching RemediationSuggestion schema: +- fix_description: Clear explanation of what the fix does and why +- patch_diff: Unified diff format (--- a/file, +++ b/file, @@ lines) showing changes +- confidence: "high" if fix is straightforward, "medium" if context-dependent, "low" if complex + +CONSTRAINTS: +- Fix must be MINIMAL - change only what's needed to remediate +- Patch must be valid unified diff format +- Do not refactor surrounding code +- Preserve existing APIs and function signatures +- Use framework-idiomatic solutions (e.g., parameterized queries, not manual escaping) +- Do not return markdown or prose outside the JSON object + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Use the repository path to inspect the actual source code for accurate patch generation. \ No newline at end of file diff --git a/go/internal/agents/remediation/testdata/golden/generate_prompt_min.txt b/go/internal/agents/remediation/testdata/golden/generate_prompt_min.txt new file mode 100644 index 0000000..7e00e57 --- /dev/null +++ b/go/internal/agents/remediation/testdata/golden/generate_prompt_min.txt @@ -0,0 +1,45 @@ +ROLE: +You are a security remediation specialist for SEC-AF. You generate precise, production-ready code fixes. + +CONTEXT: +A security vulnerability has been identified and verified: + +Finding: SQL injection in user lookup +Description: User-controlled `user_id` flows into a raw SQL string. +CWE: CWE-89 (SQL Injection) +Type: sast +File: src/db/users.py:42 +Verdict: confirmed +Rationale: Tainted parameter reaches the sink with no sanitization. + +Vulnerable code: + + +Related files: +[] + +TASK: +Generate a remediation suggestion with a concrete code patch: +1. Read the actual source file to understand full context +2. Determine the minimal fix that addresses the vulnerability +3. Generate a unified diff patch showing exact changes needed +4. Ensure the fix preserves existing functionality + +OUTPUT: +Return a JSON object matching RemediationSuggestion schema: +- fix_description: Clear explanation of what the fix does and why +- patch_diff: Unified diff format (--- a/file, +++ b/file, @@ lines) showing changes +- confidence: "high" if fix is straightforward, "medium" if context-dependent, "low" if complex + +CONSTRAINTS: +- Fix must be MINIMAL - change only what's needed to remediate +- Patch must be valid unified diff format +- Do not refactor surrounding code +- Preserve existing APIs and function signatures +- Use framework-idiomatic solutions (e.g., parameterized queries, not manual escaping) +- Do not return markdown or prose outside the JSON object + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Use the repository path to inspect the actual source code for accurate patch generation. \ No newline at end of file diff --git a/go/internal/agents/remediation/testdata/golden/run_prompt.txt b/go/internal/agents/remediation/testdata/golden/run_prompt.txt new file mode 100644 index 0000000..b5facb6 --- /dev/null +++ b/go/internal/agents/remediation/testdata/golden/run_prompt.txt @@ -0,0 +1,49 @@ +ROLE: +You are a security remediation specialist for SEC-AF. You generate precise, production-ready code fixes. + +CONTEXT: +A security vulnerability has been identified and verified: + +Finding: SQL injection in user lookup +Description: User-controlled `user_id` flows into a raw SQL string. +CWE: CWE-89 (SQL Injection) +Type: sast +File: src/db/users.py:42 +Verdict: confirmed +Rationale: Tainted parameter reaches the sink with no sanitization. + +Vulnerable code: +cur.execute("SELECT * FROM users WHERE id = " + user_id) + +Related files: +[ + "src/api/routes.py", + "src/db/na\u00efve_\"cache\".py", + "src/&co.py" +] + +TASK: +Generate a remediation suggestion with a concrete code patch: +1. Read the actual source file to understand full context +2. Determine the minimal fix that addresses the vulnerability +3. Generate a unified diff patch showing exact changes needed +4. Ensure the fix preserves existing functionality + +OUTPUT: +Return a JSON object matching RemediationSuggestion schema: +- fix_description: Clear explanation of what the fix does and why +- patch_diff: Unified diff format (--- a/file, +++ b/file, @@ lines) showing changes +- confidence: "high" if fix is straightforward, "medium" if context-dependent, "low" if complex + +CONSTRAINTS: +- Fix must be MINIMAL - change only what's needed to remediate +- Patch must be valid unified diff format +- Do not refactor surrounding code +- Preserve existing APIs and function signatures +- Use framework-idiomatic solutions (e.g., parameterized queries, not manual escaping) +- Do not return markdown or prose outside the JSON object + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Use the repository path to inspect the actual source code for accurate patch generation. \ No newline at end of file diff --git a/go/internal/agents/remediation/testdata/golden/run_prompt_empty.txt b/go/internal/agents/remediation/testdata/golden/run_prompt_empty.txt new file mode 100644 index 0000000..53548d1 --- /dev/null +++ b/go/internal/agents/remediation/testdata/golden/run_prompt_empty.txt @@ -0,0 +1,45 @@ +ROLE: +You are a security remediation specialist for SEC-AF. You generate precise, production-ready code fixes. + +CONTEXT: +A security vulnerability has been identified and verified: + +Finding: SQL injection in user lookup +Description: User-controlled `user_id` flows into a raw SQL string. +CWE: CWE-89 (SQL Injection) +Type: sast +File: src/db/users.py:42 +Verdict: likely +Rationale: + +Vulnerable code: +cur.execute("SELECT * FROM users WHERE id = " + user_id) + +Related files: +[] + +TASK: +Generate a remediation suggestion with a concrete code patch: +1. Read the actual source file to understand full context +2. Determine the minimal fix that addresses the vulnerability +3. Generate a unified diff patch showing exact changes needed +4. Ensure the fix preserves existing functionality + +OUTPUT: +Return a JSON object matching RemediationSuggestion schema: +- fix_description: Clear explanation of what the fix does and why +- patch_diff: Unified diff format (--- a/file, +++ b/file, @@ lines) showing changes +- confidence: "high" if fix is straightforward, "medium" if context-dependent, "low" if complex + +CONSTRAINTS: +- Fix must be MINIMAL - change only what's needed to remediate +- Patch must be valid unified diff format +- Do not refactor surrounding code +- Preserve existing APIs and function signatures +- Use framework-idiomatic solutions (e.g., parameterized queries, not manual escaping) +- Do not return markdown or prose outside the JSON object + + +CONTEXT: +- Repository path: /fixtures/demo-repo +- Use the repository path to inspect the actual source code for accurate patch generation. \ No newline at end of file diff --git a/go/internal/aix/aix.go b/go/internal/aix/aix.go new file mode 100644 index 0000000..6e563bb --- /dev/null +++ b/go/internal/aix/aix.go @@ -0,0 +1,388 @@ +// Package aix is the Go form of SEC-AF's `.ai(...)` gate calls — the single +// structured LLM request, as opposed to the multi-turn coding-agent session +// that internal/harnessx drives. +// +// Python shape (src/sec_af/agents/prove/verdict.py:99, reasoners/phases.py:125, +// harness.py:420, agents/dedup.py:113, compliance/mapping.py:410): +// +// result = await app.ai(user=prompt, schema=VerdictDecision) +// result = await app.ai(system=system, user=prompt, schema=Model, model=...) +// +// The Python SDK turns `schema=Model` into an OpenAI structured-output request +// (agentfield/agent_ai.py:803): +// +// "schema": _strictify_openai_schema(schema.model_json_schema()) +// +// so the Go port must (a) start from the SAME pydantic schema — the committed +// fixture harnessx already embeds — and (b) apply the SAME strictification, +// because OpenAI's strict mode rejects a schema that omits +// additionalProperties:false or under-populates required. +package aix + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "reflect" + "sort" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/harnessx" +) + +// Structured performs `await app.ai(system=system, user=user, schema=T)` and +// returns the parsed T. +// +// system == "" means Python's `system=None`: no system message is added, which +// is what every SEC-AF call site except AIGateWrapper.invoke does. +// +// Decoding is TOLERANT and RETRIED exactly as the Python SDK's is — see +// parseStructured and maxParseRetries. +func Structured[T any](ctx context.Context, app appx.AIer, system, user string) (T, error) { + return StructuredOpts[T](ctx, app, system, user) +} + +// StructuredOpts is Structured with extra SDK options appended after the schema +// option — the seam AIGateWrapper.invoke needs, since it also passes +// `model=self.config.ai_model` (src/sec_af/harness.go:420-425). Keeping it +// separate leaves Structured's signature exactly the one the design doc +// specifies. +func StructuredOpts[T any](ctx context.Context, app appx.AIer, system, user string, extra ...ai.Option) (T, error) { + var v T + typeName := reflect.TypeOf((*T)(nil)).Elem().Name() + + raw, err := json.Marshal(StrictifyOrdered[T](harnessx.SchemaFor[T]())) + if err != nil { + return v, fmt.Errorf("aix.Structured[%s]: marshal schema: %w", typeName, err) + } + + opts := make([]ai.Option, 0, 2+len(extra)) + if system != "" { + opts = append(opts, ai.WithSystem(system)) + } + // json.RawMessage takes the SDK's pass-through branch: the bytes become + // response_format.json_schema.schema verbatim, with strict:true — the same + // request body the Python SDK builds. + opts = append(opts, ai.WithSchema(json.RawMessage(raw))) + opts = append(opts, extra...) + + // Python parity (agentfield/agent_ai.py:1061-1088): the SDK retries the + // WHOLE call — request AND parse — up to `max_parse_retries = 2` more + // times when the body cannot be decoded, i.e. 3 attempts in total. A + // TRANSPORT error is not retried here: Python's loop only catches the + // `ValueError("Could not parse structured response: ...")` that + // `_execute_and_parse` raises, and the SDK's own rate-limit/fallback + // retries sit below this layer. + var lastErr error + for attempt := 0; attempt <= maxParseRetries; attempt++ { + resp, err := app.AI(ctx, user, opts...) + if err != nil { + return v, fmt.Errorf("aix.Structured[%s]: %w", typeName, err) + } + if resp == nil { + // Go-only guard. Python would raise AttributeError inside + // detect_multimodal_response, which the `except ValueError` retry + // loop does not catch either — so, as here, no retry. + return v, fmt.Errorf("aix.Structured[%s]: nil response", typeName) + } + parsed, perr := parseStructured[T](resp.Text()) + if perr == nil { + return parsed, nil + } + lastErr = fmt.Errorf("aix.Structured[%s]: %w", typeName, perr) + } + return v, lastErr +} + +// maxParseRetries ports `max_parse_retries = 2` (agent_ai.py:960): two RETRIES +// on top of the first attempt, so three AI requests at most. +const maxParseRetries = 2 + +// parseStructured ports the tolerant structured-output decode the Python SDK +// performs after every `.ai(schema=...)` completion (agent_ai.py:1032-1060): +// +// try: +// json_data = json.loads(str(text)); return schema(**json_data) +// except (json.JSONDecodeError, ValueError, ValidationError): +// json_match = re.search(r"\{.*\}", str(text), re.DOTALL) +// if json_match: +// try: return schema(**json.loads(json_match.group())) +// except (...): pass +// raise ValueError(f"Could not parse structured response: {text}") +// +// i.e. a straight decode first, then a greedy first-`{`..last-`}` extraction, +// then give up. This is what lets a model that wraps its JSON in a ```json +// fence — or prefixes it with prose — still succeed; observed live with +// kimi-k2.5 on the run_verifier gate, where the strict Go decode failed the +// execution and the Python node did not. +// +// The returned error text reproduces Python's message verbatim, including the +// full (untruncated) body, because that string is what an operator reads out +// of a failed execution on either node. +// +// Known, pre-existing divergence: `schema(**data)` is pydantic validation and +// rejects a payload missing a required field, while json.Unmarshal happily +// leaves it zero. Go therefore accepts a few bodies Python would retry on. +func parseStructured[T any](text string) (T, error) { + var direct T + if err := json.Unmarshal([]byte(text), &direct); err == nil { + return direct, nil + } + if candidate, ok := extractJSONObject(text); ok { + // A fresh destination: a failed Unmarshal may have already written + // part of `direct`. + var extracted T + if err := json.Unmarshal([]byte(candidate), &extracted); err == nil { + return extracted, nil + } + } + var zero T + return zero, fmt.Errorf("Could not parse structured response: %s", text) //nolint:staticcheck // Python's message, verbatim +} + +// extractJSONObject is Go's `re.search(r"\{.*\}", text, re.DOTALL)`. +// +// The pattern is GREEDY and unanchored: the engine takes the earliest `{` it +// can start from, then backtracks `.*` to the LAST `}` in the string. If no +// `}` follows the first `{` there is no later `{` that could do better either, +// so the whole search fails — which is exactly the two-index form below. +// Indexing by byte is safe: `{` and `}` are ASCII and can never occur inside a +// UTF-8 continuation sequence. +func extractJSONObject(s string) (string, bool) { + start := strings.Index(s, "{") + if start < 0 { + return "", false + } + end := strings.LastIndex(s, "}") + if end <= start { + return "", false + } + return s[start : end+1], true +} + +// Strictify ports the Python SDK's _strictify_openai_schema +// (`agentfield.agent_ai._strictify_openai_schema`, sdk/python/agentfield/agent_ai.py): +// +// def walk(node): +// if isinstance(node, dict): +// node = {key: walk(value) for key, value in node.items()} +// props = node.get("properties") +// if isinstance(props, dict) and (node.get("type") == "object" or "type" not in node): +// node["additionalProperties"] = False +// node["required"] = list(props.keys()) +// return node +// if isinstance(node, list): +// return [walk(item) for item in node] +// return node +// +// i.e. EVERY dict that has a "properties" object and is either explicitly +// type:"object" or carries no "type" at all gets additionalProperties:false and +// a required list naming all of its properties — recursing first, so $defs, +// nested properties, items and anyOf branches are all covered. Forcing every +// property into required is OpenAI's documented strict-mode requirement; a +// genuinely optional field is expressed as nullable, not as absent-from-required. +// +// Two Go-specific notes: +// +// - The input is never mutated; a fresh tree is returned. +// - `required` is emitted in SORTED key order, because a bare +// `map[string]any` carries no insertion order to reproduce Python's +// `list(props.keys())`. The LIVE path does not go through here: it calls +// StrictifyOrdered[T], which recovers pydantic's declaration order from the +// Go struct. This entry point stays for the schema transform itself (and +// for the goldens, which are generated from the committed sort_keys=True +// fixtures, so their own property order is sorted). +func Strictify(schema map[string]any) map[string]any { + out, _ := strictifyNode(schema).(map[string]any) + return out +} + +func strictifyNode(node any) any { + switch x := node.(type) { + case map[string]any: + out := make(map[string]any, len(x)+2) + for k, v := range x { + out[k] = strictifyNode(v) + } + props, isObject := out["properties"].(map[string]any) + if isObject { + typ, hasType := out["type"] + // Python: `node.get("type") == "object" or "type" not in node`. + // A list-valued "type" (e.g. ["object","null"]) satisfies neither + // arm, in Python and here alike. + if (hasType && typ == "object") || !hasType { + out["additionalProperties"] = false + keys := make([]string, 0, len(props)) + for k := range props { + keys = append(keys, k) + } + sort.Strings(keys) + required := make([]any, len(keys)) + for i, k := range keys { + required[i] = k + } + out["required"] = required + } + } + return out + case []any: + out := make([]any, len(x)) + for i, e := range x { + out[i] = strictifyNode(e) + } + return out + default: + return node + } +} + +// StrictifyOrdered is Strictify plus pydantic's DECLARATION order. +// +// Python strictifies the LIVE `Model.model_json_schema()`, whose `properties` +// is an insertion-ordered dict in pydantic field-declaration order; +// `node["required"] = list(props.keys())` (agent_ai.py:319) therefore inherits +// that order, and litellm serialises the dict as-is. Go decodes the committed +// fixture into a `map[string]any`, which has no order at all, and +// `json.Marshal` sorts every map key — so without this the bytes in +// `response_format.json_schema.schema` differ from Python's for every +// `.ai(schema=...)` call. +// +// The order is recovered from the Go struct, which the port keeps in pydantic +// declaration order (harnessx.FieldOrders). It is applied to the ROOT object +// and to every `$defs` entry whose name matches a struct reachable from T — +// which is every object node pydantic emits, because a nested BaseModel always +// becomes a `$defs` entry. Property names with no matching Go field are +// appended in sorted order rather than dropped, so a drifted fixture degrades +// to a visible ordering difference instead of losing schema content. +// +// Residual: the key order WITHIN each node (title/type/description/...) is +// still sorted rather than pydantic's, because go/scripts/gen_schemas.py writes +// the fixtures with sort_keys=True and that order is gone before Go sees it. +// The finding this closes is about `properties` and `required`; those are the +// two the pydantic document orders meaningfully. +func StrictifyOrdered[T any](schema map[string]any) any { + return strictifyOrderedNode(schema, harnessx.FieldOrdersFor[T](), + reflect.TypeOf((*T)(nil)).Elem().Name()) +} + +// strictifyOrderedNode is strictifyNode with an ORDER for the node's own +// properties. typeName names the pydantic class this node describes ("" when +// the node is not a model root), which is how the $defs entries are matched. +func strictifyOrderedNode(node any, orders map[string][]string, typeName string) any { + switch x := node.(type) { + case map[string]any: + out := make(map[string]any, len(x)+2) + for k, v := range x { + switch k { + case "$defs": + defs, isMap := v.(map[string]any) + if !isMap { + out[k] = strictifyOrderedNode(v, orders, "") + continue + } + newDefs := make(map[string]any, len(defs)) + for defName, defSchema := range defs { + newDefs[defName] = strictifyOrderedNode(defSchema, orders, defName) + } + out[k] = newDefs + case "properties": + props, isMap := v.(map[string]any) + if !isMap { + // Python leaves a non-dict "properties" alone (and the + // strictify branch below never fires for it). + out[k] = strictifyOrderedNode(v, orders, "") + continue + } + strict := make(map[string]any, len(props)) + for name, sub := range props { + strict[name] = strictifyOrderedNode(sub, orders, "") + } + out[k] = strict + default: + out[k] = strictifyOrderedNode(v, orders, "") + } + } + props, isObject := out["properties"].(map[string]any) + if isObject { + typ, hasType := out["type"] + if (hasType && typ == "object") || !hasType { + names := orderedPropertyNames(props, orders[typeName]) + out["additionalProperties"] = false + ordered := make(jsonObject, len(names)) + required := make([]any, len(names)) + for i, name := range names { + ordered[i] = jsonField{Key: name, Value: props[name]} + required[i] = name + } + out["properties"] = ordered + out["required"] = required + } + } + return out + case []any: + out := make([]any, len(x)) + for i, e := range x { + out[i] = strictifyOrderedNode(e, orders, "") + } + return out + default: + return node + } +} + +// orderedPropertyNames lists props following `order`, appending anything the +// order does not mention in sorted order. +func orderedPropertyNames(props map[string]any, order []string) []string { + names := make([]string, 0, len(props)) + emitted := make(map[string]struct{}, len(props)) + for _, name := range order { + if _, ok := props[name]; ok { + names = append(names, name) + emitted[name] = struct{}{} + } + } + leftovers := make([]string, 0) + for name := range props { + if _, done := emitted[name]; !done { + leftovers = append(leftovers, name) + } + } + sort.Strings(leftovers) + return append(names, leftovers...) +} + +// jsonObject is a JSON object that marshals its members in the order given +// rather than in the sorted order encoding/json imposes on a map. +type jsonObject []jsonField + +type jsonField struct { + Key string + Value any +} + +func (o jsonObject) MarshalJSON() ([]byte, error) { + var b bytes.Buffer + b.WriteByte('{') + for i, f := range o { + if i > 0 { + b.WriteByte(',') + } + key, err := json.Marshal(f.Key) + if err != nil { + return nil, err + } + b.Write(key) + b.WriteByte(':') + value, err := json.Marshal(f.Value) + if err != nil { + return nil, err + } + b.Write(value) + } + b.WriteByte('}') + return b.Bytes(), nil +} diff --git a/go/internal/aix/aix_test.go b/go/internal/aix/aix_test.go new file mode 100644 index 0000000..db32186 --- /dev/null +++ b/go/internal/aix/aix_test.go @@ -0,0 +1,428 @@ +package aix + +import ( + "context" + "encoding/json" + "errors" + "os" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + + "github.com/Agent-Field/sec-af/go/internal/harnessx" +) + +// --------------------------------------------------------------------------- +// Strictify goldens +// +// testdata/*.json is produced by testdata/gen_strictify_golden.py, which runs +// the REAL Python SDK function this file ports +// (`agentfield.agent_ai._strictify_openai_schema`) over the committed pydantic +// fixtures and over a hand-written edge-case document. Regenerate with any +// Python that has the `agentfield` package installed: +// +// go/internal/aix/testdata/gen_strictify_golden.py +// --------------------------------------------------------------------------- + +func loadJSON(t *testing.T, path string, dest any) { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if err := json.Unmarshal(b, dest); err != nil { + t.Fatalf("decode %s: %v", path, err) + } +} + +// TestStrictifyMatchesPythonOnEveryFixture is the parity gate: for every schema +// SEC-AF actually strictifies at runtime, Go's output must equal what the Python +// SDK produced from the same document — same additionalProperties placement, +// same required lists (compared element-wise, so ORDER is checked too), same +// untouched nodes. +func TestStrictifyMatchesPythonOnEveryFixture(t *testing.T) { + var want map[string]any + loadJSON(t, "testdata/strictified_fixtures.json", &want) + + names := harnessx.FixtureNames() + if len(names) == 0 { + t.Fatal("harnessx embedded no fixtures") + } + if len(want) != len(names) { + t.Fatalf("golden has %d schemas, harnessx embeds %d — regenerate testdata/strictified_fixtures.json", + len(want), len(names)) + } + + for _, name := range names { + in, err := harnessx.LoadFixture(name) + if err != nil { + t.Errorf("%s: %v", name, err) + continue + } + got := Strictify(in) + if !reflect.DeepEqual(got, want[name]) { + gb, _ := json.MarshalIndent(got, "", " ") + wb, _ := json.MarshalIndent(want[name], "", " ") + t.Errorf("%s: Strictify diverged from python\n got: %s\nwant: %s", name, gb, wb) + } + } +} + +// TestStrictifyEdgeCases covers the walk branches the real fixtures do not +// reach: a properties-bearing node with NO "type" (strictified), a LIST-valued +// "type" (untouched), a non-dict "properties" value (untouched), an anyOf +// branch, nested items, and a stale required/additionalProperties pair that must +// be overwritten. +func TestStrictifyEdgeCases(t *testing.T) { + var in, want map[string]any + loadJSON(t, "testdata/edgecases_input.json", &in) + loadJSON(t, "testdata/edgecases_strict.json", &want) + + got := Strictify(in) + if !reflect.DeepEqual(got, want) { + gb, _ := json.MarshalIndent(got, "", " ") + wb, _ := json.MarshalIndent(want, "", " ") + t.Errorf("Strictify(edgecases) diverged from python\n got: %s\nwant: %s", gb, wb) + } +} + +// TestStrictifyDoesNotMutateInput — Python's walk rebuilds every dict, so the +// caller's schema is untouched. harnessx caches and shares the fixture map +// across goroutines, so mutating it would be a data race AND would corrupt the +// schema the harness path sends. +func TestStrictifyDoesNotMutateInput(t *testing.T) { + in := map[string]any{ + "type": "object", + "properties": map[string]any{ + "a": map[string]any{"type": "string"}, + }, + } + _ = Strictify(in) + if _, present := in["additionalProperties"]; present { + t.Error("Strictify mutated the input map") + } + if _, present := in["required"]; present { + t.Error("Strictify added required to the input map") + } +} + +// TestStrictifySharedFixtureIsNotMutated guards the specific aliasing hazard: +// Structured strictifies harnessx's CACHED fixture map. +func TestStrictifySharedFixtureIsNotMutated(t *testing.T) { + before, err := harnessx.LoadFixture("CWEExpansion") + if err != nil { + t.Fatalf("LoadFixture: %v", err) + } + cached := harnessx.SchemaFor[CWEExpansion]() + _ = Strictify(cached) + after := harnessx.SchemaFor[CWEExpansion]() + if !reflect.DeepEqual(after, before) { + t.Error("Strictify mutated the cached harnessx fixture") + } +} + +// --------------------------------------------------------------------------- +// Structured +// --------------------------------------------------------------------------- + +// CWEExpansion mirrors the pydantic gate model of the same name; the fixture is +// resolved by this Go type's NAME. +type CWEExpansion struct { + AdditionalCWEs []string `json:"additional_cwes"` + Rationale string `json:"rationale"` +} + +type fakeAI struct { + gotPrompt string + gotReq *ai.Request + calls int + + content string + err error +} + +func (f *fakeAI) AI(ctx context.Context, prompt string, opts ...ai.Option) (*ai.Response, error) { + f.calls++ + f.gotPrompt = prompt + + req := &ai.Request{} + for _, o := range opts { + if err := o(req); err != nil { + return nil, err + } + } + f.gotReq = req + + if f.err != nil { + return nil, f.err + } + return &ai.Response{ + Choices: []ai.Choice{{ + Message: ai.Message{ + Role: "assistant", + Content: []ai.ContentPart{{Type: "text", Text: f.content}}, + }, + }}, + }, nil +} + +func TestStructuredSendsStrictifiedPydanticSchema(t *testing.T) { + f := &fakeAI{content: `{"additional_cwes":["CWE-918"],"rationale":"ssrf"}`} + + got, err := Structured[CWEExpansion](context.Background(), f, "", "suggest CWEs") + if err != nil { + t.Fatalf("Structured: %v", err) + } + want := CWEExpansion{AdditionalCWEs: []string{"CWE-918"}, Rationale: "ssrf"} + if !reflect.DeepEqual(got, want) { + t.Errorf("Structured = %#v, want %#v", got, want) + } + + if f.gotPrompt != "suggest CWEs" { + t.Errorf("prompt = %q, want the `user=` argument", f.gotPrompt) + } + // system == "" is Python's system=None: NO system message is prepended. + if len(f.gotReq.Messages) != 0 { + t.Errorf("messages = %#v, want none for system==\"\"", f.gotReq.Messages) + } + rf := f.gotReq.ResponseFormat + if rf == nil || rf.Type != "json_schema" || rf.JSONSchema == nil { + t.Fatalf("response_format = %#v, want a json_schema block", rf) + } + if !rf.JSONSchema.Strict { + t.Error("json_schema.strict = false, want true (the SDK sets it for the RawMessage branch)") + } + + var sent map[string]any + if err := json.Unmarshal(rf.JSONSchema.Schema, &sent); err != nil { + t.Fatalf("sent schema is not JSON: %v", err) + } + if sent["title"] != "CWEExpansion" { + t.Errorf("sent schema title = %v, want the pydantic fixture's", sent["title"]) + } + if sent["additionalProperties"] != false { + t.Errorf("sent schema additionalProperties = %v, want false (strictified)", sent["additionalProperties"]) + } + if !reflect.DeepEqual(sent["required"], []any{"additional_cwes", "rationale"}) { + t.Errorf("sent schema required = %v, want every property", sent["required"]) + } +} + +func TestStructuredAddsSystemMessageWhenPresent(t *testing.T) { + f := &fakeAI{content: `{"additional_cwes":[],"rationale":""}`} + if _, err := Structured[CWEExpansion](context.Background(), f, "You are a gate.", "u"); err != nil { + t.Fatalf("Structured: %v", err) + } + if len(f.gotReq.Messages) != 1 || f.gotReq.Messages[0].Role != "system" { + t.Fatalf("messages = %#v, want one system message", f.gotReq.Messages) + } + if got := f.gotReq.Messages[0].Content[0].Text; got != "You are a gate." { + t.Errorf("system content = %q", got) + } +} + +func TestStructuredExtraOptionsAreApplied(t *testing.T) { + f := &fakeAI{content: `{"additional_cwes":[],"rationale":""}`} + if _, err := StructuredOpts[CWEExpansion](context.Background(), f, "", "u", ai.WithModel("minimax/minimax-m2.5")); err != nil { + t.Fatalf("StructuredOpts: %v", err) + } + if f.gotReq.Model != "minimax/minimax-m2.5" { + t.Errorf("model = %q, want the extra option applied (AIGateWrapper passes model=)", f.gotReq.Model) + } +} + +func TestStructuredPropagatesTransportError(t *testing.T) { + want := errors.New("429 rate limited") + f := &fakeAI{err: want} + _, err := Structured[CWEExpansion](context.Background(), f, "", "u") + if !errors.Is(err, want) { + t.Errorf("Structured error = %v, want the SDK error wrapped", err) + } + if err == nil || !strings.Contains(err.Error(), "CWEExpansion") { + t.Errorf("Structured error = %v, want the destination type named", err) + } + // Python parity: the `for attempt in range(max_parse_retries + 1)` loop + // catches only the parse ValueError, so a transport failure is NOT retried. + if f.calls != 1 { + t.Errorf("AI calls = %d, want 1 (a transport error must not be retried)", f.calls) + } +} + +// An empty completion is not a special case in Python: `json.loads("")` raises, +// the `\{.*\}` salvage finds nothing, and the SAME +// ValueError("Could not parse structured response: ") is raised and retried. +// Verified against agent_ai.py's `if schema:` branch. +func TestStructuredEmptyContentIsARetriedParseFailure(t *testing.T) { + f := &fakeAI{content: ""} + _, err := Structured[CWEExpansion](context.Background(), f, "", "u") + if err == nil { + t.Fatal("Structured should reject an empty completion") + } + if !strings.Contains(err.Error(), "Could not parse structured response") { + t.Errorf("Structured error = %v, want Python's parse-failure message", err) + } + if !strings.Contains(err.Error(), "CWEExpansion") { + t.Errorf("Structured error = %v, want the destination type named", err) + } + if f.calls != 3 { + t.Errorf("AI calls = %d, want 3 (1 + max_parse_retries)", f.calls) + } +} + +func TestStructuredMalformedJSONIsAnError(t *testing.T) { + f := &fakeAI{content: "I think the answer is CWE-918."} + _, err := Structured[CWEExpansion](context.Background(), f, "", "u") + if err == nil { + t.Fatal("Structured should reject non-JSON content") + } + if !strings.Contains(err.Error(), "CWEExpansion") || !strings.Contains(err.Error(), "I think the answer") { + t.Errorf("Structured error = %v, want the type and the offending content", err) + } + if f.calls != 3 { + t.Errorf("AI calls = %d, want 3 (1 + max_parse_retries)", f.calls) + } +} + +// --------------------------------------------------------------------------- +// F1 — tolerant structured-output parsing + parse retries +// +// Ports the Python SDK's post-completion decode (agent_ai.py:1032-1088), which +// SEC-AF's Python node inherits for free from `.ai(schema=...)`. Two live +// run_verifier executions on the Go node failed where Python's did not, because +// kimi-k2.5 wrapped its json_schema reply in a ```json fence. +// --------------------------------------------------------------------------- + +// scriptedAI answers each successive AI call with the next body in Bodies (the +// last body repeats once the script runs out), so a test can express "garbage, +// garbage, then valid". +type scriptedAI struct { + Bodies []string + calls int +} + +func (s *scriptedAI) AI(_ context.Context, _ string, _ ...ai.Option) (*ai.Response, error) { + body := s.Bodies[len(s.Bodies)-1] + if s.calls < len(s.Bodies) { + body = s.Bodies[s.calls] + } + s.calls++ + return &ai.Response{Choices: []ai.Choice{{ + Message: ai.Message{Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: body}}}, + }}}, nil +} + +// (a) and (b): the salvage step recovers a fenced body and a prose-wrapped one, +// on the FIRST attempt — no retry is needed or spent. +func TestStructuredSalvagesNonStrictBodies(t *testing.T) { + want := CWEExpansion{AdditionalCWEs: []string{"CWE-918"}, Rationale: "ssrf"} + const payload = `{"additional_cwes":["CWE-918"],"rationale":"ssrf"}` + + for _, tc := range []struct { + name string + body string + }{ + {"markdown fence", "```json\n" + payload + "\n```"}, + {"fence without a language", "```\n" + payload + "\n```"}, + {"prose before and after", "Sure! Here is the JSON: " + payload + " Hope that helps."}, + {"leading reasoning line", "Let me think.\n\n" + payload}, + } { + t.Run(tc.name, func(t *testing.T) { + f := &scriptedAI{Bodies: []string{tc.body}} + got, err := Structured[CWEExpansion](context.Background(), f, "", "u") + if err != nil { + t.Fatalf("Structured: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("Structured = %#v, want %#v", got, want) + } + if f.calls != 1 { + t.Errorf("AI calls = %d, want 1 (the salvage step must not cost a retry)", f.calls) + } + }) + } +} + +// (c): garbage twice, then a valid body — Python re-issues the whole request on +// a parse failure, so the third attempt succeeds and exactly 3 calls are made. +func TestStructuredRetriesTheRequestOnParseFailure(t *testing.T) { + f := &scriptedAI{Bodies: []string{ + "I cannot help with that.", + "still not json", + `{"additional_cwes":["CWE-79"],"rationale":"xss"}`, + }} + got, err := Structured[CWEExpansion](context.Background(), f, "", "u") + if err != nil { + t.Fatalf("Structured: %v", err) + } + want := CWEExpansion{AdditionalCWEs: []string{"CWE-79"}, Rationale: "xss"} + if !reflect.DeepEqual(got, want) { + t.Errorf("Structured = %#v, want %#v", got, want) + } + if f.calls != 3 { + t.Errorf("AI calls = %d, want 3", f.calls) + } +} + +// (d): garbage on every attempt — 3 calls, then Python's final message. +func TestStructuredGivesUpAfterThreeAttempts(t *testing.T) { + f := &scriptedAI{Bodies: []string{"nope", "nope", "nope", "nope"}} + _, err := Structured[CWEExpansion](context.Background(), f, "", "u") + if err == nil { + t.Fatal("Structured should fail when no attempt parses") + } + const want = "aix.Structured[CWEExpansion]: Could not parse structured response: nope" + if err.Error() != want { + t.Errorf("Structured error = %q, want %q", err.Error(), want) + } + if f.calls != 3 { + t.Errorf("AI calls = %d, want exactly 3 (1 + max_parse_retries), not %d", f.calls, f.calls) + } +} + +// A body that the salvage step recovers but that is STILL not decodable burns a +// retry like any other parse failure. +func TestStructuredSalvagedButUndecodableStillRetries(t *testing.T) { + f := &scriptedAI{Bodies: []string{ + "here you go: {not: valid, json}", + `{"additional_cwes":[],"rationale":"ok"}`, + }} + if _, err := Structured[CWEExpansion](context.Background(), f, "", "u"); err != nil { + t.Fatalf("Structured: %v", err) + } + if f.calls != 2 { + t.Errorf("AI calls = %d, want 2", f.calls) + } +} + +// extractJSONObject must equal CPython's `re.search(r"\{.*\}", s, re.DOTALL)`. +// Every want below was produced by running that expression under +// ~/.agentfield/packages/sec-af/venv/bin/python. +func TestExtractJSONObjectMatchesTheGreedyPythonRegex(t *testing.T) { + for _, tc := range []struct { + in string + want string // "" means: no match + }{ + {"```json\n{\"a\": 1, \"b\": [1,2]}\n```", "{\"a\": 1, \"b\": [1,2]}"}, + {"Sure! Here is the JSON: {\"a\": 1} hope that helps.", "{\"a\": 1}"}, + {"no braces at all", ""}, + {"} weird {\"a\":1}", "{\"a\":1}"}, + {"{\"a\":1} then } more", "{\"a\":1} then }"}, + {"{\"a\":1", ""}, + {"", ""}, + {"{\"a\": {\"b\": 2}} trailing {oops", "{\"a\": {\"b\": 2}}"}, + {"prefix {\"a\":1} middle {\"b\":2} suffix", "{\"a\":1} middle {\"b\":2}"}, + } { + got, ok := extractJSONObject(tc.in) + if tc.want == "" { + if ok { + t.Errorf("extractJSONObject(%q) = %q, want no match", tc.in, got) + } + continue + } + if !ok || got != tc.want { + t.Errorf("extractJSONObject(%q) = %q/%v, want %q", tc.in, got, ok, tc.want) + } + } +} diff --git a/go/internal/aix/order_test.go b/go/internal/aix/order_test.go new file mode 100644 index 0000000..1ebaab1 --- /dev/null +++ b/go/internal/aix/order_test.go @@ -0,0 +1,232 @@ +package aix + +// Tests for the ORDER of the schema document Structured sends as +// response_format.json_schema.schema. +// +// Validation contract, taken from the Python SDK's live path +// (sdk/python/agentfield/agent_ai.py:803 -> +// `_strictify_openai_schema(schema.model_json_schema())`), not from the Go code: +// +// - Python strictifies `Model.model_json_schema()`, whose `properties` is an +// insertion-ordered dict in pydantic field-DECLARATION order, and sets +// `node["required"] = list(props.keys())` — so both render in declaration +// order, and litellm serialises the dict as-is. VERIFIED on the pinned +// interpreter, e.g. VerdictDecision -> ["verdict", "evidence_level", +// "rationale", "confidence"] for `model_fields`, `properties` AND +// `required`, and ComplianceGate's `$defs.ComplianceSuggestion` -> +// ["framework", "control_id", "control_name"]. +// - Go reads the committed fixture, which go/scripts/gen_schemas.py wrote with +// sort_keys=True, into a map — so without StrictifyOrdered the marshalled +// bytes carry `properties` and `required` in SORTED order for every +// `.ai(schema=...)` call the node makes. +// +// The declaration order is asserted against internal/schemas/testdata/model_keys.json, +// which go/scripts/gen_model_keys.py generates from the live pydantic models +// (`keys` is `model_dump()` order, which is `model_fields` order, which is the +// `properties` order verified above). + +import ( + "bytes" + "encoding/json" + "os" + "reflect" + "testing" + + "github.com/Agent-Field/sec-af/go/internal/harnessx" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// pydanticKeys returns a model's field-declaration order from the generated +// pydantic fixture. +func pydanticKeys(t *testing.T, module, class string) []string { + t.Helper() + raw, err := os.ReadFile("../schemas/testdata/model_keys.json") + if err != nil { + t.Fatalf("read model_keys.json: %v", err) + } + var doc struct { + Models []struct { + PythonModule string `json:"python_module"` + PythonClass string `json:"python_class"` + Keys []string `json:"keys"` + } `json:"models"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("decode model_keys.json: %v", err) + } + for _, m := range doc.Models { + if m.PythonModule == module && m.PythonClass == class { + return m.Keys + } + } + t.Fatalf("%s.%s not in model_keys.json", module, class) + return nil +} + +// objectKeys returns a JSON object's member names in the order they appear in +// the bytes — which is the whole point of the assertion, so decoding into a map +// (unordered) would defeat it. +func objectKeys(t *testing.T, raw json.RawMessage) []string { + t.Helper() + dec := json.NewDecoder(bytes.NewReader(raw)) + tok, err := dec.Token() + if err != nil { + t.Fatalf("read object: %v", err) + } + if delim, ok := tok.(json.Delim); !ok || delim != '{' { + t.Fatalf("not an object: %v", tok) + } + var keys []string + for dec.More() { + key, err := dec.Token() + if err != nil { + t.Fatalf("read key: %v", err) + } + name, ok := key.(string) + if !ok { + t.Fatalf("key is not a string: %v", key) + } + keys = append(keys, name) + var skip json.RawMessage + if err := dec.Decode(&skip); err != nil { + t.Fatalf("skip value of %q: %v", name, err) + } + } + return keys +} + +// strictifiedNode is one object of the emitted document, decoded far enough to +// look at its properties order and required array. +type strictifiedNode struct { + Properties json.RawMessage `json:"properties"` + Required []string `json:"required"` + Defs map[string]json.RawMessage +} + +func emit[T any](t *testing.T) json.RawMessage { + t.Helper() + raw, err := json.Marshal(StrictifyOrdered[T](harnessx.SchemaFor[T]())) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return raw +} + +func decodeNode(t *testing.T, raw json.RawMessage) strictifiedNode { + t.Helper() + var node strictifiedNode + if err := json.Unmarshal(raw, &node); err != nil { + t.Fatalf("decode node: %v", err) + } + var withDefs struct { + Defs map[string]json.RawMessage `json:"$defs"` + } + if err := json.Unmarshal(raw, &withDefs); err != nil { + t.Fatalf("decode $defs: %v", err) + } + node.Defs = withDefs.Defs + return node +} + +func assertDeclarationOrder(t *testing.T, label string, raw json.RawMessage, want []string) { + t.Helper() + node := decodeNode(t, raw) + if got := objectKeys(t, node.Properties); !reflect.DeepEqual(got, want) { + t.Errorf("%s properties order\n got: %v\nwant: %v", label, got, want) + } + if !reflect.DeepEqual(node.Required, want) { + t.Errorf("%s required order\n got: %v\nwant: %v", label, node.Required, want) + } +} + +// TestStructuredSchemaCarriesPydanticDeclarationOrder covers every model +// SEC-AF passes to `.ai(schema=...)`. +func TestStructuredSchemaCarriesPydanticDeclarationOrder(t *testing.T) { + const gates = "sec_af.schemas.gates" + + t.Run("DuplicateCheck", func(t *testing.T) { + assertDeclarationOrder(t, "DuplicateCheck", emit[schemas.DuplicateCheck](t), + pydanticKeys(t, gates, "DuplicateCheck")) + }) + t.Run("StrategySelection", func(t *testing.T) { + assertDeclarationOrder(t, "StrategySelection", emit[schemas.StrategySelection](t), + pydanticKeys(t, gates, "StrategySelection")) + }) + t.Run("CWEExpansion", func(t *testing.T) { + assertDeclarationOrder(t, "CWEExpansion", emit[schemas.CWEExpansion](t), + pydanticKeys(t, gates, "CWEExpansion")) + }) + t.Run("ReachabilityGate", func(t *testing.T) { + assertDeclarationOrder(t, "ReachabilityGate", emit[schemas.ReachabilityGate](t), + pydanticKeys(t, gates, "ReachabilityGate")) + }) + t.Run("VerdictDecision", func(t *testing.T) { + assertDeclarationOrder(t, "VerdictDecision", emit[schemas.VerdictDecision](t), + pydanticKeys(t, "sec_af.schemas.prove", "VerdictDecision")) + }) + // ComplianceGate is the only gate schema with a $defs entry, so it is the + // one that proves the order reaches nested models too. + t.Run("ComplianceGate", func(t *testing.T) { + raw := emit[schemas.ComplianceGate](t) + assertDeclarationOrder(t, "ComplianceGate", raw, pydanticKeys(t, gates, "ComplianceGate")) + + node := decodeNode(t, raw) + sub, ok := node.Defs["ComplianceSuggestion"] + if !ok { + t.Fatalf("$defs = %v, want a ComplianceSuggestion entry", node.Defs) + } + assertDeclarationOrder(t, "$defs.ComplianceSuggestion", sub, + pydanticKeys(t, gates, "ComplianceSuggestion")) + }) +} + +// TestStrictifyOrderedKeepsTheStrictifyTransform: the ordering wrapper must not +// change WHAT the document says — same additionalProperties placement, same +// required SET, same untouched nodes — only the order it says it in. Comparing +// against Strictify (whose parity with the Python SDK the goldens already pin) +// keeps the two from drifting apart. +func TestStrictifyOrderedKeepsTheStrictifyTransform(t *testing.T) { + for _, name := range harnessx.FixtureNames() { + in, err := harnessx.LoadFixture(name) + if err != nil { + t.Errorf("%s: %v", name, err) + continue + } + // The Go type is irrelevant to the transform; only the order changes, + // and re-decoding erases it. + gotRaw, err := json.Marshal(StrictifyOrdered[struct{}](in)) + if err != nil { + t.Fatalf("%s: marshal ordered: %v", name, err) + } + wantRaw, err := json.Marshal(Strictify(in)) + if err != nil { + t.Fatalf("%s: marshal strictify: %v", name, err) + } + var got, want any + if err := json.Unmarshal(gotRaw, &got); err != nil { + t.Fatalf("%s: %v", name, err) + } + if err := json.Unmarshal(wantRaw, &want); err != nil { + t.Fatalf("%s: %v", name, err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: StrictifyOrdered changed the document, not just the order\n got: %s\nwant: %s", + name, gotRaw, wantRaw) + } + } +} + +// TestStrictifyOrderedDoesNotMutateTheCachedFixture: Structured runs this over +// harnessx's SHARED cached map, so a mutation would be both a data race and a +// corrupted harness schema. +func TestStrictifyOrderedDoesNotMutateTheCachedFixture(t *testing.T) { + before, err := harnessx.LoadFixture("ComplianceGate") + if err != nil { + t.Fatalf("LoadFixture: %v", err) + } + _ = StrictifyOrdered[schemas.ComplianceGate](harnessx.SchemaFor[schemas.ComplianceGate]()) + after := harnessx.SchemaFor[schemas.ComplianceGate]() + if !reflect.DeepEqual(after, before) { + t.Error("StrictifyOrdered mutated the cached harnessx fixture") + } +} diff --git a/go/internal/aix/testdata/edgecases_input.json b/go/internal/aix/testdata/edgecases_input.json new file mode 100644 index 0000000..5c15579 --- /dev/null +++ b/go/internal/aix/testdata/edgecases_input.json @@ -0,0 +1,84 @@ +{ + "$defs": { + "AlreadyStrict": { + "additionalProperties": true, + "properties": { + "kept": { + "type": "string" + } + }, + "required": [ + "stale" + ], + "type": "object" + }, + "Leaf": { + "properties": { + "v": { + "type": "number" + } + }, + "type": "object" + }, + "ListTypeNode": { + "properties": { + "z": { + "type": "string" + } + }, + "type": [ + "object", + "null" + ] + }, + "NoTypeNode": { + "properties": { + "a": { + "type": "integer" + }, + "b": { + "type": "string" + } + } + } + }, + "properties": { + "decoy": { + "properties": "not-a-dict", + "type": "object" + }, + "list_of_objects": { + "items": { + "properties": { + "inner": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "nested": { + "$ref": "#/$defs/Leaf" + }, + "nullable_object": { + "anyOf": [ + { + "properties": { + "x": { + "type": "string" + } + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, + "plain": { + "type": "string" + } + }, + "type": "object" +} diff --git a/go/internal/aix/testdata/edgecases_strict.json b/go/internal/aix/testdata/edgecases_strict.json new file mode 100644 index 0000000..c575790 --- /dev/null +++ b/go/internal/aix/testdata/edgecases_strict.json @@ -0,0 +1,109 @@ +{ + "$defs": { + "AlreadyStrict": { + "additionalProperties": false, + "properties": { + "kept": { + "type": "string" + } + }, + "required": [ + "kept" + ], + "type": "object" + }, + "Leaf": { + "additionalProperties": false, + "properties": { + "v": { + "type": "number" + } + }, + "required": [ + "v" + ], + "type": "object" + }, + "ListTypeNode": { + "properties": { + "z": { + "type": "string" + } + }, + "type": [ + "object", + "null" + ] + }, + "NoTypeNode": { + "additionalProperties": false, + "properties": { + "a": { + "type": "integer" + }, + "b": { + "type": "string" + } + }, + "required": [ + "a", + "b" + ] + } + }, + "additionalProperties": false, + "properties": { + "decoy": { + "properties": "not-a-dict", + "type": "object" + }, + "list_of_objects": { + "items": { + "additionalProperties": false, + "properties": { + "inner": { + "type": "string" + } + }, + "required": [ + "inner" + ], + "type": "object" + }, + "type": "array" + }, + "nested": { + "$ref": "#/$defs/Leaf" + }, + "nullable_object": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "x": { + "type": "string" + } + }, + "required": [ + "x" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "plain": { + "type": "string" + } + }, + "required": [ + "decoy", + "list_of_objects", + "nested", + "nullable_object", + "plain" + ], + "type": "object" +} diff --git a/go/internal/aix/testdata/gen_strictify_golden.py b/go/internal/aix/testdata/gen_strictify_golden.py new file mode 100644 index 0000000..9466702 --- /dev/null +++ b/go/internal/aix/testdata/gen_strictify_golden.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Golden generator for aix.Strictify. + +Runs the REAL Python SDK function that aix.Strictify ports — +``agentfield.agent_ai._strictify_openai_schema`` — over + + 1. every committed pydantic schema fixture under + ``go/internal/harnessx/testdata/schemas/`` (the exact documents + ``aix.Structured`` strictifies at runtime), writing the results as one + ``{"": }`` object, and + 2. a hand-written edge-case document that exercises the branches the real + fixtures do not: a properties-bearing node with NO "type" key (must be + strictified), a node with a LIST-valued "type" (must NOT be), an "anyOf" + branch, a nested "items", and a pre-existing wrong "required"/ + "additionalProperties" pair that the walk must overwrite. + +Run from the repo root: + + ~/.agentfield/packages/sec-af/venv/bin/python \ + go/internal/aix/testdata/gen_strictify_golden.py + +Deterministic: rerunning writes identical bytes unless the SDK function or a +schema fixture changed. +""" + +from __future__ import annotations + +import json +import os + +from agentfield.agent_ai import _strictify_openai_schema + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_GO = os.path.dirname(os.path.dirname(os.path.dirname(_HERE))) +_FIXTURES = os.path.join(_GO, "internal", "harnessx", "testdata", "schemas") + +# Exercises every branch of the walk. Key order is irrelevant to the assertion +# (the Go test compares decoded documents), but the required lists it produces +# are order-sensitive, so this file is written sorted to match Go's sorted +# `required` — see aix.Strictify's doc comment. +EDGE_CASES = { + "$defs": { + "NoTypeNode": { + # No "type" key at all -> `"type" not in node` -> strictified. + "properties": { + "b": {"type": "string"}, + "a": {"type": "integer"}, + }, + }, + "ListTypeNode": { + # type is a LIST -> neither `== "object"` nor `not in` -> untouched. + "type": ["object", "null"], + "properties": {"z": {"type": "string"}}, + }, + "AlreadyStrict": { + "type": "object", + "additionalProperties": True, + "required": ["stale"], + "properties": {"kept": {"type": "string"}}, + }, + "Leaf": { + "type": "object", + "properties": {"v": {"type": "number"}}, + }, + }, + "type": "object", + "properties": { + "nested": {"$ref": "#/$defs/Leaf"}, + "list_of_objects": { + "type": "array", + "items": { + "type": "object", + "properties": {"inner": {"type": "string"}}, + }, + }, + "nullable_object": { + "anyOf": [ + {"type": "object", "properties": {"x": {"type": "string"}}}, + {"type": "null"}, + ] + }, + "plain": {"type": "string"}, + # A non-dict "properties" value must not trip the isinstance check. + "decoy": {"type": "object", "properties": "not-a-dict"}, + }, +} + + +def main() -> None: + strictified: dict[str, object] = {} + for entry in sorted(os.listdir(_FIXTURES)): + if not entry.endswith(".json"): + continue + with open(os.path.join(_FIXTURES, entry), encoding="utf-8") as f: + schema = json.load(f) + strictified[entry[: -len(".json")]] = _strictify_openai_schema(schema) + + out = os.path.join(_HERE, "strictified_fixtures.json") + with open(out, "w", encoding="utf-8") as f: + f.write(json.dumps(strictified, indent=2, sort_keys=True) + "\n") + print(f"wrote {os.path.basename(out)} ({len(strictified)} schemas)") + + # Round-trip through sorted JSON FIRST so the dict Python walks has the same + # key order the Go test will read off disk. `list(props.keys())` is + # insertion-ordered, so without this the golden `required` lists would carry + # the literal's declaration order while Go produced sorted ones. + edge_input = json.loads(json.dumps(EDGE_CASES, sort_keys=True)) + with open(os.path.join(_HERE, "edgecases_input.json"), "w", encoding="utf-8") as f: + f.write(json.dumps(edge_input, indent=2, sort_keys=True) + "\n") + with open(os.path.join(_HERE, "edgecases_strict.json"), "w", encoding="utf-8") as f: + f.write(json.dumps(_strictify_openai_schema(edge_input), indent=2, sort_keys=True) + "\n") + print("wrote edgecases_input.json / edgecases_strict.json") + + +if __name__ == "__main__": + main() diff --git a/go/internal/aix/testdata/strictified_fixtures.json b/go/internal/aix/testdata/strictified_fixtures.json new file mode 100644 index 0000000..2662b38 --- /dev/null +++ b/go/internal/aix/testdata/strictified_fixtures.json @@ -0,0 +1,850 @@ +{ + "ArchitectureMapRaw": { + "additionalProperties": false, + "description": "Flat harness output for architecture mapper. All list[str], no nesting.", + "properties": { + "api_endpoints": { + "description": "One string per API endpoint. Format: 'method | path | handler | file_path:line | auth_required | rate_limited'. Example: 'GET | /api/users | get_users | src/api.py:15 | true | false'", + "items": { + "type": "string" + }, + "title": "Api Endpoints", + "type": "array" + }, + "app_type": { + "default": "unknown", + "description": "Application type: web_api, cli_tool, library, microservice, monolith", + "title": "App Type", + "type": "string" + }, + "entry_points": { + "description": "One string per entry point. Format: 'kind | route_or_id | file_path:line | auth_required'. Example: 'http | POST /api/login | src/routes.py:42 | false'", + "items": { + "type": "string" + }, + "title": "Entry Points", + "type": "array" + }, + "modules": { + "description": "One string per module. Format: 'name | path | language | description'. Example: 'auth | src/auth/ | python | Authentication and session management'", + "items": { + "type": "string" + }, + "title": "Modules", + "type": "array" + }, + "services": { + "description": "One string per external service. Format: 'name | type | endpoint | auth_mechanism'. Example: 'PostgreSQL | database | localhost:5432 | password'", + "items": { + "type": "string" + }, + "title": "Services", + "type": "array" + }, + "trust_boundaries": { + "description": "One string per boundary. Format: 'name | source_zone | target_zone | description'. Example: 'API Gateway | external | internal | Rate limiting and auth'", + "items": { + "type": "string" + }, + "title": "Trust Boundaries", + "type": "array" + } + }, + "required": [ + "api_endpoints", + "app_type", + "entry_points", + "modules", + "services", + "trust_boundaries" + ], + "title": "ArchitectureMapRaw", + "type": "object" + }, + "CWEExpansion": { + "additionalProperties": false, + "description": "AI-suggested CWE additions based on recon context.", + "properties": { + "additional_cwes": { + "description": "CWE IDs to add beyond baseline, e.g. ['CWE-918', 'CWE-611'].", + "items": { + "type": "string" + }, + "title": "Additional Cwes", + "type": "array" + }, + "rationale": { + "title": "Rationale", + "type": "string" + } + }, + "required": [ + "additional_cwes", + "rationale" + ], + "title": "CWEExpansion", + "type": "object" + }, + "ChainCorrelationResult": { + "additionalProperties": false, + "description": "Flat harness schema for chain correlation. LLM identifies chains only.", + "properties": { + "chains": { + "description": "Multi-step attack chains found. Format per entry: 'title | finding_id1,finding_id2,... | combined_impact | severity'. Example: 'SSRF to Internal API | f1,f2 | Access internal services | high'", + "items": { + "type": "string" + }, + "title": "Chains", + "type": "array" + }, + "duplicate_ids": { + "description": "Finding IDs that are duplicates missed by programmatic dedup (to drop)", + "items": { + "type": "string" + }, + "title": "Duplicate Ids", + "type": "array" + } + }, + "required": [ + "chains", + "duplicate_ids" + ], + "title": "ChainCorrelationResult", + "type": "object" + }, + "ComplianceGate": { + "$defs": { + "ComplianceSuggestion": { + "additionalProperties": false, + "properties": { + "control_id": { + "title": "Control Id", + "type": "string" + }, + "control_name": { + "title": "Control Name", + "type": "string" + }, + "framework": { + "title": "Framework", + "type": "string" + } + }, + "required": [ + "control_id", + "control_name", + "framework" + ], + "title": "ComplianceSuggestion", + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "confidence": { + "title": "Confidence", + "type": "string" + }, + "mappings": { + "items": { + "$ref": "#/$defs/ComplianceSuggestion" + }, + "title": "Mappings", + "type": "array" + } + }, + "required": [ + "confidence", + "mappings" + ], + "title": "ComplianceGate", + "type": "object" + }, + "ConfigReportRaw": { + "additionalProperties": false, + "description": "Flat harness output for config scanner. All list[str], no nesting.", + "properties": { + "misconfigs": { + "description": "One string per misconfiguration. Format: 'category | file_path:line | key | risk | remediation'. Example: 'debug_mode | config.py:15 | DEBUG=True | Exposes stack traces | Set DEBUG=False'", + "items": { + "type": "string" + }, + "title": "Misconfigs", + "type": "array" + }, + "secrets": { + "description": "One string per secret finding. Format: 'type | file_path:line | match_preview | confidence | is_test(true/false)'. Example: 'aws_access_key | .env:3 | AKIA... | high | false'", + "items": { + "type": "string" + }, + "title": "Secrets", + "type": "array" + } + }, + "required": [ + "misconfigs", + "secrets" + ], + "title": "ConfigReportRaw", + "type": "object" + }, + "CrossServiceFinding": { + "additionalProperties": false, + "description": "Flat schema for cross-service attack chain analysis. 4 fields.", + "properties": { + "chain_description": { + "description": "Description of the cross-service attack path", + "title": "Chain Description", + "type": "string" + }, + "entry_point": { + "description": "Public-facing entry point where attack begins", + "title": "Entry Point", + "type": "string" + }, + "impact": { + "description": "Impact if the cross-service chain is exploited", + "title": "Impact", + "type": "string" + }, + "services_involved": { + "description": "Service names in the attack chain", + "items": { + "type": "string" + }, + "title": "Services Involved", + "type": "array" + } + }, + "required": [ + "chain_description", + "entry_point", + "impact", + "services_involved" + ], + "title": "CrossServiceFinding", + "type": "object" + }, + "DastVerificationResult": { + "additionalProperties": false, + "description": "Flat schema for DAST-like runtime verification. 4 fields.", + "properties": { + "exploit_confirmed": { + "description": "Whether the exploit was confirmed at runtime", + "title": "Exploit Confirmed", + "type": "boolean" + }, + "payload_sent": { + "description": "The exploit payload or request that was sent", + "title": "Payload Sent", + "type": "string" + }, + "response_summary": { + "description": "Summary of the application response", + "title": "Response Summary", + "type": "string" + }, + "safety_notes": { + "description": "Safety measures taken during verification (sandbox, timeout, etc.)", + "title": "Safety Notes", + "type": "string" + } + }, + "required": [ + "exploit_confirmed", + "payload_sent", + "response_summary", + "safety_notes" + ], + "title": "DastVerificationResult", + "type": "object" + }, + "DataFlowMapRaw": { + "additionalProperties": false, + "description": "Flat harness output for data flow mapper. All list[str], no nesting.", + "properties": { + "flows": { + "description": "One string per data flow. Format: 'source | sink | sanitized(true/false) | file1, file2, ...'. Example: 'request.body | sql.execute | false | src/db.py, src/routes.py'", + "items": { + "type": "string" + }, + "title": "Flows", + "type": "array" + }, + "sanitization_points": { + "description": "One string per sanitization point. Format: 'file_path:line | function_name | type | protects_against'. Example: 'src/utils.py:42 | sanitize_html | html_encoding | CWE-79'", + "items": { + "type": "string" + }, + "title": "Sanitization Points", + "type": "array" + }, + "sinks": { + "description": "One string per security-critical sink. Format: 'sink_type | file_path:line | function_name | notes'. Example: 'sql_execute | src/db.py:55 | run_query | Direct string concatenation'", + "items": { + "type": "string" + }, + "title": "Sinks", + "type": "array" + } + }, + "required": [ + "flows", + "sanitization_points", + "sinks" + ], + "title": "DataFlowMapRaw", + "type": "object" + }, + "DataFlowTrace": { + "additionalProperties": false, + "description": "Flat schema for data flow tracing sub-agent. 4 fields.", + "properties": { + "sink": { + "description": "Security-sensitive operation reached (e.g. 'sql.execute(query)')", + "title": "Sink", + "type": "string" + }, + "sink_reached": { + "description": "Whether tainted data actually reaches the sink", + "title": "Sink Reached", + "type": "boolean" + }, + "source": { + "description": "Where tainted input enters (e.g. 'request.params.id')", + "title": "Source", + "type": "string" + }, + "steps": { + "description": "Ordered list of file:line descriptions showing flow path", + "items": { + "type": "string" + }, + "title": "Steps", + "type": "array" + } + }, + "required": [ + "sink", + "sink_reached", + "source", + "steps" + ], + "title": "DataFlowTrace", + "type": "object" + }, + "DependencyReportRaw": { + "additionalProperties": false, + "description": "Flat harness output for dependency auditor. All list[str], no nesting.", + "properties": { + "known_cves": { + "description": "One string per CVE. Format: 'cve_id | package | installed_version | fixed_version | cvss_score | direct | reachable'. Example: 'CVE-2023-1234 | lodash | 4.17.15 | 4.17.21 | 7.5 | true | unknown'", + "items": { + "type": "string" + }, + "title": "Known Cves", + "type": "array" + }, + "outdated": { + "description": "One string per outdated dep. Format: 'package | current_version | latest_version | direct(true/false)'. Example: 'express | 4.17.0 | 4.18.2 | true'", + "items": { + "type": "string" + }, + "title": "Outdated", + "type": "array" + }, + "sbom": { + "description": "One string per dependency. Format: 'name | version | ecosystem | direct(true/false) | license'. Example: 'express | 4.18.2 | npm | true | MIT'", + "items": { + "type": "string" + }, + "title": "Sbom", + "type": "array" + } + }, + "required": [ + "known_cves", + "outdated", + "sbom" + ], + "title": "DependencyReportRaw", + "type": "object" + }, + "DuplicateCheck": { + "additionalProperties": false, + "description": "DESIGN.md \u00a75.5: quick duplicate check gate for dedup decisions.", + "properties": { + "duplicate_of": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Duplicate Of" + }, + "is_duplicate": { + "title": "Is Duplicate", + "type": "boolean" + }, + "reason": { + "title": "Reason", + "type": "string" + } + }, + "required": [ + "duplicate_of", + "is_duplicate", + "reason" + ], + "title": "DuplicateCheck", + "type": "object" + }, + "EnrichedFinding": { + "additionalProperties": false, + "description": "Flat schema for Step 2: finding enrichment. 6 fields.", + "properties": { + "confidence": { + "description": "One of: \"high\", \"medium\", \"low\"", + "title": "Confidence", + "type": "string" + }, + "cwe_id": { + "description": "CWE identifier (e.g. 'CWE-89')", + "title": "Cwe Id", + "type": "string" + }, + "data_flow_summary": { + "description": "Natural language summary of the data flow (string, not nested)", + "title": "Data Flow Summary", + "type": "string" + }, + "description": { + "description": "Detailed description of the vulnerability", + "title": "Description", + "type": "string" + }, + "severity": { + "description": "One of: \"critical\", \"high\", \"medium\", \"low\", \"info\"", + "title": "Severity", + "type": "string" + }, + "title": { + "description": "Human-readable title for the finding", + "title": "Title", + "type": "string" + } + }, + "required": [ + "confidence", + "cwe_id", + "data_flow_summary", + "description", + "severity", + "title" + ], + "title": "EnrichedFinding", + "type": "object" + }, + "ExploitHypothesis": { + "additionalProperties": false, + "description": "Flat schema for exploit construction sub-agent. 3 fields.", + "properties": { + "expected_outcome": { + "description": "What would happen if exploit succeeds", + "title": "Expected Outcome", + "type": "string" + }, + "hypothesis": { + "description": "Natural language description of exploit scenario", + "title": "Hypothesis", + "type": "string" + }, + "payload": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Concrete exploit payload or input", + "title": "Payload" + } + }, + "required": [ + "expected_outcome", + "hypothesis", + "payload" + ], + "title": "ExploitHypothesis", + "type": "object" + }, + "PolicyEvalResult": { + "additionalProperties": false, + "description": "Flat schema for AI policy evaluation. 4 fields.", + "properties": { + "description": { + "description": "How the policy is violated, or 'No violation' if compliant", + "title": "Description", + "type": "string" + }, + "file_path": { + "description": "Primary file where violation occurs, or 'N/A'", + "title": "File Path", + "type": "string" + }, + "severity": { + "description": "Severity: \"high\", \"medium\", or \"low\"", + "title": "Severity", + "type": "string" + }, + "violated": { + "description": "Whether the policy is violated", + "title": "Violated", + "type": "boolean" + } + }, + "required": [ + "description", + "file_path", + "severity", + "violated" + ], + "title": "PolicyEvalResult", + "type": "object" + }, + "ReachabilityGate": { + "additionalProperties": false, + "description": "Reachability assessment for findings without explicit reachability tags.", + "properties": { + "confidence": { + "description": "One of: \"high\", \"medium\", \"low\".", + "title": "Confidence", + "type": "string" + }, + "rationale": { + "title": "Rationale", + "type": "string" + }, + "reachability": { + "description": "One of: \"externally_reachable\", \"requires_auth\", \"internal_only\", \"unreachable\".", + "title": "Reachability", + "type": "string" + } + }, + "required": [ + "confidence", + "rationale", + "reachability" + ], + "title": "ReachabilityGate", + "type": "object" + }, + "ReachabilityProof": { + "additionalProperties": false, + "description": "Flat schema for dependency reachability analysis. 4 fields.", + "properties": { + "call_chain": { + "description": "Import/call chain from app code to vulnerable function", + "items": { + "type": "string" + }, + "title": "Call Chain", + "type": "array" + }, + "direct": { + "description": "Whether the dependency is direct or transitive", + "title": "Direct", + "type": "boolean" + }, + "reachable": { + "description": "Whether the vulnerable function is actually called", + "title": "Reachable", + "type": "boolean" + }, + "vulnerable_function": { + "description": "The vulnerable function/method in the dependency", + "title": "Vulnerable Function", + "type": "string" + } + }, + "required": [ + "call_chain", + "direct", + "reachable", + "vulnerable_function" + ], + "title": "ReachabilityProof", + "type": "object" + }, + "RemediationSuggestion": { + "additionalProperties": false, + "description": "Flat schema for AI-generated remediation suggestion. 3 fields.", + "properties": { + "confidence": { + "description": "Confidence in the fix: \"high\", \"medium\", or \"low\"", + "title": "Confidence", + "type": "string" + }, + "fix_description": { + "description": "Natural language description of the recommended fix", + "title": "Fix Description", + "type": "string" + }, + "patch_diff": { + "description": "Unified diff format patch showing the code changes needed", + "title": "Patch Diff", + "type": "string" + } + }, + "required": [ + "confidence", + "fix_description", + "patch_diff" + ], + "title": "RemediationSuggestion", + "type": "object" + }, + "SanitizationResult": { + "additionalProperties": false, + "description": "Flat schema for sanitization analysis sub-agent. 4 fields.", + "properties": { + "bypass_method": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "How sanitization could be bypassed, if applicable", + "title": "Bypass Method" + }, + "found": { + "description": "Whether any sanitization/validation was found on the path", + "title": "Found", + "type": "boolean" + }, + "sufficient": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Whether sanitization is sufficient to prevent exploit", + "title": "Sufficient" + }, + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Type of sanitization (e.g. 'parameterized query', 'html encoding')", + "title": "Type" + } + }, + "required": [ + "bypass_method", + "found", + "sufficient", + "type" + ], + "title": "SanitizationResult", + "type": "object" + }, + "ScanLocationsResult": { + "$defs": { + "VulnLocation": { + "additionalProperties": false, + "description": "Flat schema for Step 1: location scanning. 4 fields.", + "properties": { + "code_snippet": { + "description": "Relevant code snippet around the vulnerability", + "title": "Code Snippet", + "type": "string" + }, + "file_path": { + "description": "Path to the file containing the potential vulnerability", + "title": "File Path", + "type": "string" + }, + "pattern_type": { + "description": "Type of vulnerability pattern detected (e.g. 'sql_injection', 'command_injection')", + "title": "Pattern Type", + "type": "string" + }, + "start_line": { + "description": "Starting line number of the vulnerable code", + "title": "Start Line", + "type": "integer" + } + }, + "required": [ + "code_snippet", + "file_path", + "pattern_type", + "start_line" + ], + "title": "VulnLocation", + "type": "object" + } + }, + "additionalProperties": false, + "description": "Container for Step 1 results.", + "properties": { + "locations": { + "items": { + "$ref": "#/$defs/VulnLocation" + }, + "title": "Locations", + "type": "array" + } + }, + "required": [ + "locations" + ], + "title": "ScanLocationsResult", + "type": "object" + }, + "SecurityContextRaw": { + "additionalProperties": false, + "description": "Flat harness output for security context profiler. All flat, no nesting.", + "properties": { + "auth_details": { + "default": "", + "description": "Brief description of auth implementation details", + "title": "Auth Details", + "type": "string" + }, + "auth_model": { + "description": "Authentication model: jwt, session_cookie, oauth2, api_key, none, or other", + "title": "Auth Model", + "type": "string" + }, + "crypto_usage": { + "description": "One string per crypto usage. Format: 'algorithm | key_size | mode | usage_context | is_weak(true/false)'. Example: 'AES | 256 | GCM | data encryption | false'", + "items": { + "type": "string" + }, + "title": "Crypto Usage", + "type": "array" + }, + "security_signals": { + "description": "Framework security features, security headers, and deployment signals. One signal per entry. Examples: 'CSRF protection enabled', 'HSTS header present', 'Runs in Docker'", + "items": { + "type": "string" + }, + "title": "Security Signals", + "type": "array" + } + }, + "required": [ + "auth_details", + "auth_model", + "crypto_usage", + "security_signals" + ], + "title": "SecurityContextRaw", + "type": "object" + }, + "SeverityClassification": { + "additionalProperties": false, + "description": "DESIGN.md \u00a72.4: quick severity classification gate used in scoring.", + "properties": { + "confidence": { + "title": "Confidence", + "type": "number" + }, + "rationale": { + "title": "Rationale", + "type": "string" + }, + "severity": { + "description": "One of: \"critical\", \"high\", \"medium\", \"low\".", + "title": "Severity", + "type": "string" + } + }, + "required": [ + "confidence", + "rationale", + "severity" + ], + "title": "SeverityClassification", + "type": "object" + }, + "StrategySelection": { + "additionalProperties": false, + "description": "DESIGN.md \u00a75.3: strategy selection gate for HUNT routing.", + "properties": { + "rationale": { + "title": "Rationale", + "type": "string" + }, + "strategies": { + "items": { + "type": "string" + }, + "title": "Strategies", + "type": "array" + } + }, + "required": [ + "rationale", + "strategies" + ], + "title": "StrategySelection", + "type": "object" + }, + "VerdictDecision": { + "additionalProperties": false, + "description": "Flat schema for verdict sub-agent. Uses .ai() not .harness(). 4 fields.", + "properties": { + "confidence": { + "description": "One of: \"high\", \"medium\", \"low\"", + "title": "Confidence", + "type": "string" + }, + "evidence_level": { + "description": "1-6 scale: 1=STATIC_MATCH to 6=FULL_EXPLOIT", + "title": "Evidence Level", + "type": "integer" + }, + "rationale": { + "title": "Rationale", + "type": "string" + }, + "verdict": { + "description": "One of: \"confirmed\", \"likely\", \"inconclusive\", \"not_exploitable\"", + "title": "Verdict", + "type": "string" + } + }, + "required": [ + "confidence", + "evidence_level", + "rationale", + "verdict" + ], + "title": "VerdictDecision", + "type": "object" + } +} diff --git a/go/internal/appx/appx.go b/go/internal/appx/appx.go new file mode 100644 index 0000000..0bc29a4 --- /dev/null +++ b/go/internal/appx/appx.go @@ -0,0 +1,49 @@ +// Package appx declares the agent-capability seam every reasoner and phase in +// the port depends on. Python code receives the SDK `Agent` (or the +// `AgentRouter` proxying to it) and calls `.harness(...)`, `.ai(...)`, +// `.note(...)` and `.call(...)` on it; the Go port receives an App. The live +// *agent.Agent satisfies App unchanged; tests supply fakes that record calls. +package appx + +import ( + "context" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/agentfield/sdk/go/harness" +) + +// Harnesser is the `app.harness(...)` seam (sdk/go/agent/harness.go). +type Harnesser interface { + Harness(ctx context.Context, prompt string, schema map[string]any, dest any, opts harness.Options) (*harness.Result, error) +} + +// AIer is the `app.ai(...)` seam (sdk/go/agent/agent.go). +type AIer interface { + AI(ctx context.Context, prompt string, opts ...ai.Option) (*ai.Response, error) +} + +// Noter is the `app.note(msg, tags=[...])` seam. +type Noter interface { + Note(ctx context.Context, message string, tags ...string) +} + +// Caller is the `app.call(f"{NODE_ID}.x", **kwargs)` seam — the control-plane +// routed reasoner invocation that produces a tracked child execution (a DAG +// node). It returns the target reasoner's result map already unwrapped from +// the execution envelope on success, and an error on any failure status. +type Caller interface { + Call(ctx context.Context, target string, input map[string]any) (map[string]any, error) +} + +// App is the union the reasoners, phases and orchestrator are written +// against. *agent.Agent implements every method. +type App interface { + Harnesser + AIer + Noter + Caller +} + +// Compile-time proof the live SDK agent satisfies the seam. +var _ App = (*agent.Agent)(nil) diff --git a/go/internal/appx/fake.go b/go/internal/appx/fake.go new file mode 100644 index 0000000..ee5d1ec --- /dev/null +++ b/go/internal/appx/fake.go @@ -0,0 +1,207 @@ +package appx + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/agentfield/sdk/go/harness" +) + +// Fake is a scripted, recording App for tests. Every seam is a plain function +// field; unset seams fail loudly (Harness/AI/Call return an error, Note is +// recorded). Fake also tracks the maximum number of concurrently in-flight +// Harness and Call invocations so phase tests can assert the semaphore bounds +// the Python code enforces (asyncio.Semaphore(n)). +// +// Fake is safe for concurrent use; all recorded slices are guarded by mu. +type Fake struct { + mu sync.Mutex + + // HarnessFn answers Harness. To mimic a successful structured run, marshal + // the canned value into dest and return &harness.Result{Parsed: dest}. + // Use HarnessJSON for the common case. + HarnessFn func(ctx context.Context, prompt string, schema map[string]any, dest any, opts harness.Options) (*harness.Result, error) + // AIFn answers AI. Use AIJSON for the common "return this JSON" case. + AIFn func(ctx context.Context, prompt string, opts ...ai.Option) (*ai.Response, error) + // CallFn answers Call. + CallFn func(ctx context.Context, target string, input map[string]any) (map[string]any, error) + + // Recorded invocations, in call order. + Harnesses []HarnessCall + AIs []AICall + Notes []NoteCall + Calls []CallCall + + inflightHarness, maxHarness int + inflightCall, maxCall int +} + +// HarnessCall is one recorded Harness invocation. +type HarnessCall struct { + Prompt string + Schema map[string]any + Opts harness.Options +} + +// AICall is one recorded AI invocation (the option list is opaque; tests that +// need the system prompt/schema should apply the options to an ai.Request). +type AICall struct { + Prompt string + Opts []ai.Option +} + +// NoteCall is one recorded Note invocation. +type NoteCall struct { + Message string + Tags []string +} + +// CallCall is one recorded Call invocation. +type CallCall struct { + Target string + Input map[string]any +} + +var _ App = (*Fake)(nil) + +// Harness implements Harnesser. +func (f *Fake) Harness(ctx context.Context, prompt string, schema map[string]any, dest any, opts harness.Options) (*harness.Result, error) { + f.mu.Lock() + f.Harnesses = append(f.Harnesses, HarnessCall{Prompt: prompt, Schema: schema, Opts: opts}) + f.inflightHarness++ + if f.inflightHarness > f.maxHarness { + f.maxHarness = f.inflightHarness + } + fn := f.HarnessFn + f.mu.Unlock() + defer func() { + f.mu.Lock() + f.inflightHarness-- + f.mu.Unlock() + }() + if fn == nil { + return nil, fmt.Errorf("appx.Fake: Harness not scripted (prompt %q)", truncate(prompt, 80)) + } + return fn(ctx, prompt, schema, dest, opts) +} + +// AI implements AIer. +func (f *Fake) AI(ctx context.Context, prompt string, opts ...ai.Option) (*ai.Response, error) { + f.mu.Lock() + f.AIs = append(f.AIs, AICall{Prompt: prompt, Opts: opts}) + fn := f.AIFn + f.mu.Unlock() + if fn == nil { + return nil, fmt.Errorf("appx.Fake: AI not scripted (prompt %q)", truncate(prompt, 80)) + } + return fn(ctx, prompt, opts...) +} + +// Note implements Noter. +func (f *Fake) Note(ctx context.Context, message string, tags ...string) { + f.mu.Lock() + defer f.mu.Unlock() + f.Notes = append(f.Notes, NoteCall{Message: message, Tags: append([]string(nil), tags...)}) +} + +// Call implements Caller. +func (f *Fake) Call(ctx context.Context, target string, input map[string]any) (map[string]any, error) { + f.mu.Lock() + f.Calls = append(f.Calls, CallCall{Target: target, Input: input}) + f.inflightCall++ + if f.inflightCall > f.maxCall { + f.maxCall = f.inflightCall + } + fn := f.CallFn + f.mu.Unlock() + defer func() { + f.mu.Lock() + f.inflightCall-- + f.mu.Unlock() + }() + if fn == nil { + return nil, fmt.Errorf("appx.Fake: Call not scripted (target %q)", target) + } + return fn(ctx, target, input) +} + +// MaxConcurrentHarness returns the peak number of simultaneously in-flight +// Harness invocations observed so far. +func (f *Fake) MaxConcurrentHarness() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.maxHarness +} + +// MaxConcurrentCalls returns the peak number of simultaneously in-flight Call +// invocations observed so far. +func (f *Fake) MaxConcurrentCalls() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.maxCall +} + +// CallTargets returns the recorded Call targets in order. +func (f *Fake) CallTargets() []string { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]string, 0, len(f.Calls)) + for _, c := range f.Calls { + out = append(out, c.Target) + } + return out +} + +// NoteMessages returns the recorded Note messages in order. +func (f *Fake) NoteMessages() []string { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]string, 0, len(f.Notes)) + for _, n := range f.Notes { + out = append(out, n.Message) + } + return out +} + +// HarnessJSON builds a HarnessFn that answers every invocation by unmarshaling +// the JSON produced by pick(prompt) into dest and returning a successful +// Result whose Parsed is dest — exactly what the SDK runner does on a +// schema-valid run. pick returning an error yields a Result with IsError set +// and that message (the SDK's failure shape), NOT a transport error. +func HarnessJSON(pick func(prompt string, opts harness.Options) (json.RawMessage, error)) func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return func(_ context.Context, prompt string, _ map[string]any, dest any, opts harness.Options) (*harness.Result, error) { + raw, err := pick(prompt, opts) + if err != nil { + return &harness.Result{IsError: true, ErrorMessage: err.Error()}, nil + } + if dest == nil { + return &harness.Result{Result: string(raw)}, nil + } + if err := json.Unmarshal(raw, dest); err != nil { + return &harness.Result{IsError: true, ErrorMessage: "fake: unmarshal into dest: " + err.Error()}, nil + } + return &harness.Result{Parsed: dest, Result: string(raw)}, nil + } +} + +// AIJSON builds an AIFn that answers every invocation with a response whose +// text content is the JSON produced by pick(prompt). +func AIJSON(pick func(prompt string) (json.RawMessage, error)) func(context.Context, string, ...ai.Option) (*ai.Response, error) { + return func(_ context.Context, prompt string, _ ...ai.Option) (*ai.Response, error) { + raw, err := pick(prompt) + if err != nil { + return nil, err + } + return &ai.Response{Choices: []ai.Choice{{Message: ai.Message{Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: string(raw)}}}}}}, nil + } +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/go/internal/audit/audit.go b/go/internal/audit/audit.go new file mode 100644 index 0000000..4bc7242 --- /dev/null +++ b/go/internal/audit/audit.go @@ -0,0 +1,28 @@ +// Package audit ports src/sec_af/audit.py — the audit domain scaffold from +// DESIGN.md §3 (Signal Cascade Pipeline). +// +// The Python module is a single-field dataclass stub with NO callers anywhere +// in src/ or tests/ (`grep -rn "sec_af.audit\|from .audit"` finds nothing): it +// is a placeholder for the aggregate model described in the product design doc, +// never wired into app.py, the orchestrator or any reasoner. +// +// It is ported here purely for 1:1 module completeness — the port's rule is +// that every Python module has a Go counterpart, so a reviewer diffing +// src/sec_af against go/internal finds no unexplained gaps. Nothing imports +// this package, and nothing should until the Python module grows a caller; +// SecurityAuditResult (internal/schemas) is the real audit output type. +package audit + +// SecurityAudit is the stub audit aggregate model from DESIGN.md §7.3. +// +// Ports audit.py's `@dataclass(slots=True) class SecurityAudit`. The Python +// default is `status: str = "not_implemented"`, which is NOT the Go zero value, +// so construct one with NewSecurityAudit rather than a bare literal. +type SecurityAudit struct { + Status string `json:"status"` +} + +// NewSecurityAudit returns the dataclass default: status "not_implemented". +func NewSecurityAudit() SecurityAudit { + return SecurityAudit{Status: "not_implemented"} +} diff --git a/go/internal/audit/audit_test.go b/go/internal/audit/audit_test.go new file mode 100644 index 0000000..7f94fd3 --- /dev/null +++ b/go/internal/audit/audit_test.go @@ -0,0 +1,28 @@ +package audit + +import ( + "encoding/json" + "testing" +) + +// src/sec_af/audit.py has no Python test file — it is an uncalled stub. This +// test exists only to pin the one observable thing the dataclass declares: its +// default. + +// TestNewSecurityAuditDefault pins `status: str = "not_implemented"`. +func TestNewSecurityAuditDefault(t *testing.T) { + if got := NewSecurityAudit().Status; got != "not_implemented" { + t.Errorf("Status = %q, want %q", got, "not_implemented") + } +} + +// TestSecurityAuditJSONKey pins the field name a serialized stub would carry. +func TestSecurityAuditJSONKey(t *testing.T) { + b, err := json.Marshal(NewSecurityAudit()) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if want := `{"status":"not_implemented"}`; string(b) != want { + t.Errorf("marshaled as %s, want %s", b, want) + } +} diff --git a/go/internal/compliance/mapping.go b/go/internal/compliance/mapping.go new file mode 100644 index 0000000..9c3ff7b --- /dev/null +++ b/go/internal/compliance/mapping.go @@ -0,0 +1,604 @@ +// Package compliance ports src/sec_af/compliance/mapping.py: the static +// CWE -> framework-control table SEC-AF scores findings against, the lookup and +// normalization helpers around it, the AI fallback for CWEs the table does not +// cover, and the gap aggregation the audit result reports. +// +// The table itself lives in table_gen.go, mechanically derived from the Python +// module by scripts/gen_compliance_table.py — see that file. +package compliance + +import ( + "context" + "fmt" + "reflect" + "sort" + "strings" + "sync" + "unicode" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// severityRank ports the module-level _SEVERITY_RANK table. A severity that is +// not a key ranks 0 (Python's `.get(x, 0)`), which is what makes the +// `str(Severity.HIGH)` quirk in GetComplianceGaps observable — see +// severityKey. +var severityRank = map[string]int{ + "critical": 5, + "high": 4, + "medium": 3, + "low": 2, + "info": 1, +} + +// defaultFrameworks ports _DEFAULT_FRAMEWORKS: the framework list named in the +// AI-fallback prompt when the caller did not restrict the frameworks. +var defaultFrameworks = []string{"OWASP", "PCI-DSS", "SOC2", "HIPAA", "ISO27001"} + +// --------------------------------------------------------------------------- +// AI gate seam +// --------------------------------------------------------------------------- + +// AIGateLike mirrors mapping.py's `_AIGateLike` Protocol, narrowed to the one +// call GetComplianceMappingsHybrid actually makes: +// +// suggestion = await ai_gate.invoke(user=prompt, schema=ComplianceGate) +// +// Python's Protocol is generic over the pydantic model because AIGateWrapper +// serves every gate; a Go method cannot carry a type parameter, so the seam is +// specialised to ComplianceGate here. That also keeps this package free of any +// dependency on internal/gates (which in turn depends on aix, harnessx and the +// SDK) — the production wiring adapts the real gate with AIGateFunc: +// +// compliance.GetComplianceMappingsHybrid(ctx, cwe, frameworks, +// compliance.AIGateFunc(func(ctx context.Context, user string) (schemas.ComplianceGate, error) { +// return gates.Invoke[schemas.ComplianceGate](ctx, gate, user, "") +// })) +// +// A nil AIGateLike is Python's `ai_gate=None`: no fallback, empty result. +type AIGateLike interface { + // InvokeComplianceGate runs the gate with the compliance prompt and + // returns the parsed ComplianceGate. An error is Python's raised + // exception: GetComplianceMappingsHybrid swallows it and returns no + // mappings, exactly like the `except Exception: return []` in mapping.py. + InvokeComplianceGate(ctx context.Context, user string) (schemas.ComplianceGate, error) +} + +// AIGateFunc adapts a plain function to AIGateLike. +type AIGateFunc func(ctx context.Context, user string) (schemas.ComplianceGate, error) + +// InvokeComplianceGate implements AIGateLike. +func (f AIGateFunc) InvokeComplianceGate(ctx context.Context, user string) (schemas.ComplianceGate, error) { + return f(ctx, user) +} + +// --------------------------------------------------------------------------- +// normalization +// --------------------------------------------------------------------------- + +// normalizeCweID ports _normalize_cwe_id: +// +// raw = cwe_id.strip().upper() +// if raw.startswith("CWE-"): return raw +// if raw.startswith("CWE"): return f"CWE-{raw[3:]}" +// return f"CWE-{raw}" +// +// so "89", "cwe89", " CWE-89 " and "CWE89" all normalize to "CWE-89". +// +// Python parity note: `raw[3:]` slices CHARACTERS while Go slices bytes, but +// the slice only runs after an ASCII "CWE" prefix match, where the two agree. +// `str.upper()` is Unicode-aware in Python and can lengthen a string (ß -> SS) +// where strings.ToUpper does not; no CWE identifier contains such a character. +func normalizeCweID(cweID string) string { + raw := strings.ToUpper(pyStrip(cweID)) + if strings.HasPrefix(raw, "CWE-") { + return raw + } + if strings.HasPrefix(raw, "CWE") { + return "CWE-" + raw[3:] + } + return "CWE-" + raw +} + +// normalizeFramework ports _normalize_framework: +// +// framework.strip().lower().replace("_", "-") +// +// It is the comparison key for framework filtering, so "PCI-DSS", "pci-dss" +// and "pci_dss" all select the same controls. +func normalizeFramework(framework string) string { + return strings.ReplaceAll(strings.ToLower(pyStrip(framework)), "_", "-") +} + +// pyStrip reproduces Python's str.strip(): it trims every character for which +// `str.isspace()` is true. That is Go's unicode.IsSpace plus the four +// information separators U+001C..U+001F, which Go does not classify as space. +func pyStrip(s string) string { + return strings.TrimFunc(s, pyIsSpace) +} + +// pyIsSpace reports whether r is whitespace to Python (`str.isspace()`). +func pyIsSpace(r rune) bool { + if r >= 0x1C && r <= 0x1F { + return true + } + return unicode.IsSpace(r) +} + +// --------------------------------------------------------------------------- +// static lookup +// --------------------------------------------------------------------------- + +// GetComplianceMappings ports get_compliance_mappings: the static table lookup +// for one CWE, optionally restricted to a set of frameworks. +// +// Passing a nil or empty frameworks slice is Python's `frameworks=None` / +// `frameworks=[]` — both are falsy, so both mean "no filter". +// +// The returned slice is always freshly allocated and never aliases the table, +// which is what Python's `mapping.model_copy(deep=True)` buys: a caller that +// mutates a returned ComplianceMapping cannot corrupt the table for the next +// caller. ComplianceMapping is a struct of three strings, so a slice copy IS a +// deep copy. +// +// An unmapped CWE yields an empty (non-nil) slice, matching Python's `[]`. +func GetComplianceMappings(cweID string, frameworks []string) []schemas.ComplianceMapping { + normalized := normalizeCweID(cweID) + mappings := ComplianceMap[normalized] + + if len(frameworks) == 0 { + out := make([]schemas.ComplianceMapping, len(mappings)) + copy(out, mappings) + return out + } + + allowed := normalizedFrameworkSet(frameworks) + out := make([]schemas.ComplianceMapping, 0, len(mappings)) + for _, mapping := range mappings { + if _, ok := allowed[normalizeFramework(mapping.Framework)]; ok { + out = append(out, mapping) + } + } + return out +} + +// normalizedFrameworkSet builds Python's `{_normalize_framework(f) for f in frameworks}`. +func normalizedFrameworkSet(frameworks []string) map[string]struct{} { + allowed := make(map[string]struct{}, len(frameworks)) + for _, framework := range frameworks { + allowed[normalizeFramework(framework)] = struct{}{} + } + return allowed +} + +// GetSupportedFrameworks ports get_supported_frameworks: +// +// sorted({m.framework for ms in COMPLIANCE_MAP.values() for m in ms}) +// +// i.e. ["HIPAA", "ISO27001", "OWASP", "PCI-DSS", "SOC2"]. Python sorts strings +// by code point; Go's sort.Strings sorts by UTF-8 byte order, which is the same +// ordering for valid UTF-8. +func GetSupportedFrameworks() []string { + seen := map[string]struct{}{} + for _, mappings := range ComplianceMap { + for _, mapping := range mappings { + seen[mapping.Framework] = struct{}{} + } + } + out := make([]string, 0, len(seen)) + for framework := range seen { + out = append(out, framework) + } + sort.Strings(out) + return out +} + +// --------------------------------------------------------------------------- +// AI fallback + cache +// --------------------------------------------------------------------------- + +// aiCacheKey is the Go form of Python's cache key tuple +// `(normalized_cwe, tuple(sorted({normalized frameworks})) | None)`. +// +// A Go map key must be comparable, so the framework tuple is flattened into a +// single string joined on NUL (which cannot occur in a framework name) and the +// `None` case is carried by a separate bool rather than by the empty string — +// otherwise ("CWE-1", None) and ("CWE-1", ()) would collide, and Python keeps +// them distinct. +type aiCacheKey struct { + cwe string + frameworks string + hasFrameworks bool +} + +// aiComplianceCache ports the module-level _AI_COMPLIANCE_CACHE dict. +// +// Python's dict is safe by accident (the module is only ever driven from a +// single event loop); Go reasoners run genuinely in parallel, so the map is +// guarded by a mutex. The lock is deliberately NOT held across the gate call: +// that keeps a slow LLM round-trip from serialising every other CWE, at the +// cost of reproducing Python's behaviour where two concurrent misses for the +// same key both invoke the gate and the last writer wins. +var ( + aiComplianceCacheMu sync.Mutex + aiComplianceCache = map[aiCacheKey][]schemas.ComplianceMapping{} +) + +// ClearAICache empties the AI fallback cache. +// +// It is the port of the Python tests' `mapping._AI_COMPLIANCE_CACHE.clear()`: +// module-level caches leak across test cases, so every hybrid test starts by +// clearing it. Nothing in the production path calls this. +func ClearAICache() { + aiComplianceCacheMu.Lock() + defer aiComplianceCacheMu.Unlock() + aiComplianceCache = map[aiCacheKey][]schemas.ComplianceMapping{} +} + +// GetComplianceMappingsHybrid ports get_compliance_mappings_hybrid: the static +// table first, and only for a CWE the table does not know does it ask the AI +// gate — caching the answer so the same unknown CWE costs one LLM call per +// process. +// +// Python parity, in order: +// +// 1. the static lookup (already framework-filtered) wins whenever it is +// non-empty — the gate is not even constructed; +// 2. no gate (Python's `ai_gate is None`, Go's nil interface) yields []; +// 3. the cache is keyed by (normalized CWE, sorted normalized framework set or +// None) and stores the ALREADY FILTERED list; +// 4. a gate failure yields [] and is NOT cached, so a transient error does not +// poison the CWE for the rest of the run. Python swallows the exception +// (`except Exception: return []`) and so does this function — which is why +// it returns no error: any diagnostics belong in the AIGateLike +// implementation, where the caller still has the error in hand. +// +// Python parity quirk kept deliberately: the prompt interpolates +// `cwe_description = "Unknown CWE"` — a local constant that is never derived +// from the CWE — so every fallback prompt says "(Unknown CWE)". +func GetComplianceMappingsHybrid( + ctx context.Context, + cweID string, + frameworks []string, + aiGate AIGateLike, +) []schemas.ComplianceMapping { + normalizedCwe := normalizeCweID(cweID) + if cached := GetComplianceMappings(normalizedCwe, frameworks); len(cached) > 0 { + return cached + } + if aiGate == nil { + return []schemas.ComplianceMapping{} + } + + key := aiCacheKey{cwe: normalizedCwe} + frameworkList := append([]string(nil), defaultFrameworks...) + if len(frameworks) > 0 { + key.hasFrameworks = true + key.frameworks = sortedFrameworkTuple(frameworks) + frameworkList = append([]string(nil), frameworks...) + } + + if hit, ok := loadAICache(key); ok { + return hit + } + + prompt := complianceGatePrompt(normalizedCwe, frameworkList) + + suggestion, err := aiGate.InvokeComplianceGate(ctx, prompt) + if err != nil { + // Python parity: `except Exception: return []` — not cached. + return []schemas.ComplianceMapping{} + } + + aiMappings := make([]schemas.ComplianceMapping, 0, len(suggestion.Mappings)) + for _, item := range suggestion.Mappings { + aiMappings = append(aiMappings, schemas.ComplianceMapping{ + Framework: item.Framework, + ControlID: item.ControlID, + ControlName: item.ControlName, + }) + } + if len(frameworks) > 0 { + allowed := normalizedFrameworkSet(frameworks) + filtered := make([]schemas.ComplianceMapping, 0, len(aiMappings)) + for _, mapping := range aiMappings { + if _, ok := allowed[normalizeFramework(mapping.Framework)]; ok { + filtered = append(filtered, mapping) + } + } + aiMappings = filtered + } + + storeAICache(key, aiMappings) + out := make([]schemas.ComplianceMapping, len(aiMappings)) + copy(out, aiMappings) + return out +} + +// complianceGatePrompt builds the AI-fallback prompt byte-for-byte as +// mapping.py does. +// +// Python parity: the `", ".join(framework_list) if framework_list else ...` +// fallback is unreachable — framework_list is either the caller's non-empty +// list or _DEFAULT_FRAMEWORKS — but it is reproduced so the two functions read +// the same. +func complianceGatePrompt(normalizedCwe string, frameworkList []string) string { + const cweDescription = "Unknown CWE" + frameworkPrompt := strings.Join(frameworkList, ", ") + if len(frameworkList) == 0 { + frameworkPrompt = strings.Join(defaultFrameworks, ", ") + } + return fmt.Sprintf( + "Map %s (%s) to compliance framework controls. Frameworks: %s. Return specific control IDs.", + normalizedCwe, cweDescription, frameworkPrompt, + ) +} + +// sortedFrameworkTuple renders `tuple(sorted({_normalize_framework(f) for f in frameworks}))` +// as a NUL-joined string usable as a Go map key. +func sortedFrameworkTuple(frameworks []string) string { + set := normalizedFrameworkSet(frameworks) + names := make([]string, 0, len(set)) + for name := range set { + names = append(names, name) + } + sort.Strings(names) + return strings.Join(names, "\x00") +} + +// loadAICache returns a fresh copy of the cached mappings for key, matching +// Python's `[m.model_copy(deep=True) for m in cache[key]]`. +func loadAICache(key aiCacheKey) ([]schemas.ComplianceMapping, bool) { + aiComplianceCacheMu.Lock() + defer aiComplianceCacheMu.Unlock() + cached, ok := aiComplianceCache[key] + if !ok { + return nil, false + } + out := make([]schemas.ComplianceMapping, len(cached)) + copy(out, cached) + return out, true +} + +// storeAICache stores a private copy of mappings under key. +func storeAICache(key aiCacheKey, mappings []schemas.ComplianceMapping) { + stored := make([]schemas.ComplianceMapping, len(mappings)) + copy(stored, mappings) + aiComplianceCacheMu.Lock() + defer aiComplianceCacheMu.Unlock() + aiComplianceCache[key] = stored +} + +// --------------------------------------------------------------------------- +// gap aggregation +// --------------------------------------------------------------------------- + +// gapKey is Python's aggregation key tuple +// `(mapping.framework, mapping.control_id, mapping.control_name)`. +type gapKey struct { + framework string + controlID string + controlName string +} + +// gapEntry is the mutable accumulator Python keeps in the aggregation dict. +type gapEntry struct { + count int + maxSeverity string + cweIDs []string +} + +// GetComplianceGaps ports get_compliance_gaps: it walks findings, maps each +// one's CWE through the static table, and aggregates per framework control how +// many findings hit it, which CWEs, and the worst severity among them. +// +// The parameter is generic so the two Python call shapes both work unchanged: +// `[]schemas.VerifiedFinding` (what the orchestrator passes) and +// `[]any` / `[]map[string]any` (what the tests pass). Field access is the port +// of `_read_field`, i.e. Python's duck typing — see readField. +// +// Findings without a truthy `cwe_id`, and findings whose CWE the table does not +// know, are skipped (Python `continue`s on both). +// +// The result is sorted by (framework, control_id, control_name), so it is fully +// deterministic even though the aggregation itself runs over a Go map. +func GetComplianceGaps[T any](findings []T) []schemas.ComplianceGap { + aggregated := map[gapKey]*gapEntry{} + order := make([]gapKey, 0, len(findings)) + + for _, finding := range findings { + item := any(finding) + cweID := readField(item, "cwe_id") + severity := severityKey(readField(item, "severity")) + if !pyTruthy(cweID) { + continue + } + + normalizedCwe := normalizeCweID(pyStrOfField(cweID)) + mappings := GetComplianceMappings(normalizedCwe, nil) + if len(mappings) == 0 { + continue + } + + for _, mapping := range mappings { + key := gapKey{mapping.Framework, mapping.ControlID, mapping.ControlName} + entry, ok := aggregated[key] + if !ok { + entry = &gapEntry{maxSeverity: "info"} + aggregated[key] = entry + order = append(order, key) + } + + entry.count++ + if !containsString(entry.cweIDs, normalizedCwe) { + entry.cweIDs = append(entry.cweIDs, normalizedCwe) + } + + // Python parity: an unrecognised severity ranks 0, so it can never + // raise max_severity above the "info" seed — see severityKey for + // why a real VerifiedFinding always takes that branch. + if severityRank[severity] > severityRank[entry.maxSeverity] { + entry.maxSeverity = severity + } + } + } + + gaps := make([]schemas.ComplianceGap, 0, len(order)) + for _, key := range order { + entry := aggregated[key] + cweIDs := append([]string(nil), entry.cweIDs...) + sort.Strings(cweIDs) + gaps = append(gaps, schemas.ComplianceGap{ + Framework: key.framework, + ControlID: key.controlID, + ControlName: key.controlName, + FindingCount: entry.count, + MaxSeverity: entry.maxSeverity, + CweIDs: cweIDs, + }) + } + sort.SliceStable(gaps, func(i, j int) bool { + a, b := gaps[i], gaps[j] + if a.Framework != b.Framework { + return a.Framework < b.Framework + } + if a.ControlID != b.ControlID { + return a.ControlID < b.ControlID + } + return a.ControlName < b.ControlName + }) + return gaps +} + +// readField ports _read_field: +// +// if isinstance(finding, dict): return finding.get(field_name) +// return getattr(finding, field_name, None) +// +// A Go map stands in for the dict branch (any string-keyed map, not just +// map[string]any) and a struct for the attribute branch, where the "attribute +// name" is the json tag — the schemas package tags every field with its exact +// pydantic field name, which is also its Python attribute name. A missing key, +// a missing field, or a nil finding all yield nil, Python's None default. +func readField(finding any, fieldName string) any { + if finding == nil { + return nil + } + rv := reflect.ValueOf(finding) + for rv.Kind() == reflect.Pointer || rv.Kind() == reflect.Interface { + if rv.IsNil() { + return nil + } + rv = rv.Elem() + } + + switch rv.Kind() { + case reflect.Map: + if rv.Type().Key().Kind() != reflect.String { + return nil + } + got := rv.MapIndex(reflect.ValueOf(fieldName).Convert(rv.Type().Key())) + if !got.IsValid() { + return nil + } + return got.Interface() + case reflect.Struct: + rt := rv.Type() + for i := 0; i < rt.NumField(); i++ { + field := rt.Field(i) + if !field.IsExported() { + continue + } + name, _, _ := strings.Cut(field.Tag.Get("json"), ",") + if name == "" { + name = field.Name + } + if name == fieldName { + return rv.Field(i).Interface() + } + } + return nil + default: + return nil + } +} + +// severityKey ports `str(_read_field(finding, "severity") or "low").lower()`. +// +// Python parity — the quirk this function exists to preserve: `Severity` is a +// `class Severity(str, Enum)`, and CPython 3.11 renders such a member with +// Enum.__str__, so `str(Severity.HIGH)` is "Severity.HIGH", not "high". The +// audit orchestrator passes real VerifiedFinding objects, so every production +// call produces "severity.high" — a key that is absent from _SEVERITY_RANK and +// therefore ranks 0, which is why every gap the orchestrator emits carries +// max_severity "info". Only the dict-shaped findings the tests pass (plain +// strings) rank at all. Reproducing the quirk is deliberate; do not "fix" it. +func severityKey(value any) string { + if !pyTruthy(value) { + return "low" + } + return strings.ToLower(pyStrOfField(value)) +} + +// pyStrOfField renders `str(x)` for the field values readField can return. +// +// A plain Go string is itself. A schemas.Severity is a Python `(str, Enum)` +// member and stringifies as "Severity."; every member's name is the +// upper-case of its value, so that is how the name is recovered. Anything else +// falls back to fmt.Sprint, which covers the ints and bools a dict-shaped +// finding might carry. +func pyStrOfField(value any) string { + switch x := value.(type) { + case nil: + return "None" + case string: + return x + case schemas.Severity: + return "Severity." + strings.ToUpper(string(x)) + case bool: + if x { + return "True" + } + return "False" + } + return fmt.Sprint(value) +} + +// pyTruthy reproduces `bool(x)` for the value kinds readField returns: nil, +// False, 0, "" and empty containers are falsy, everything else is truthy. +func pyTruthy(value any) bool { + if value == nil { + return false + } + rv := reflect.ValueOf(value) + switch rv.Kind() { + case reflect.Bool: + return rv.Bool() + case reflect.String: + return rv.Len() != 0 + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return rv.Int() != 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return rv.Uint() != 0 + case reflect.Float32, reflect.Float64: + return rv.Float() != 0 + case reflect.Slice, reflect.Array, reflect.Map: + return rv.Len() != 0 + case reflect.Pointer, reflect.Interface: + if rv.IsNil() { + return false + } + return pyTruthy(rv.Elem().Interface()) + } + return true +} + +// containsString reports whether list already holds value. +func containsString(list []string, value string) bool { + for _, item := range list { + if item == value { + return true + } + } + return false +} diff --git a/go/internal/compliance/mapping_test.go b/go/internal/compliance/mapping_test.go new file mode 100644 index 0000000..de76a4f --- /dev/null +++ b/go/internal/compliance/mapping_test.go @@ -0,0 +1,723 @@ +package compliance + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "sort" + "sync" + "testing" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// requiredCWEs ports tests/test_compliance.py::REQUIRED_CWES. +var requiredCWEs = []string{ + "CWE-78", "CWE-79", "CWE-89", "CWE-90", "CWE-91", "CWE-94", "CWE-917", + "CWE-287", "CWE-306", "CWE-352", "CWE-862", "CWE-863", "CWE-326", + "CWE-327", "CWE-328", "CWE-330", "CWE-916", "CWE-840", "CWE-841", + "CWE-200", "CWE-209", "CWE-312", "CWE-319", "CWE-532", "CWE-829", + "CWE-1104", "CWE-16", "CWE-259", "CWE-321", "CWE-798", "CWE-285", + "CWE-346", "CWE-601", "CWE-918", +} + +// fakeAIGate ports tests/test_compliance.py::_FakeAIGate. +type fakeAIGate struct { + mu sync.Mutex + calls int + prompts []string + response schemas.ComplianceGate + err error +} + +func (f *fakeAIGate) InvokeComplianceGate(_ context.Context, user string) (schemas.ComplianceGate, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + f.prompts = append(f.prompts, user) + if f.err != nil { + return schemas.ComplianceGate{}, f.err + } + return f.response, nil +} + +func (f *fakeAIGate) callCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.calls +} + +// namespaceFinding stands in for the tests' SimpleNamespace findings: an +// object (not a dict) whose attributes carry the finding fields. +type namespaceFinding struct { + CweID string `json:"cwe_id"` + Severity string `json:"severity"` +} + +func frameworkSet(mappings []schemas.ComplianceMapping) map[string]struct{} { + out := map[string]struct{}{} + for _, mapping := range mappings { + out[mapping.Framework] = struct{}{} + } + return out +} + +func sortedKeys(set map[string]struct{}) []string { + out := make([]string, 0, len(set)) + for key := range set { + out = append(out, key) + } + sort.Strings(out) + return out +} + +// --------------------------------------------------------------------------- +// static table +// --------------------------------------------------------------------------- + +// TestKeyCWEsIncludeRequiredFrameworkMappings ports +// test_key_cwes_include_required_framework_mappings (parametrized). +func TestKeyCWEsIncludeRequiredFrameworkMappings(t *testing.T) { + cases := []struct { + cweID string + owaspControl string + }{ + {"CWE-89", "A03:2021"}, + {"CWE-79", "A03:2021"}, + {"CWE-287", "A07:2021"}, + {"CWE-862", "A01:2021"}, + {"CWE-326", "A02:2021"}, + {"CWE-840", "A04:2021"}, + {"CWE-200", "A01:2021"}, + {"CWE-1104", "A06:2021"}, + {"CWE-16", "A05:2021"}, + {"CWE-918", "A10:2021"}, + } + for _, tc := range cases { + t.Run(tc.cweID, func(t *testing.T) { + mappings := GetComplianceMappings(tc.cweID, nil) + frameworks := frameworkSet(mappings) + for _, want := range []string{"PCI-DSS", "SOC2", "OWASP"} { + if _, ok := frameworks[want]; !ok { + t.Fatalf("%s: framework %q missing from %v", tc.cweID, want, sortedKeys(frameworks)) + } + } + found := false + for _, mapping := range mappings { + if mapping.Framework == "OWASP" && mapping.ControlID == tc.owaspControl { + found = true + } + } + if !found { + t.Fatalf("%s: no OWASP mapping with control id %q", tc.cweID, tc.owaspControl) + } + }) + } +} + +// TestAllRequiredCWEsAreMapped ports test_all_required_cwes_are_mapped. +func TestAllRequiredCWEsAreMapped(t *testing.T) { + got := make([]string, 0, len(ComplianceMap)) + for cwe := range ComplianceMap { + got = append(got, cwe) + } + want := append([]string(nil), requiredCWEs...) + sort.Strings(got) + sort.Strings(want) + if !reflect.DeepEqual(got, want) { + t.Fatalf("COMPLIANCE_MAP keys mismatch\n got: %v\nwant: %v", got, want) + } +} + +// TestGetComplianceMappingsHandlesCweNormalization ports +// test_get_compliance_mappings_handles_cwe_normalization. +func TestGetComplianceMappingsHandlesCweNormalization(t *testing.T) { + normalized := GetComplianceMappings("CWE-89", nil) + shorthand := GetComplianceMappings("89", nil) + mixedCase := GetComplianceMappings("cwe89", nil) + + if len(normalized) == 0 { + t.Fatal("CWE-89 has no mappings") + } + if !reflect.DeepEqual(shorthand, normalized) { + t.Fatalf("\"89\" != \"CWE-89\": %v vs %v", shorthand, normalized) + } + if !reflect.DeepEqual(mixedCase, normalized) { + t.Fatalf("\"cwe89\" != \"CWE-89\": %v vs %v", mixedCase, normalized) + } +} + +// TestNormalizeCweID covers _normalize_cwe_id's three branches directly, +// including the whitespace strip Python's `.strip()` performs. +func TestNormalizeCweID(t *testing.T) { + cases := map[string]string{ + "CWE-89": "CWE-89", + "cwe-89": "CWE-89", + " CWE-89 ": "CWE-89", + "CWE89": "CWE-89", + "cwe89": "CWE-89", + "89": "CWE-89", + "\t89\n": "CWE-89", + "": "CWE-", + "CWE": "CWE-", + "CWE-": "CWE-", + } + for input, want := range cases { + if got := normalizeCweID(input); got != want { + t.Errorf("normalizeCweID(%q) = %q, want %q", input, got, want) + } + } +} + +// TestNormalizeFramework covers _normalize_framework. +func TestNormalizeFramework(t *testing.T) { + cases := map[string]string{ + "PCI-DSS": "pci-dss", + "pci_dss": "pci-dss", + " SOC2 ": "soc2", + "ISO_27001": "iso-27001", + } + for input, want := range cases { + if got := normalizeFramework(input); got != want { + t.Errorf("normalizeFramework(%q) = %q, want %q", input, got, want) + } + } +} + +// TestGetComplianceMappingsCanFilterFrameworks ports +// test_get_compliance_mappings_can_filter_frameworks. +func TestGetComplianceMappingsCanFilterFrameworks(t *testing.T) { + filtered := GetComplianceMappings("CWE-319", []string{"pci-dss", "owasp"}) + got := sortedKeys(frameworkSet(filtered)) + want := []string{"OWASP", "PCI-DSS"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("frameworks = %v, want %v", got, want) + } +} + +// TestGetComplianceMappingsFrameworkFilterWithUnknownFramework ports +// test_get_compliance_mappings_framework_filter_with_unknown_framework. +func TestGetComplianceMappingsFrameworkFilterWithUnknownFramework(t *testing.T) { + filtered := GetComplianceMappings("CWE-89", []string{"owasp", "does-not-exist"}) + got := sortedKeys(frameworkSet(filtered)) + want := []string{"OWASP"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("frameworks = %v, want %v", got, want) + } +} + +// TestGetComplianceMappingsReturnsEmptyForUnmappedCwe ports +// test_get_compliance_mappings_returns_empty_for_unmapped_cwe. The Python +// assertion is `== []`, so the Go result must be empty AND non-nil. +func TestGetComplianceMappingsReturnsEmptyForUnmappedCwe(t *testing.T) { + got := GetComplianceMappings("CWE-9999", nil) + if got == nil { + t.Fatal("want an empty non-nil slice (Python returns []), got nil") + } + if len(got) != 0 { + t.Fatalf("want no mappings, got %v", got) + } +} + +// TestGetComplianceMappingsReturnsDeepCopies ports +// test_get_compliance_mappings_returns_deep_copies: mutating one result must +// not be visible to the next caller, nor corrupt the table. +func TestGetComplianceMappingsReturnsDeepCopies(t *testing.T) { + first := GetComplianceMappings("CWE-89", nil) + second := GetComplianceMappings("CWE-89", nil) + + first[0].ControlName = "mutated" + + if second[0].ControlName == "mutated" { + t.Fatal("second call observed the first call's mutation") + } + if ComplianceMap["CWE-89"][0].ControlName == "mutated" { + t.Fatal("the static table itself was mutated") + } +} + +// TestGetSupportedFrameworksReturnsExpectedSet ports +// test_get_supported_frameworks_returns_expected_set. +func TestGetSupportedFrameworksReturnsExpectedSet(t *testing.T) { + want := []string{"HIPAA", "ISO27001", "OWASP", "PCI-DSS", "SOC2"} + if got := GetSupportedFrameworks(); !reflect.DeepEqual(got, want) { + t.Fatalf("GetSupportedFrameworks() = %v, want %v", got, want) + } +} + +// --------------------------------------------------------------------------- +// gap aggregation +// --------------------------------------------------------------------------- + +// TestGetComplianceGapsAggregatesCountAndMaxSeverity ports +// test_get_compliance_gaps_aggregates_count_and_max_severity. +func TestGetComplianceGapsAggregatesCountAndMaxSeverity(t *testing.T) { + findings := []map[string]any{ + {"cwe_id": "CWE-89", "severity": "high"}, + {"cwe_id": "CWE-79", "severity": "critical"}, + {"cwe_id": "CWE-918", "severity": "medium"}, + } + + gaps := GetComplianceGaps(findings) + + var pciInjection []schemas.ComplianceGap + for _, gap := range gaps { + if gap.Framework == "PCI-DSS" && gap.ControlID == "Req 6.2.4" { + pciInjection = append(pciInjection, gap) + } + } + if len(pciInjection) != 1 { + t.Fatalf("want exactly one PCI-DSS Req 6.2.4 gap, got %d", len(pciInjection)) + } + if pciInjection[0].FindingCount != 2 { + t.Errorf("finding_count = %d, want 2", pciInjection[0].FindingCount) + } + if pciInjection[0].MaxSeverity != "critical" { + t.Errorf("max_severity = %q, want \"critical\"", pciInjection[0].MaxSeverity) + } + if !reflect.DeepEqual(pciInjection[0].CweIDs, []string{"CWE-79", "CWE-89"}) { + t.Errorf("cwe_ids = %v, want [CWE-79 CWE-89]", pciInjection[0].CweIDs) + } +} + +// TestGetComplianceGapsSortsByFrameworkControl asserts the documented ordering +// contract: sorted by (framework, control_id, control_name). +func TestGetComplianceGapsSortsByFrameworkControl(t *testing.T) { + gaps := GetComplianceGaps([]map[string]any{ + {"cwe_id": "CWE-89", "severity": "high"}, + {"cwe_id": "CWE-918", "severity": "low"}, + }) + if len(gaps) == 0 { + t.Fatal("no gaps") + } + for i := 1; i < len(gaps); i++ { + prev, cur := gaps[i-1], gaps[i] + if prev.Framework > cur.Framework || + (prev.Framework == cur.Framework && prev.ControlID > cur.ControlID) || + (prev.Framework == cur.Framework && prev.ControlID == cur.ControlID && prev.ControlName > cur.ControlName) { + t.Fatalf("gaps not sorted at %d: %+v then %+v", i, prev, cur) + } + } +} + +// TestGetComplianceGapsAcceptsObjectFindingsAndNormalizesCwe ports +// test_get_compliance_gaps_accepts_object_findings_and_normalizes_cwe. +func TestGetComplianceGapsAcceptsObjectFindingsAndNormalizesCwe(t *testing.T) { + findings := []namespaceFinding{ + {CweID: "89", Severity: "high"}, + {CweID: "cwe89", Severity: "critical"}, + } + + gaps := GetComplianceGaps(findings) + + var owaspGap *schemas.ComplianceGap + for i := range gaps { + if gaps[i].Framework == "OWASP" && gaps[i].ControlID == "A03:2021" { + owaspGap = &gaps[i] + break + } + } + if owaspGap == nil { + t.Fatal("no OWASP A03:2021 gap") + } + if owaspGap.FindingCount != 2 { + t.Errorf("finding_count = %d, want 2", owaspGap.FindingCount) + } + if owaspGap.MaxSeverity != "critical" { + t.Errorf("max_severity = %q, want \"critical\"", owaspGap.MaxSeverity) + } + if !reflect.DeepEqual(owaspGap.CweIDs, []string{"CWE-89"}) { + t.Errorf("cwe_ids = %v, want [CWE-89]", owaspGap.CweIDs) + } +} + +// TestGetComplianceGapsIgnoresEntriesWithoutValidCwe ports +// test_get_compliance_gaps_ignores_entries_without_valid_cwe. +func TestGetComplianceGapsIgnoresEntriesWithoutValidCwe(t *testing.T) { + findings := []map[string]any{ + {"severity": "critical"}, + {"cwe_id": nil, "severity": "high"}, + {"cwe_id": "CWE-9999", "severity": "critical"}, + } + + gaps := GetComplianceGaps(findings) + if len(gaps) != 0 { + t.Fatalf("want no gaps, got %v", gaps) + } +} + +// TestGetComplianceGapsVerifiedFindingSeverityQuirk pins the CPython behaviour +// documented on severityKey: a real VerifiedFinding carries a +// `class Severity(str, Enum)` member, `str()` of which is "Severity.CRITICAL", +// so the lower-cased key misses _SEVERITY_RANK entirely and max_severity stays +// at its "info" seed. Ground truth captured from +// ~/.agentfield/packages/sec-af/venv/bin/python. +func TestGetComplianceGapsVerifiedFindingSeverityQuirk(t *testing.T) { + finding := schemas.NewVerifiedFinding() + finding.CweID = "CWE-89" + finding.Severity = schemas.SeverityCritical + + gaps := GetComplianceGaps([]schemas.VerifiedFinding{finding}) + + if len(gaps) != 5 { + t.Fatalf("want 5 gaps for CWE-89, got %d", len(gaps)) + } + for _, gap := range gaps { + if gap.MaxSeverity != "info" { + t.Errorf("%s %s: max_severity = %q, want \"info\" (Python str(Severity.CRITICAL) quirk)", + gap.Framework, gap.ControlID, gap.MaxSeverity) + } + if gap.FindingCount != 1 { + t.Errorf("%s %s: finding_count = %d, want 1", gap.Framework, gap.ControlID, gap.FindingCount) + } + } + // Ground truth from Python for the same input, in result order. + wantControls := []string{"§164.312(a)(1)", "A.8.28", "A03:2021", "Req 6.2.4", "CC6"} + wantFrameworks := []string{"HIPAA", "ISO27001", "OWASP", "PCI-DSS", "SOC2"} + for i, gap := range gaps { + if gap.Framework != wantFrameworks[i] || gap.ControlID != wantControls[i] { + t.Fatalf("gap %d = (%s, %s), want (%s, %s)", i, gap.Framework, gap.ControlID, wantFrameworks[i], wantControls[i]) + } + } +} + +// TestGetComplianceGapsPointerFindings proves readField follows pointers, the +// shape a caller holding []*VerifiedFinding would produce. +func TestGetComplianceGapsPointerFindings(t *testing.T) { + gaps := GetComplianceGaps([]*namespaceFinding{{CweID: "CWE-89", Severity: "high"}}) + if len(gaps) != 5 { + t.Fatalf("want 5 gaps, got %d", len(gaps)) + } + if gaps[0].MaxSeverity != "high" { + t.Fatalf("max_severity = %q, want \"high\"", gaps[0].MaxSeverity) + } +} + +// TestReadField covers _read_field's two branches plus the missing-key default. +func TestReadField(t *testing.T) { + if got := readField(map[string]any{"cwe_id": "CWE-1"}, "cwe_id"); got != "CWE-1" { + t.Errorf("dict branch: got %v", got) + } + if got := readField(map[string]any{}, "cwe_id"); got != nil { + t.Errorf("dict missing key: got %v, want nil", got) + } + if got := readField(namespaceFinding{CweID: "CWE-2"}, "cwe_id"); got != "CWE-2" { + t.Errorf("attribute branch: got %v", got) + } + if got := readField(namespaceFinding{}, "not_a_field"); got != nil { + t.Errorf("missing attribute: got %v, want nil", got) + } + if got := readField(nil, "cwe_id"); got != nil { + t.Errorf("nil finding: got %v, want nil", got) + } + if got := readField(map[string]string{"severity": "low"}, "severity"); got != "low" { + t.Errorf("typed map branch: got %v", got) + } +} + +// TestSeverityKey covers `str(x or "low").lower()`. +func TestSeverityKey(t *testing.T) { + cases := []struct { + in any + want string + }{ + {nil, "low"}, + {"", "low"}, + {"HIGH", "high"}, + {"critical", "critical"}, + {schemas.SeverityHigh, "severity.high"}, + {schemas.SeverityInfo, "severity.info"}, + } + for _, tc := range cases { + if got := severityKey(tc.in); got != tc.want { + t.Errorf("severityKey(%#v) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// --------------------------------------------------------------------------- +// hybrid / AI fallback +// --------------------------------------------------------------------------- + +// TestGetComplianceMappingsHybridUsesAIFallbackForUnknownCwe ports +// test_get_compliance_mappings_hybrid_uses_ai_fallback_for_unknown_cwe. +func TestGetComplianceMappingsHybridUsesAIFallbackForUnknownCwe(t *testing.T) { + ClearAICache() + gate := &fakeAIGate{response: schemas.ComplianceGate{ + Mappings: []schemas.ComplianceSuggestion{ + {Framework: "OWASP", ControlID: "A03:2021", ControlName: "Injection"}, + {Framework: "PCI-DSS", ControlID: "Req 6.2.4", ControlName: "Prevent injection attacks"}, + }, + Confidence: "high", + }} + + results := GetComplianceMappingsHybrid(context.Background(), "CWE-9999", nil, gate) + + if gate.callCount() != 1 { + t.Fatalf("gate calls = %d, want 1", gate.callCount()) + } + if len(results) != 2 { + t.Fatalf("results = %v, want 2 mappings", results) + } + got := sortedKeys(frameworkSet(results)) + if !reflect.DeepEqual(got, []string{"OWASP", "PCI-DSS"}) { + t.Fatalf("frameworks = %v, want [OWASP PCI-DSS]", got) + } +} + +// TestGetComplianceMappingsHybridUsesCacheForAIResults ports +// test_get_compliance_mappings_hybrid_uses_cache_for_ai_results. +func TestGetComplianceMappingsHybridUsesCacheForAIResults(t *testing.T) { + ClearAICache() + gate := &fakeAIGate{response: schemas.ComplianceGate{ + Mappings: []schemas.ComplianceSuggestion{ + {Framework: "OWASP", ControlID: "A03:2021", ControlName: "Injection"}, + }, + Confidence: "high", + }} + + first := GetComplianceMappingsHybrid(context.Background(), "CWE-9999", nil, gate) + second := GetComplianceMappingsHybrid(context.Background(), "CWE-9999", nil, gate) + + if gate.callCount() != 1 { + t.Fatalf("gate calls = %d, want 1 (second call must hit the cache)", gate.callCount()) + } + if !reflect.DeepEqual(first, second) { + t.Fatalf("cached result differs: %v vs %v", first, second) + } +} + +// TestGetComplianceMappingsHybridKeepsFastPathForKnownCwe ports +// test_get_compliance_mappings_hybrid_keeps_fast_path_for_known_cwe. +func TestGetComplianceMappingsHybridKeepsFastPathForKnownCwe(t *testing.T) { + ClearAICache() + gate := &fakeAIGate{response: schemas.ComplianceGate{Confidence: "low"}} + + results := GetComplianceMappingsHybrid(context.Background(), "CWE-89", nil, gate) + + if gate.callCount() != 0 { + t.Fatalf("gate calls = %d, want 0 for a table-known CWE", gate.callCount()) + } + if !reflect.DeepEqual(results, GetComplianceMappings("CWE-89", nil)) { + t.Fatalf("hybrid result differs from the static lookup: %v", results) + } +} + +// TestGetComplianceMappingsHybridWithoutGate covers `ai_gate is None`. +func TestGetComplianceMappingsHybridWithoutGate(t *testing.T) { + ClearAICache() + got := GetComplianceMappingsHybrid(context.Background(), "CWE-9999", nil, nil) + if got == nil || len(got) != 0 { + t.Fatalf("want an empty non-nil slice, got %#v", got) + } +} + +// TestGetComplianceMappingsHybridSwallowsGateErrors covers +// `except Exception: return []` — and asserts the failure is NOT cached, so a +// later successful call still reaches the gate. +func TestGetComplianceMappingsHybridSwallowsGateErrors(t *testing.T) { + ClearAICache() + gate := &fakeAIGate{err: errors.New("boom")} + + got := GetComplianceMappingsHybrid(context.Background(), "CWE-9999", nil, gate) + if got == nil || len(got) != 0 { + t.Fatalf("want an empty non-nil slice, got %#v", got) + } + + gate.err = nil + gate.response = schemas.ComplianceGate{ + Mappings: []schemas.ComplianceSuggestion{{Framework: "OWASP", ControlID: "A03:2021", ControlName: "Injection"}}, + Confidence: "high", + } + retry := GetComplianceMappingsHybrid(context.Background(), "CWE-9999", nil, gate) + if len(retry) != 1 { + t.Fatalf("retry after a failure returned %v", retry) + } + if gate.callCount() != 2 { + t.Fatalf("gate calls = %d, want 2 (a failure must not be cached)", gate.callCount()) + } +} + +// TestGetComplianceMappingsHybridFiltersAIMappings asserts the framework filter +// is applied to the gate's answer, not just to the static table. +func TestGetComplianceMappingsHybridFiltersAIMappings(t *testing.T) { + ClearAICache() + gate := &fakeAIGate{response: schemas.ComplianceGate{ + Mappings: []schemas.ComplianceSuggestion{ + {Framework: "OWASP", ControlID: "A03:2021", ControlName: "Injection"}, + {Framework: "PCI-DSS", ControlID: "Req 6.2.4", ControlName: "Prevent injection"}, + }, + Confidence: "high", + }} + + got := GetComplianceMappingsHybrid(context.Background(), "CWE-9999", []string{"owasp"}, gate) + + if len(got) != 1 || got[0].Framework != "OWASP" { + t.Fatalf("want only the OWASP mapping, got %v", got) + } +} + +// TestGetComplianceMappingsHybridCacheKeyDistinguishesFrameworks pins the +// Python cache key: `(cwe, None)` and `(cwe, ("owasp",))` are distinct entries, +// so restricting the frameworks re-queries the gate. +func TestGetComplianceMappingsHybridCacheKeyDistinguishesFrameworks(t *testing.T) { + ClearAICache() + gate := &fakeAIGate{response: schemas.ComplianceGate{ + Mappings: []schemas.ComplianceSuggestion{{Framework: "OWASP", ControlID: "A03:2021", ControlName: "Injection"}}, + Confidence: "high", + }} + + _ = GetComplianceMappingsHybrid(context.Background(), "CWE-9999", nil, gate) + _ = GetComplianceMappingsHybrid(context.Background(), "CWE-9999", []string{"owasp"}, gate) + if gate.callCount() != 2 { + t.Fatalf("gate calls = %d, want 2 (None and ('owasp',) are distinct keys)", gate.callCount()) + } + + // The same framework set spelled differently normalizes to the same key. + _ = GetComplianceMappingsHybrid(context.Background(), "CWE-9999", []string{"OWASP"}, gate) + _ = GetComplianceMappingsHybrid(context.Background(), "CWE-9999", []string{"owasp", "owasp"}, gate) + if gate.callCount() != 2 { + t.Fatalf("gate calls = %d, want 2 (normalized framework sets share a key)", gate.callCount()) + } +} + +// TestGetComplianceMappingsHybridCachedResultsAreCopies asserts a caller cannot +// corrupt the cache by mutating what it was handed (Python model_copy(deep=True)). +func TestGetComplianceMappingsHybridCachedResultsAreCopies(t *testing.T) { + ClearAICache() + gate := &fakeAIGate{response: schemas.ComplianceGate{ + Mappings: []schemas.ComplianceSuggestion{{Framework: "OWASP", ControlID: "A03:2021", ControlName: "Injection"}}, + Confidence: "high", + }} + + first := GetComplianceMappingsHybrid(context.Background(), "CWE-9999", nil, gate) + first[0].ControlName = "mutated" + second := GetComplianceMappingsHybrid(context.Background(), "CWE-9999", nil, gate) + + if second[0].ControlName != "Injection" { + t.Fatalf("cache was corrupted by the caller: %q", second[0].ControlName) + } +} + +// TestComplianceGatePrompt pins the fallback prompt text byte-for-byte, +// including the "(Unknown CWE)" literal. +func TestComplianceGatePrompt(t *testing.T) { + ClearAICache() + gate := &fakeAIGate{response: schemas.ComplianceGate{Confidence: "low"}} + + _ = GetComplianceMappingsHybrid(context.Background(), "cwe9999", nil, gate) + want := "Map CWE-9999 (Unknown CWE) to compliance framework controls. " + + "Frameworks: OWASP, PCI-DSS, SOC2, HIPAA, ISO27001. Return specific control IDs." + if gate.prompts[0] != want { + t.Fatalf("prompt =\n%q\nwant\n%q", gate.prompts[0], want) + } + + ClearAICache() + gate2 := &fakeAIGate{response: schemas.ComplianceGate{Confidence: "low"}} + _ = GetComplianceMappingsHybrid(context.Background(), "CWE-9999", []string{"PCI-DSS", "owasp"}, gate2) + want2 := "Map CWE-9999 (Unknown CWE) to compliance framework controls. " + + "Frameworks: PCI-DSS, owasp. Return specific control IDs." + if gate2.prompts[0] != want2 { + t.Fatalf("prompt =\n%q\nwant\n%q", gate2.prompts[0], want2) + } +} + +// TestGetComplianceMappingsHybridConcurrent exercises the cache under -race: +// Python's dict is single-event-loop safe, the Go port must be mutex safe. +func TestGetComplianceMappingsHybridConcurrent(t *testing.T) { + ClearAICache() + gate := &fakeAIGate{response: schemas.ComplianceGate{ + Mappings: []schemas.ComplianceSuggestion{{Framework: "OWASP", ControlID: "A03:2021", ControlName: "Injection"}}, + Confidence: "high", + }} + + var wg sync.WaitGroup + for i := 0; i < 32; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + cwe := "CWE-90" + string(rune('0'+i%10)) + got := GetComplianceMappingsHybrid(context.Background(), cwe, nil, gate) + if len(got) != 1 { + t.Errorf("%s: got %v", cwe, got) + } + }(i) + } + wg.Wait() +} + +// --------------------------------------------------------------------------- +// generated-table drift guard +// --------------------------------------------------------------------------- + +// TestComplianceMapMatchesPythonSnapshot compares the generated Go table +// against testdata/compliance_map.json, which scripts/gen_compliance_table.py +// writes from the live Python COMPLIANCE_MAP in the same run that writes +// table_gen.go. A hand edit to either file fails here. +func TestComplianceMapMatchesPythonSnapshot(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "compliance_map.json")) + if err != nil { + t.Fatalf("read snapshot: %v", err) + } + var snapshot map[string][]schemas.ComplianceMapping + if err := json.Unmarshal(raw, &snapshot); err != nil { + t.Fatalf("decode snapshot: %v", err) + } + if !reflect.DeepEqual(ComplianceMap, snapshot) { + if len(ComplianceMap) != len(snapshot) { + t.Fatalf("table has %d CWEs, snapshot has %d", len(ComplianceMap), len(snapshot)) + } + for cwe, want := range snapshot { + if got := ComplianceMap[cwe]; !reflect.DeepEqual(got, want) { + t.Errorf("%s:\n got: %+v\nwant: %+v", cwe, got, want) + } + } + t.FailNow() + } +} + +// TestGetComplianceGapsFullGroundTruth compares the whole result against the +// output CPython produces for the same three dict findings. Captured with: +// +// PYTHONPATH=src ~/.agentfield/packages/sec-af/venv/bin/python -c " +// from sec_af.compliance.mapping import get_compliance_gaps +// print([(g.framework, g.control_id, g.max_severity, g.finding_count, g.cwe_ids) +// for g in get_compliance_gaps([ +// {'cwe_id':'CWE-89','severity':'high'}, +// {'cwe_id':'CWE-79','severity':'critical'}, +// {'cwe_id':'CWE-918','severity':'medium'}])])" +func TestGetComplianceGapsFullGroundTruth(t *testing.T) { + gaps := GetComplianceGaps([]map[string]any{ + {"cwe_id": "CWE-89", "severity": "high"}, + {"cwe_id": "CWE-79", "severity": "critical"}, + {"cwe_id": "CWE-918", "severity": "medium"}, + }) + + want := []schemas.ComplianceGap{ + {Framework: "HIPAA", ControlID: "\u00a7164.312(a)(1)", ControlName: "Access control", FindingCount: 1, MaxSeverity: "high", CweIDs: []string{"CWE-89"}}, + {Framework: "HIPAA", ControlID: "\u00a7164.312(c)(1)", ControlName: "Integrity", FindingCount: 1, MaxSeverity: "critical", CweIDs: []string{"CWE-79"}}, + {Framework: "HIPAA", ControlID: "\u00a7164.312(e)(1)", ControlName: "Transmission security", FindingCount: 1, MaxSeverity: "medium", CweIDs: []string{"CWE-918"}}, + {Framework: "ISO27001", ControlID: "A.8.20", ControlName: "Network security", FindingCount: 1, MaxSeverity: "medium", CweIDs: []string{"CWE-918"}}, + {Framework: "ISO27001", ControlID: "A.8.28", ControlName: "Secure coding", FindingCount: 2, MaxSeverity: "critical", CweIDs: []string{"CWE-79", "CWE-89"}}, + {Framework: "OWASP", ControlID: "A03:2021", ControlName: "Injection", FindingCount: 2, MaxSeverity: "critical", CweIDs: []string{"CWE-79", "CWE-89"}}, + {Framework: "OWASP", ControlID: "A10:2021", ControlName: "Server-Side Request Forgery", FindingCount: 1, MaxSeverity: "medium", CweIDs: []string{"CWE-918"}}, + {Framework: "PCI-DSS", ControlID: "Req 6", ControlName: "Develop and maintain secure systems and software", FindingCount: 1, MaxSeverity: "medium", CweIDs: []string{"CWE-918"}}, + {Framework: "PCI-DSS", ControlID: "Req 6.2.4", ControlName: "Custom software addresses common coding vulnerabilities", FindingCount: 2, MaxSeverity: "critical", CweIDs: []string{"CWE-79", "CWE-89"}}, + {Framework: "SOC2", ControlID: "CC6", ControlName: "Logical and physical access controls", FindingCount: 1, MaxSeverity: "high", CweIDs: []string{"CWE-89"}}, + {Framework: "SOC2", ControlID: "CC7", ControlName: "System operations", FindingCount: 2, MaxSeverity: "critical", CweIDs: []string{"CWE-79", "CWE-918"}}, + } + if !reflect.DeepEqual(gaps, want) { + if len(gaps) != len(want) { + t.Fatalf("got %d gaps, want %d", len(gaps), len(want)) + } + for i := range want { + if !reflect.DeepEqual(gaps[i], want[i]) { + t.Errorf("gap %d:\n got: %+v\nwant: %+v", i, gaps[i], want[i]) + } + } + t.FailNow() + } +} diff --git a/go/internal/compliance/table_gen.go b/go/internal/compliance/table_gen.go new file mode 100644 index 0000000..2b4c5b3 --- /dev/null +++ b/go/internal/compliance/table_gen.go @@ -0,0 +1,258 @@ +// Code generated by scripts/gen_compliance_table.py. DO NOT EDIT. + +package compliance + +import "github.com/Agent-Field/sec-af/go/internal/schemas" + +// ComplianceMap ports the module-level COMPLIANCE_MAP table of +// src/sec_af/compliance/mapping.py: the static CWE -> framework-control +// mapping every finding is scored against. +// +// Key order below is the Python dict's literal (insertion) order, and each +// value keeps the Python list order, because GetComplianceMappings returns the +// per-CWE list verbatim and callers compare it positionally. +// +// Regenerate with: +// +// PYTHONPATH=src ~/.agentfield/packages/sec-af/venv/bin/python \ +// go/scripts/gen_compliance_table.py +var ComplianceMap = map[string][]schemas.ComplianceMapping{ + "CWE-78": { + {Framework: "PCI-DSS", ControlID: "Req 6.2.4", ControlName: "Custom software addresses common coding vulnerabilities"}, + {Framework: "SOC2", ControlID: "CC7", ControlName: "System operations"}, + {Framework: "OWASP", ControlID: "A03:2021", ControlName: "Injection"}, + {Framework: "HIPAA", ControlID: "§164.312(c)(1)", ControlName: "Integrity"}, + {Framework: "ISO27001", ControlID: "A.8.28", ControlName: "Secure coding"}, + }, + "CWE-79": { + {Framework: "PCI-DSS", ControlID: "Req 6.2.4", ControlName: "Custom software addresses common coding vulnerabilities"}, + {Framework: "SOC2", ControlID: "CC7", ControlName: "System operations"}, + {Framework: "OWASP", ControlID: "A03:2021", ControlName: "Injection"}, + {Framework: "HIPAA", ControlID: "§164.312(c)(1)", ControlName: "Integrity"}, + {Framework: "ISO27001", ControlID: "A.8.28", ControlName: "Secure coding"}, + }, + "CWE-89": { + {Framework: "PCI-DSS", ControlID: "Req 6.2.4", ControlName: "Custom software addresses common coding vulnerabilities"}, + {Framework: "SOC2", ControlID: "CC6", ControlName: "Logical and physical access controls"}, + {Framework: "OWASP", ControlID: "A03:2021", ControlName: "Injection"}, + {Framework: "HIPAA", ControlID: "§164.312(a)(1)", ControlName: "Access control"}, + {Framework: "ISO27001", ControlID: "A.8.28", ControlName: "Secure coding"}, + }, + "CWE-90": { + {Framework: "PCI-DSS", ControlID: "Req 6.2.4", ControlName: "Custom software addresses common coding vulnerabilities"}, + {Framework: "SOC2", ControlID: "CC7", ControlName: "System operations"}, + {Framework: "OWASP", ControlID: "A03:2021", ControlName: "Injection"}, + {Framework: "HIPAA", ControlID: "§164.312(c)(1)", ControlName: "Integrity"}, + {Framework: "ISO27001", ControlID: "A.8.28", ControlName: "Secure coding"}, + }, + "CWE-91": { + {Framework: "PCI-DSS", ControlID: "Req 6.2.4", ControlName: "Custom software addresses common coding vulnerabilities"}, + {Framework: "SOC2", ControlID: "CC7", ControlName: "System operations"}, + {Framework: "OWASP", ControlID: "A03:2021", ControlName: "Injection"}, + {Framework: "HIPAA", ControlID: "§164.312(c)(1)", ControlName: "Integrity"}, + {Framework: "ISO27001", ControlID: "A.8.28", ControlName: "Secure coding"}, + }, + "CWE-94": { + {Framework: "PCI-DSS", ControlID: "Req 6.2.4", ControlName: "Custom software addresses common coding vulnerabilities"}, + {Framework: "SOC2", ControlID: "CC7", ControlName: "System operations"}, + {Framework: "OWASP", ControlID: "A03:2021", ControlName: "Injection"}, + {Framework: "HIPAA", ControlID: "§164.312(c)(1)", ControlName: "Integrity"}, + {Framework: "ISO27001", ControlID: "A.8.28", ControlName: "Secure coding"}, + }, + "CWE-917": { + {Framework: "PCI-DSS", ControlID: "Req 6.2.4", ControlName: "Custom software addresses common coding vulnerabilities"}, + {Framework: "SOC2", ControlID: "CC7", ControlName: "System operations"}, + {Framework: "OWASP", ControlID: "A03:2021", ControlName: "Injection"}, + {Framework: "HIPAA", ControlID: "§164.312(c)(1)", ControlName: "Integrity"}, + {Framework: "ISO27001", ControlID: "A.8.28", ControlName: "Secure coding"}, + }, + "CWE-287": { + {Framework: "PCI-DSS", ControlID: "Req 8", ControlName: "Identify users and authenticate access to system components"}, + {Framework: "SOC2", ControlID: "CC6", ControlName: "Logical and physical access controls"}, + {Framework: "OWASP", ControlID: "A07:2021", ControlName: "Identification and Authentication Failures"}, + {Framework: "HIPAA", ControlID: "§164.312(d)", ControlName: "Person or entity authentication"}, + {Framework: "ISO27001", ControlID: "A.5.17", ControlName: "Authentication information"}, + }, + "CWE-306": { + {Framework: "PCI-DSS", ControlID: "Req 8", ControlName: "Identify users and authenticate access to system components"}, + {Framework: "SOC2", ControlID: "CC6", ControlName: "Logical and physical access controls"}, + {Framework: "OWASP", ControlID: "A07:2021", ControlName: "Identification and Authentication Failures"}, + {Framework: "HIPAA", ControlID: "§164.312(a)(1)", ControlName: "Access control"}, + {Framework: "ISO27001", ControlID: "A.5.15", ControlName: "Access control"}, + }, + "CWE-352": { + {Framework: "PCI-DSS", ControlID: "Req 6.2.4", ControlName: "Custom software addresses common coding vulnerabilities"}, + {Framework: "SOC2", ControlID: "CC6", ControlName: "Logical and physical access controls"}, + {Framework: "OWASP", ControlID: "A01:2021", ControlName: "Broken Access Control"}, + {Framework: "HIPAA", ControlID: "§164.312(a)(1)", ControlName: "Access control"}, + {Framework: "ISO27001", ControlID: "A.8.5", ControlName: "Secure authentication"}, + }, + "CWE-862": { + {Framework: "PCI-DSS", ControlID: "Req 8", ControlName: "Identify users and authenticate access to system components"}, + {Framework: "SOC2", ControlID: "CC6", ControlName: "Logical and physical access controls"}, + {Framework: "OWASP", ControlID: "A01:2021", ControlName: "Broken Access Control"}, + {Framework: "HIPAA", ControlID: "§164.312(a)(1)", ControlName: "Access control"}, + {Framework: "ISO27001", ControlID: "A.5.15", ControlName: "Access control"}, + }, + "CWE-863": { + {Framework: "PCI-DSS", ControlID: "Req 8", ControlName: "Identify users and authenticate access to system components"}, + {Framework: "SOC2", ControlID: "CC6", ControlName: "Logical and physical access controls"}, + {Framework: "OWASP", ControlID: "A01:2021", ControlName: "Broken Access Control"}, + {Framework: "HIPAA", ControlID: "§164.312(a)(1)", ControlName: "Access control"}, + {Framework: "ISO27001", ControlID: "A.5.18", ControlName: "Access rights"}, + }, + "CWE-326": { + {Framework: "PCI-DSS", ControlID: "Req 3", ControlName: "Protect stored account data"}, + {Framework: "SOC2", ControlID: "CC6", ControlName: "Logical and physical access controls"}, + {Framework: "OWASP", ControlID: "A02:2021", ControlName: "Cryptographic Failures"}, + {Framework: "HIPAA", ControlID: "§164.312(a)(2)(iv)", ControlName: "Encryption and decryption"}, + {Framework: "ISO27001", ControlID: "A.8.24", ControlName: "Use of cryptography"}, + }, + "CWE-327": { + {Framework: "PCI-DSS", ControlID: "Req 3", ControlName: "Protect stored account data"}, + {Framework: "SOC2", ControlID: "CC6", ControlName: "Logical and physical access controls"}, + {Framework: "OWASP", ControlID: "A02:2021", ControlName: "Cryptographic Failures"}, + {Framework: "HIPAA", ControlID: "§164.312(a)(2)(iv)", ControlName: "Encryption and decryption"}, + {Framework: "ISO27001", ControlID: "A.8.24", ControlName: "Use of cryptography"}, + }, + "CWE-328": { + {Framework: "PCI-DSS", ControlID: "Req 3", ControlName: "Protect stored account data"}, + {Framework: "SOC2", ControlID: "CC6", ControlName: "Logical and physical access controls"}, + {Framework: "OWASP", ControlID: "A02:2021", ControlName: "Cryptographic Failures"}, + {Framework: "HIPAA", ControlID: "§164.312(a)(2)(iv)", ControlName: "Encryption and decryption"}, + {Framework: "ISO27001", ControlID: "A.8.24", ControlName: "Use of cryptography"}, + }, + "CWE-330": { + {Framework: "PCI-DSS", ControlID: "Req 3", ControlName: "Protect stored account data"}, + {Framework: "SOC2", ControlID: "CC6", ControlName: "Logical and physical access controls"}, + {Framework: "OWASP", ControlID: "A02:2021", ControlName: "Cryptographic Failures"}, + {Framework: "HIPAA", ControlID: "§164.312(c)(1)", ControlName: "Integrity"}, + {Framework: "ISO27001", ControlID: "A.8.24", ControlName: "Use of cryptography"}, + }, + "CWE-916": { + {Framework: "PCI-DSS", ControlID: "Req 8", ControlName: "Identify users and authenticate access to system components"}, + {Framework: "SOC2", ControlID: "CC6", ControlName: "Logical and physical access controls"}, + {Framework: "OWASP", ControlID: "A07:2021", ControlName: "Identification and Authentication Failures"}, + {Framework: "HIPAA", ControlID: "§164.312(d)", ControlName: "Person or entity authentication"}, + {Framework: "ISO27001", ControlID: "A.8.24", ControlName: "Use of cryptography"}, + }, + "CWE-840": { + {Framework: "PCI-DSS", ControlID: "Req 6", ControlName: "Develop and maintain secure systems and software"}, + {Framework: "SOC2", ControlID: "CC7", ControlName: "System operations"}, + {Framework: "OWASP", ControlID: "A04:2021", ControlName: "Insecure Design"}, + {Framework: "HIPAA", ControlID: "§164.308(a)(1)(ii)(A)", ControlName: "Risk analysis"}, + {Framework: "ISO27001", ControlID: "A.8.25", ControlName: "Secure development lifecycle"}, + }, + "CWE-841": { + {Framework: "PCI-DSS", ControlID: "Req 6", ControlName: "Develop and maintain secure systems and software"}, + {Framework: "SOC2", ControlID: "CC7", ControlName: "System operations"}, + {Framework: "OWASP", ControlID: "A04:2021", ControlName: "Insecure Design"}, + {Framework: "HIPAA", ControlID: "§164.308(a)(1)(ii)(A)", ControlName: "Risk analysis"}, + {Framework: "ISO27001", ControlID: "A.8.25", ControlName: "Secure development lifecycle"}, + }, + "CWE-200": { + {Framework: "PCI-DSS", ControlID: "Req 3", ControlName: "Protect stored account data"}, + {Framework: "SOC2", ControlID: "CC6", ControlName: "Logical and physical access controls"}, + {Framework: "OWASP", ControlID: "A01:2021", ControlName: "Broken Access Control"}, + {Framework: "HIPAA", ControlID: "§164.312(a)(1)", ControlName: "Access control"}, + {Framework: "ISO27001", ControlID: "A.8.12", ControlName: "Data leakage prevention"}, + }, + "CWE-209": { + {Framework: "PCI-DSS", ControlID: "Req 6", ControlName: "Develop and maintain secure systems and software"}, + {Framework: "SOC2", ControlID: "CC7", ControlName: "System operations"}, + {Framework: "OWASP", ControlID: "A09:2021", ControlName: "Security Logging and Monitoring Failures"}, + {Framework: "HIPAA", ControlID: "§164.312(b)", ControlName: "Audit controls"}, + {Framework: "ISO27001", ControlID: "A.8.15", ControlName: "Logging"}, + }, + "CWE-312": { + {Framework: "PCI-DSS", ControlID: "Req 3", ControlName: "Protect stored account data"}, + {Framework: "SOC2", ControlID: "CC6", ControlName: "Logical and physical access controls"}, + {Framework: "OWASP", ControlID: "A02:2021", ControlName: "Cryptographic Failures"}, + {Framework: "HIPAA", ControlID: "§164.312(a)(2)(iv)", ControlName: "Encryption and decryption"}, + {Framework: "ISO27001", ControlID: "A.8.24", ControlName: "Use of cryptography"}, + }, + "CWE-319": { + {Framework: "PCI-DSS", ControlID: "Req 4", ControlName: "Protect cardholder data with strong cryptography during transmission"}, + {Framework: "SOC2", ControlID: "CC6", ControlName: "Logical and physical access controls"}, + {Framework: "OWASP", ControlID: "A02:2021", ControlName: "Cryptographic Failures"}, + {Framework: "HIPAA", ControlID: "§164.312(e)(1)", ControlName: "Transmission security"}, + {Framework: "ISO27001", ControlID: "A.8.20", ControlName: "Network security"}, + }, + "CWE-532": { + {Framework: "PCI-DSS", ControlID: "Req 3", ControlName: "Protect stored account data"}, + {Framework: "SOC2", ControlID: "CC7", ControlName: "System operations"}, + {Framework: "OWASP", ControlID: "A09:2021", ControlName: "Security Logging and Monitoring Failures"}, + {Framework: "HIPAA", ControlID: "§164.312(b)", ControlName: "Audit controls"}, + {Framework: "ISO27001", ControlID: "A.8.15", ControlName: "Logging"}, + }, + "CWE-829": { + {Framework: "PCI-DSS", ControlID: "Req 6", ControlName: "Develop and maintain secure systems and software"}, + {Framework: "SOC2", ControlID: "CC8", ControlName: "Change management"}, + {Framework: "OWASP", ControlID: "A08:2021", ControlName: "Software and Data Integrity Failures"}, + {Framework: "HIPAA", ControlID: "§164.308(a)(1)(ii)(B)", ControlName: "Risk management"}, + {Framework: "ISO27001", ControlID: "A.8.25", ControlName: "Secure development lifecycle"}, + }, + "CWE-1104": { + {Framework: "PCI-DSS", ControlID: "Req 6", ControlName: "Develop and maintain secure systems and software"}, + {Framework: "SOC2", ControlID: "CC8", ControlName: "Change management"}, + {Framework: "OWASP", ControlID: "A06:2021", ControlName: "Vulnerable and Outdated Components"}, + {Framework: "HIPAA", ControlID: "§164.308(a)(1)(ii)(B)", ControlName: "Risk management"}, + {Framework: "ISO27001", ControlID: "A.8.8", ControlName: "Management of technical vulnerabilities"}, + }, + "CWE-16": { + {Framework: "PCI-DSS", ControlID: "Req 2", ControlName: "Apply secure configurations to all system components"}, + {Framework: "SOC2", ControlID: "CC7", ControlName: "System operations"}, + {Framework: "OWASP", ControlID: "A05:2021", ControlName: "Security Misconfiguration"}, + {Framework: "HIPAA", ControlID: "§164.308(a)(1)(ii)(B)", ControlName: "Risk management"}, + {Framework: "ISO27001", ControlID: "A.8.9", ControlName: "Configuration management"}, + }, + "CWE-259": { + {Framework: "PCI-DSS", ControlID: "Req 8", ControlName: "Identify users and authenticate access to system components"}, + {Framework: "SOC2", ControlID: "CC6", ControlName: "Logical and physical access controls"}, + {Framework: "OWASP", ControlID: "A07:2021", ControlName: "Identification and Authentication Failures"}, + {Framework: "HIPAA", ControlID: "§164.312(d)", ControlName: "Person or entity authentication"}, + {Framework: "ISO27001", ControlID: "A.5.17", ControlName: "Authentication information"}, + }, + "CWE-321": { + {Framework: "PCI-DSS", ControlID: "Req 3", ControlName: "Protect stored account data"}, + {Framework: "SOC2", ControlID: "CC6", ControlName: "Logical and physical access controls"}, + {Framework: "OWASP", ControlID: "A02:2021", ControlName: "Cryptographic Failures"}, + {Framework: "HIPAA", ControlID: "§164.312(a)(2)(iv)", ControlName: "Encryption and decryption"}, + {Framework: "ISO27001", ControlID: "A.8.24", ControlName: "Use of cryptography"}, + }, + "CWE-798": { + {Framework: "PCI-DSS", ControlID: "Req 8", ControlName: "Identify users and authenticate access to system components"}, + {Framework: "SOC2", ControlID: "CC6", ControlName: "Logical and physical access controls"}, + {Framework: "OWASP", ControlID: "A07:2021", ControlName: "Identification and Authentication Failures"}, + {Framework: "HIPAA", ControlID: "§164.312(d)", ControlName: "Person or entity authentication"}, + {Framework: "ISO27001", ControlID: "A.5.17", ControlName: "Authentication information"}, + }, + "CWE-285": { + {Framework: "PCI-DSS", ControlID: "Req 8", ControlName: "Identify users and authenticate access to system components"}, + {Framework: "SOC2", ControlID: "CC6", ControlName: "Logical and physical access controls"}, + {Framework: "OWASP", ControlID: "A01:2021", ControlName: "Broken Access Control"}, + {Framework: "HIPAA", ControlID: "§164.312(a)(1)", ControlName: "Access control"}, + {Framework: "ISO27001", ControlID: "A.5.15", ControlName: "Access control"}, + }, + "CWE-346": { + {Framework: "PCI-DSS", ControlID: "Req 8", ControlName: "Identify users and authenticate access to system components"}, + {Framework: "SOC2", ControlID: "CC6", ControlName: "Logical and physical access controls"}, + {Framework: "OWASP", ControlID: "A01:2021", ControlName: "Broken Access Control"}, + {Framework: "HIPAA", ControlID: "§164.312(a)(1)", ControlName: "Access control"}, + {Framework: "ISO27001", ControlID: "A.5.16", ControlName: "Identity management"}, + }, + "CWE-601": { + {Framework: "PCI-DSS", ControlID: "Req 6", ControlName: "Develop and maintain secure systems and software"}, + {Framework: "SOC2", ControlID: "CC7", ControlName: "System operations"}, + {Framework: "OWASP", ControlID: "A01:2021", ControlName: "Broken Access Control"}, + {Framework: "HIPAA", ControlID: "§164.312(c)(1)", ControlName: "Integrity"}, + {Framework: "ISO27001", ControlID: "A.8.28", ControlName: "Secure coding"}, + }, + "CWE-918": { + {Framework: "PCI-DSS", ControlID: "Req 6", ControlName: "Develop and maintain secure systems and software"}, + {Framework: "SOC2", ControlID: "CC7", ControlName: "System operations"}, + {Framework: "OWASP", ControlID: "A10:2021", ControlName: "Server-Side Request Forgery"}, + {Framework: "HIPAA", ControlID: "§164.312(e)(1)", ControlName: "Transmission security"}, + {Framework: "ISO27001", ControlID: "A.8.20", ControlName: "Network security"}, + }, +} diff --git a/go/internal/compliance/testdata/compliance_map.json b/go/internal/compliance/testdata/compliance_map.json new file mode 100644 index 0000000..aa2345f --- /dev/null +++ b/go/internal/compliance/testdata/compliance_map.json @@ -0,0 +1,920 @@ +{ + "CWE-78": [ + { + "framework": "PCI-DSS", + "control_id": "Req 6.2.4", + "control_name": "Custom software addresses common coding vulnerabilities" + }, + { + "framework": "SOC2", + "control_id": "CC7", + "control_name": "System operations" + }, + { + "framework": "OWASP", + "control_id": "A03:2021", + "control_name": "Injection" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(c)(1)", + "control_name": "Integrity" + }, + { + "framework": "ISO27001", + "control_id": "A.8.28", + "control_name": "Secure coding" + } + ], + "CWE-79": [ + { + "framework": "PCI-DSS", + "control_id": "Req 6.2.4", + "control_name": "Custom software addresses common coding vulnerabilities" + }, + { + "framework": "SOC2", + "control_id": "CC7", + "control_name": "System operations" + }, + { + "framework": "OWASP", + "control_id": "A03:2021", + "control_name": "Injection" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(c)(1)", + "control_name": "Integrity" + }, + { + "framework": "ISO27001", + "control_id": "A.8.28", + "control_name": "Secure coding" + } + ], + "CWE-89": [ + { + "framework": "PCI-DSS", + "control_id": "Req 6.2.4", + "control_name": "Custom software addresses common coding vulnerabilities" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A03:2021", + "control_name": "Injection" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(a)(1)", + "control_name": "Access control" + }, + { + "framework": "ISO27001", + "control_id": "A.8.28", + "control_name": "Secure coding" + } + ], + "CWE-90": [ + { + "framework": "PCI-DSS", + "control_id": "Req 6.2.4", + "control_name": "Custom software addresses common coding vulnerabilities" + }, + { + "framework": "SOC2", + "control_id": "CC7", + "control_name": "System operations" + }, + { + "framework": "OWASP", + "control_id": "A03:2021", + "control_name": "Injection" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(c)(1)", + "control_name": "Integrity" + }, + { + "framework": "ISO27001", + "control_id": "A.8.28", + "control_name": "Secure coding" + } + ], + "CWE-91": [ + { + "framework": "PCI-DSS", + "control_id": "Req 6.2.4", + "control_name": "Custom software addresses common coding vulnerabilities" + }, + { + "framework": "SOC2", + "control_id": "CC7", + "control_name": "System operations" + }, + { + "framework": "OWASP", + "control_id": "A03:2021", + "control_name": "Injection" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(c)(1)", + "control_name": "Integrity" + }, + { + "framework": "ISO27001", + "control_id": "A.8.28", + "control_name": "Secure coding" + } + ], + "CWE-94": [ + { + "framework": "PCI-DSS", + "control_id": "Req 6.2.4", + "control_name": "Custom software addresses common coding vulnerabilities" + }, + { + "framework": "SOC2", + "control_id": "CC7", + "control_name": "System operations" + }, + { + "framework": "OWASP", + "control_id": "A03:2021", + "control_name": "Injection" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(c)(1)", + "control_name": "Integrity" + }, + { + "framework": "ISO27001", + "control_id": "A.8.28", + "control_name": "Secure coding" + } + ], + "CWE-917": [ + { + "framework": "PCI-DSS", + "control_id": "Req 6.2.4", + "control_name": "Custom software addresses common coding vulnerabilities" + }, + { + "framework": "SOC2", + "control_id": "CC7", + "control_name": "System operations" + }, + { + "framework": "OWASP", + "control_id": "A03:2021", + "control_name": "Injection" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(c)(1)", + "control_name": "Integrity" + }, + { + "framework": "ISO27001", + "control_id": "A.8.28", + "control_name": "Secure coding" + } + ], + "CWE-287": [ + { + "framework": "PCI-DSS", + "control_id": "Req 8", + "control_name": "Identify users and authenticate access to system components" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A07:2021", + "control_name": "Identification and Authentication Failures" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(d)", + "control_name": "Person or entity authentication" + }, + { + "framework": "ISO27001", + "control_id": "A.5.17", + "control_name": "Authentication information" + } + ], + "CWE-306": [ + { + "framework": "PCI-DSS", + "control_id": "Req 8", + "control_name": "Identify users and authenticate access to system components" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A07:2021", + "control_name": "Identification and Authentication Failures" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(a)(1)", + "control_name": "Access control" + }, + { + "framework": "ISO27001", + "control_id": "A.5.15", + "control_name": "Access control" + } + ], + "CWE-352": [ + { + "framework": "PCI-DSS", + "control_id": "Req 6.2.4", + "control_name": "Custom software addresses common coding vulnerabilities" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A01:2021", + "control_name": "Broken Access Control" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(a)(1)", + "control_name": "Access control" + }, + { + "framework": "ISO27001", + "control_id": "A.8.5", + "control_name": "Secure authentication" + } + ], + "CWE-862": [ + { + "framework": "PCI-DSS", + "control_id": "Req 8", + "control_name": "Identify users and authenticate access to system components" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A01:2021", + "control_name": "Broken Access Control" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(a)(1)", + "control_name": "Access control" + }, + { + "framework": "ISO27001", + "control_id": "A.5.15", + "control_name": "Access control" + } + ], + "CWE-863": [ + { + "framework": "PCI-DSS", + "control_id": "Req 8", + "control_name": "Identify users and authenticate access to system components" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A01:2021", + "control_name": "Broken Access Control" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(a)(1)", + "control_name": "Access control" + }, + { + "framework": "ISO27001", + "control_id": "A.5.18", + "control_name": "Access rights" + } + ], + "CWE-326": [ + { + "framework": "PCI-DSS", + "control_id": "Req 3", + "control_name": "Protect stored account data" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A02:2021", + "control_name": "Cryptographic Failures" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(a)(2)(iv)", + "control_name": "Encryption and decryption" + }, + { + "framework": "ISO27001", + "control_id": "A.8.24", + "control_name": "Use of cryptography" + } + ], + "CWE-327": [ + { + "framework": "PCI-DSS", + "control_id": "Req 3", + "control_name": "Protect stored account data" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A02:2021", + "control_name": "Cryptographic Failures" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(a)(2)(iv)", + "control_name": "Encryption and decryption" + }, + { + "framework": "ISO27001", + "control_id": "A.8.24", + "control_name": "Use of cryptography" + } + ], + "CWE-328": [ + { + "framework": "PCI-DSS", + "control_id": "Req 3", + "control_name": "Protect stored account data" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A02:2021", + "control_name": "Cryptographic Failures" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(a)(2)(iv)", + "control_name": "Encryption and decryption" + }, + { + "framework": "ISO27001", + "control_id": "A.8.24", + "control_name": "Use of cryptography" + } + ], + "CWE-330": [ + { + "framework": "PCI-DSS", + "control_id": "Req 3", + "control_name": "Protect stored account data" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A02:2021", + "control_name": "Cryptographic Failures" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(c)(1)", + "control_name": "Integrity" + }, + { + "framework": "ISO27001", + "control_id": "A.8.24", + "control_name": "Use of cryptography" + } + ], + "CWE-916": [ + { + "framework": "PCI-DSS", + "control_id": "Req 8", + "control_name": "Identify users and authenticate access to system components" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A07:2021", + "control_name": "Identification and Authentication Failures" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(d)", + "control_name": "Person or entity authentication" + }, + { + "framework": "ISO27001", + "control_id": "A.8.24", + "control_name": "Use of cryptography" + } + ], + "CWE-840": [ + { + "framework": "PCI-DSS", + "control_id": "Req 6", + "control_name": "Develop and maintain secure systems and software" + }, + { + "framework": "SOC2", + "control_id": "CC7", + "control_name": "System operations" + }, + { + "framework": "OWASP", + "control_id": "A04:2021", + "control_name": "Insecure Design" + }, + { + "framework": "HIPAA", + "control_id": "§164.308(a)(1)(ii)(A)", + "control_name": "Risk analysis" + }, + { + "framework": "ISO27001", + "control_id": "A.8.25", + "control_name": "Secure development lifecycle" + } + ], + "CWE-841": [ + { + "framework": "PCI-DSS", + "control_id": "Req 6", + "control_name": "Develop and maintain secure systems and software" + }, + { + "framework": "SOC2", + "control_id": "CC7", + "control_name": "System operations" + }, + { + "framework": "OWASP", + "control_id": "A04:2021", + "control_name": "Insecure Design" + }, + { + "framework": "HIPAA", + "control_id": "§164.308(a)(1)(ii)(A)", + "control_name": "Risk analysis" + }, + { + "framework": "ISO27001", + "control_id": "A.8.25", + "control_name": "Secure development lifecycle" + } + ], + "CWE-200": [ + { + "framework": "PCI-DSS", + "control_id": "Req 3", + "control_name": "Protect stored account data" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A01:2021", + "control_name": "Broken Access Control" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(a)(1)", + "control_name": "Access control" + }, + { + "framework": "ISO27001", + "control_id": "A.8.12", + "control_name": "Data leakage prevention" + } + ], + "CWE-209": [ + { + "framework": "PCI-DSS", + "control_id": "Req 6", + "control_name": "Develop and maintain secure systems and software" + }, + { + "framework": "SOC2", + "control_id": "CC7", + "control_name": "System operations" + }, + { + "framework": "OWASP", + "control_id": "A09:2021", + "control_name": "Security Logging and Monitoring Failures" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(b)", + "control_name": "Audit controls" + }, + { + "framework": "ISO27001", + "control_id": "A.8.15", + "control_name": "Logging" + } + ], + "CWE-312": [ + { + "framework": "PCI-DSS", + "control_id": "Req 3", + "control_name": "Protect stored account data" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A02:2021", + "control_name": "Cryptographic Failures" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(a)(2)(iv)", + "control_name": "Encryption and decryption" + }, + { + "framework": "ISO27001", + "control_id": "A.8.24", + "control_name": "Use of cryptography" + } + ], + "CWE-319": [ + { + "framework": "PCI-DSS", + "control_id": "Req 4", + "control_name": "Protect cardholder data with strong cryptography during transmission" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A02:2021", + "control_name": "Cryptographic Failures" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(e)(1)", + "control_name": "Transmission security" + }, + { + "framework": "ISO27001", + "control_id": "A.8.20", + "control_name": "Network security" + } + ], + "CWE-532": [ + { + "framework": "PCI-DSS", + "control_id": "Req 3", + "control_name": "Protect stored account data" + }, + { + "framework": "SOC2", + "control_id": "CC7", + "control_name": "System operations" + }, + { + "framework": "OWASP", + "control_id": "A09:2021", + "control_name": "Security Logging and Monitoring Failures" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(b)", + "control_name": "Audit controls" + }, + { + "framework": "ISO27001", + "control_id": "A.8.15", + "control_name": "Logging" + } + ], + "CWE-829": [ + { + "framework": "PCI-DSS", + "control_id": "Req 6", + "control_name": "Develop and maintain secure systems and software" + }, + { + "framework": "SOC2", + "control_id": "CC8", + "control_name": "Change management" + }, + { + "framework": "OWASP", + "control_id": "A08:2021", + "control_name": "Software and Data Integrity Failures" + }, + { + "framework": "HIPAA", + "control_id": "§164.308(a)(1)(ii)(B)", + "control_name": "Risk management" + }, + { + "framework": "ISO27001", + "control_id": "A.8.25", + "control_name": "Secure development lifecycle" + } + ], + "CWE-1104": [ + { + "framework": "PCI-DSS", + "control_id": "Req 6", + "control_name": "Develop and maintain secure systems and software" + }, + { + "framework": "SOC2", + "control_id": "CC8", + "control_name": "Change management" + }, + { + "framework": "OWASP", + "control_id": "A06:2021", + "control_name": "Vulnerable and Outdated Components" + }, + { + "framework": "HIPAA", + "control_id": "§164.308(a)(1)(ii)(B)", + "control_name": "Risk management" + }, + { + "framework": "ISO27001", + "control_id": "A.8.8", + "control_name": "Management of technical vulnerabilities" + } + ], + "CWE-16": [ + { + "framework": "PCI-DSS", + "control_id": "Req 2", + "control_name": "Apply secure configurations to all system components" + }, + { + "framework": "SOC2", + "control_id": "CC7", + "control_name": "System operations" + }, + { + "framework": "OWASP", + "control_id": "A05:2021", + "control_name": "Security Misconfiguration" + }, + { + "framework": "HIPAA", + "control_id": "§164.308(a)(1)(ii)(B)", + "control_name": "Risk management" + }, + { + "framework": "ISO27001", + "control_id": "A.8.9", + "control_name": "Configuration management" + } + ], + "CWE-259": [ + { + "framework": "PCI-DSS", + "control_id": "Req 8", + "control_name": "Identify users and authenticate access to system components" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A07:2021", + "control_name": "Identification and Authentication Failures" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(d)", + "control_name": "Person or entity authentication" + }, + { + "framework": "ISO27001", + "control_id": "A.5.17", + "control_name": "Authentication information" + } + ], + "CWE-321": [ + { + "framework": "PCI-DSS", + "control_id": "Req 3", + "control_name": "Protect stored account data" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A02:2021", + "control_name": "Cryptographic Failures" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(a)(2)(iv)", + "control_name": "Encryption and decryption" + }, + { + "framework": "ISO27001", + "control_id": "A.8.24", + "control_name": "Use of cryptography" + } + ], + "CWE-798": [ + { + "framework": "PCI-DSS", + "control_id": "Req 8", + "control_name": "Identify users and authenticate access to system components" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A07:2021", + "control_name": "Identification and Authentication Failures" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(d)", + "control_name": "Person or entity authentication" + }, + { + "framework": "ISO27001", + "control_id": "A.5.17", + "control_name": "Authentication information" + } + ], + "CWE-285": [ + { + "framework": "PCI-DSS", + "control_id": "Req 8", + "control_name": "Identify users and authenticate access to system components" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A01:2021", + "control_name": "Broken Access Control" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(a)(1)", + "control_name": "Access control" + }, + { + "framework": "ISO27001", + "control_id": "A.5.15", + "control_name": "Access control" + } + ], + "CWE-346": [ + { + "framework": "PCI-DSS", + "control_id": "Req 8", + "control_name": "Identify users and authenticate access to system components" + }, + { + "framework": "SOC2", + "control_id": "CC6", + "control_name": "Logical and physical access controls" + }, + { + "framework": "OWASP", + "control_id": "A01:2021", + "control_name": "Broken Access Control" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(a)(1)", + "control_name": "Access control" + }, + { + "framework": "ISO27001", + "control_id": "A.5.16", + "control_name": "Identity management" + } + ], + "CWE-601": [ + { + "framework": "PCI-DSS", + "control_id": "Req 6", + "control_name": "Develop and maintain secure systems and software" + }, + { + "framework": "SOC2", + "control_id": "CC7", + "control_name": "System operations" + }, + { + "framework": "OWASP", + "control_id": "A01:2021", + "control_name": "Broken Access Control" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(c)(1)", + "control_name": "Integrity" + }, + { + "framework": "ISO27001", + "control_id": "A.8.28", + "control_name": "Secure coding" + } + ], + "CWE-918": [ + { + "framework": "PCI-DSS", + "control_id": "Req 6", + "control_name": "Develop and maintain secure systems and software" + }, + { + "framework": "SOC2", + "control_id": "CC7", + "control_name": "System operations" + }, + { + "framework": "OWASP", + "control_id": "A10:2021", + "control_name": "Server-Side Request Forgery" + }, + { + "framework": "HIPAA", + "control_id": "§164.312(e)(1)", + "control_name": "Transmission security" + }, + { + "framework": "ISO27001", + "control_id": "A.8.20", + "control_name": "Network security" + } + ] +} diff --git a/go/internal/config/ai.go b/go/internal/config/ai.go new file mode 100644 index 0000000..01eea28 --- /dev/null +++ b/go/internal/config/ai.go @@ -0,0 +1,189 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +// AIIntegrationConfig ports config.py AIIntegrationConfig — every field is a +// `Field(default_factory=lambda: os.getenv(...))`, so the values come from the +// environment at construction time. +// +// OpencodeServer is `str | None` in Python and a pointer here: nil means the +// variable was absent entirely, which is distinguishable from a variable set to +// the empty string (see AIConfigFromEnv). +type AIIntegrationConfig struct { + Provider string `json:"provider"` + HarnessModel string `json:"harness_model"` + AIModel string `json:"ai_model"` + MaxTurns int `json:"max_turns"` + MaxRetries int `json:"max_retries"` + InitialBackoffSeconds float64 `json:"initial_backoff_seconds"` + MaxBackoffSeconds float64 `json:"max_backoff_seconds"` + OpencodeBin string `json:"opencode_bin"` + AforgeBin string `json:"aforge_bin"` + OpencodeServer *string `json:"opencode_server"` +} + +// AIConfigFromEnv ports AIIntegrationConfig.from_env() — which is just `cls()`, +// i.e. run every default_factory. +// +// Env precedence, verbatim from the lambdas: +// +// provider SEC_AF_PROVIDER > HARNESS_PROVIDER > "aforge" +// harness_model SEC_AF_MODEL > HARNESS_MODEL > "minimax/minimax-m2.5" +// ai_model SEC_AF_AI_MODEL > AI_MODEL > SEC_AF_MODEL > "minimax/minimax-m2.5" +// max_turns int(SEC_AF_MAX_TURNS or "50") +// max_retries int(SEC_AF_AI_MAX_RETRIES or "3") +// initial_backoff_seconds float(SEC_AF_AI_INITIAL_BACKOFF_SECONDS or "2.0") +// max_backoff_seconds float(SEC_AF_AI_MAX_BACKOFF_SECONDS or "8.0") +// opencode_bin SEC_AF_OPENCODE_BIN > "opencode" +// aforge_bin SEC_AF_AFORGE_BIN > AFORGE_BIN > "aforge" +// opencode_server SEC_AF_OPENCODE_SERVER > OPENCODE_SERVER > None +// +// Python parity: `os.getenv(key, fallback)` returns the variable's value +// whenever the KEY EXISTS, including when it is set to the empty string — the +// fallback only applies to an absent key. So this uses os.LookupEnv, not +// os.Getenv (which cannot tell "" from unset). +// +// A malformed numeric value is an ERROR, because Python's int()/float() raises +// inside the default_factory, which runs while app.py is being imported — the +// node fails to boot. Callers must propagate rather than substitute a default. +func AIConfigFromEnv() (AIIntegrationConfig, error) { + c := AIIntegrationConfig{ + Provider: envChain("aforge", "SEC_AF_PROVIDER", "HARNESS_PROVIDER"), + HarnessModel: envChain("minimax/minimax-m2.5", "SEC_AF_MODEL", "HARNESS_MODEL"), + AIModel: envChain("minimax/minimax-m2.5", "SEC_AF_AI_MODEL", "AI_MODEL", "SEC_AF_MODEL"), + OpencodeBin: envChain("opencode", "SEC_AF_OPENCODE_BIN"), + AforgeBin: envChain("aforge", "SEC_AF_AFORGE_BIN", "AFORGE_BIN"), + } + + var err error + if c.MaxTurns, err = envInt("SEC_AF_MAX_TURNS", 50); err != nil { + return AIIntegrationConfig{}, err + } + if c.MaxRetries, err = envInt("SEC_AF_AI_MAX_RETRIES", 3); err != nil { + return AIIntegrationConfig{}, err + } + if c.InitialBackoffSeconds, err = envFloat("SEC_AF_AI_INITIAL_BACKOFF_SECONDS", 2.0); err != nil { + return AIIntegrationConfig{}, err + } + if c.MaxBackoffSeconds, err = envFloat("SEC_AF_AI_MAX_BACKOFF_SECONDS", 8.0); err != nil { + return AIIntegrationConfig{}, err + } + + // `os.getenv("SEC_AF_OPENCODE_SERVER", os.getenv("OPENCODE_SERVER"))`: the + // inner getenv has NO default, so it yields None for an absent key — which + // then becomes the outer default. Both absent => None. + if v, ok := os.LookupEnv("SEC_AF_OPENCODE_SERVER"); ok { + c.OpencodeServer = &v + } else if v, ok := os.LookupEnv("OPENCODE_SERVER"); ok { + c.OpencodeServer = &v + } + + return c, nil +} + +// providerEnvKeys is the exact tuple config.py's provider_env() scans, in order. +var providerEnvKeys = [...]string{ + "OPENROUTER_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GOOGLE_API_KEY", + "GITHUB_TOKEN", + "GH_TOKEN", +} + +// ProviderEnv ports AIIntegrationConfig.provider_env(): +// +// env = {key: value for key in env_keys if (value := os.getenv(key))} +// env["AGENTFIELD_AFORGE_COMMAND"] = os.getenv("AGENTFIELD_AFORGE_COMMAND", "exec") +// xdg = os.getenv("XDG_DATA_HOME") or os.path.join(tempfile.gettempdir(), "opencode-shared-data") +// os.makedirs(xdg, exist_ok=True) +// env["XDG_DATA_HOME"] = xdg +// return env +// +// Parity details: +// +// - The six credential keys use TRUTHINESS (`if (value := os.getenv(key))`), +// so a key set to the empty string is omitted, not forwarded as "". +// - AGENTFIELD_AFORGE_COMMAND uses getenv-with-default, so an explicitly +// empty value IS forwarded as "" and only an absent key becomes "exec". +// - XDG_DATA_HOME uses `or`, i.e. truthiness again: an empty value falls back +// to the temp-dir path. +// - The directory is created eagerly. Python raises on failure inside the +// Agent constructor, so this returns the error instead of continuing with a +// directory the harness subprocess cannot use. +// +// Go's os.TempDir() resolves $TMPDIR (else /tmp) on unix, where Python's +// tempfile.gettempdir() also consults TEMP and TMP. On Linux and in the +// container both yield the same path. +func (c AIIntegrationConfig) ProviderEnv() (map[string]string, error) { + env := make(map[string]string, len(providerEnvKeys)+2) + for _, key := range providerEnvKeys { + if v := os.Getenv(key); v != "" { + env[key] = v + } + } + + aforgeCommand := "exec" + if v, ok := os.LookupEnv("AGENTFIELD_AFORGE_COMMAND"); ok { + aforgeCommand = v + } + env["AGENTFIELD_AFORGE_COMMAND"] = aforgeCommand + + xdg := os.Getenv("XDG_DATA_HOME") + if xdg == "" { + xdg = filepath.Join(os.TempDir(), "opencode-shared-data") + } + if err := os.MkdirAll(xdg, 0o755); err != nil { + return nil, fmt.Errorf("config.ProviderEnv: create XDG_DATA_HOME %q: %w", xdg, err) + } + env["XDG_DATA_HOME"] = xdg + + return env, nil +} + +// envChain reproduces a nested `os.getenv(a, os.getenv(b, ... default))`: the +// FIRST key that EXISTS wins, even when its value is empty. +func envChain(def string, keys ...string) string { + for _, k := range keys { + if v, ok := os.LookupEnv(k); ok { + return v + } + } + return def +} + +// envInt reproduces `int(os.getenv(key, str(def)))`. +// +// Python's int() tolerates surrounding whitespace, so TrimSpace runs first; it +// also accepts underscore digit separators ("5_0"), which strconv does not — +// deliberately not reproduced, as no deployment spells a turn count that way. +func envInt(key string, def int) (int, error) { + raw, ok := os.LookupEnv(key) + if !ok { + return def, nil + } + n, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil { + return 0, fmt.Errorf("config: %s=%q is not an integer: %w", key, raw, err) + } + return n, nil +} + +// envFloat reproduces `float(os.getenv(key, str(def)))`. +func envFloat(key string, def float64) (float64, error) { + raw, ok := os.LookupEnv(key) + if !ok { + return def, nil + } + f, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) + if err != nil { + return 0, fmt.Errorf("config: %s=%q is not a float: %w", key, raw, err) + } + return f, nil +} diff --git a/go/internal/config/audit.go b/go/internal/config/audit.go new file mode 100644 index 0000000..617e9b6 --- /dev/null +++ b/go/internal/config/audit.go @@ -0,0 +1,148 @@ +package config + +import ( + "encoding/json" + "fmt" +) + +// AuditConfig ports config.py AuditConfig — the runtime config the orchestrator +// phases consume (DESIGN.md §3 and §9). +// +// IncludePaths is `list[str] | None` in Python and a nil-able slice here: nil +// means "no include filter" and is distinct from an empty list. +type AuditConfig struct { + RepoPath string `json:"repo_path"` + Depth DepthProfile `json:"depth"` + SeverityThreshold string `json:"severity_threshold"` + ScanTypes []string `json:"scan_types"` + OutputFormats []string `json:"output_formats"` + ComplianceFrameworks []string `json:"compliance_frameworks"` + IncludePaths []string `json:"include_paths"` + ExcludePaths []string `json:"exclude_paths"` + Provider string `json:"provider"` + Budget BudgetConfig `json:"budget"` +} + +// DefaultAuditConfig builds the pydantic field defaults. repo_path is +// `Field(...)` (required) in Python and has no default; callers supply it. +// +// Fresh slices are returned per call, mirroring `default_factory` — a shared +// package-level slice would let one audit's mutation leak into the next. +func DefaultAuditConfig() AuditConfig { + return AuditConfig{ + Depth: DepthStandard, + SeverityThreshold: "low", + ScanTypes: []string{"sast", "sca", "secrets", "config"}, + OutputFormats: []string{"json"}, + ComplianceFrameworks: []string{}, + IncludePaths: nil, + ExcludePaths: []string{"tests/", "vendor/", "node_modules/", ".git/"}, + Provider: "aforge", + Budget: DefaultBudgetConfig(), + } +} + +// UnmarshalJSON seeds the pydantic defaults before decoding. +func (c *AuditConfig) UnmarshalJSON(data []byte) error { + type alias AuditConfig + v := alias(DefaultAuditConfig()) + if err := json.Unmarshal(data, &v); err != nil { + return err + } + *c = AuditConfig(v) + return nil +} + +// AuditInputFields is the projection of schemas.AuditInput that +// AuditConfig.from_input reads. It exists so this package does not have to +// import internal/schemas (which owns the full AuditInput, including the ~12 +// fields from_input ignores): the json tags are the pydantic field names, so +// FromInput can project any AuditInput-shaped value onto it through JSON. +type AuditInputFields struct { + Depth string `json:"depth"` + SeverityThreshold string `json:"severity_threshold"` + ScanTypes []string `json:"scan_types"` + OutputFormats []string `json:"output_formats"` + ComplianceFrameworks []string `json:"compliance_frameworks"` + IncludePaths []string `json:"include_paths"` + ExcludePaths []string `json:"exclude_paths"` + MaxCostUSD *float64 `json:"max_cost_usd"` + MaxProvers *int `json:"max_provers"` + MaxDurationSeconds *int `json:"max_duration_seconds"` +} + +// FromInput ports AuditConfig.from_input (config.py:53, DESIGN.md §8.2): +// +// @classmethod +// def from_input(cls, audit_input: AuditInput, repo_path: str) -> "AuditConfig": +// depth = DepthProfile(audit_input.depth) +// return cls( +// repo_path=repo_path, +// depth=depth, +// severity_threshold=audit_input.severity_threshold, +// scan_types=audit_input.scan_types, +// output_formats=audit_input.output_formats, +// compliance_frameworks=audit_input.compliance_frameworks, +// include_paths=audit_input.include_paths, +// exclude_paths=audit_input.exclude_paths, +// budget=BudgetConfig( +// max_cost_usd=audit_input.max_cost_usd, +// max_provers=audit_input.max_provers, +// max_duration_seconds=audit_input.max_duration_seconds, +// ), +// ) +// +// Two parity points that are easy to get wrong: +// +// - `DepthProfile(audit_input.depth)` is the STRICT enum constructor, not +// _normalize_depth. An unknown depth raises ValueError and the audit fails; +// it does NOT silently become "standard". FromInput returns an error with +// Python's ValueError text. +// - `provider` is not passed, so it keeps its "aforge" field default; and the +// BudgetConfig is constructed with ONLY the three caps, so the percentages +// and concurrency limits keep their own defaults. +// +// in may be any value that JSON-marshals to the AuditInput shape — in practice +// schemas.AuditInput once that package exists, or AuditInputFields directly. +// Pass the typed struct through FromInputFields when you already have one. +func (AuditConfig) FromInput(in any, repoPath string) (AuditConfig, error) { + fields, ok := in.(AuditInputFields) + if !ok { + b, err := json.Marshal(in) + if err != nil { + return AuditConfig{}, fmt.Errorf("config.FromInput: marshal %T: %w", in, err) + } + if err := json.Unmarshal(b, &fields); err != nil { + return AuditConfig{}, fmt.Errorf("config.FromInput: project %T onto AuditInputFields: %w", in, err) + } + } + return AuditConfig{}.FromInputFields(fields, repoPath) +} + +// FromInputFields is FromInput over the already-projected fields. +func (AuditConfig) FromInputFields(in AuditInputFields, repoPath string) (AuditConfig, error) { + depth := DepthProfile(in.Depth) + if !depth.IsValid() { + // Python: ValueError("'invalid' is not a valid DepthProfile") + return AuditConfig{}, fmt.Errorf("'%s' is not a valid DepthProfile", in.Depth) + } + + c := DefaultAuditConfig() + c.RepoPath = repoPath + c.Depth = depth + c.SeverityThreshold = in.SeverityThreshold + c.ScanTypes = in.ScanTypes + c.OutputFormats = in.OutputFormats + c.ComplianceFrameworks = in.ComplianceFrameworks + c.IncludePaths = in.IncludePaths + c.ExcludePaths = in.ExcludePaths + + // Only the three caps are passed to BudgetConfig(...); everything else keeps + // its pydantic default. + c.Budget = DefaultBudgetConfig() + c.Budget.MaxCostUSD = in.MaxCostUSD + c.Budget.MaxProvers = in.MaxProvers + c.Budget.MaxDurationSeconds = in.MaxDurationSeconds + + return c, nil +} diff --git a/go/internal/config/budget.go b/go/internal/config/budget.go new file mode 100644 index 0000000..3a7ea90 --- /dev/null +++ b/go/internal/config/budget.go @@ -0,0 +1,55 @@ +package config + +import "encoding/json" + +// BudgetConfig ports config.py BudgetConfig — the budget-enforcement thresholds +// from DESIGN.md §9.1. +// +// The three caps are `X | None` in Python and pointers here: nil means "no cap", +// which is NOT the same as a zero cap. The percentages and concurrency limits +// have non-zero pydantic defaults, so UnmarshalJSON seeds them (a BudgetConfig +// can arrive over the wire inside a checkpoint or a phase payload, where a Go +// zero value would silently mean "no hunters, no budget"). +type BudgetConfig struct { + MaxCostUSD *float64 `json:"max_cost_usd"` + MaxProvers *int `json:"max_provers"` + MaxDurationSeconds *int `json:"max_duration_seconds"` + + ReconBudgetPct float64 `json:"recon_budget_pct"` + HuntBudgetPct float64 `json:"hunt_budget_pct"` + ProveBudgetPct float64 `json:"prove_budget_pct"` + + MaxConcurrentHunters int `json:"max_concurrent_hunters"` + MaxConcurrentProvers int `json:"max_concurrent_provers"` + + HunterEarlyStopFileThreshold int `json:"hunter_early_stop_file_threshold"` +} + +// DefaultBudgetConfig builds the pydantic defaults verbatim: +// +// recon_budget_pct = 0.10, hunt_budget_pct = 0.45, prove_budget_pct = 0.45 +// max_concurrent_hunters = 4, max_concurrent_provers = 3 +// hunter_early_stop_file_threshold = 30 +// max_cost_usd / max_provers / max_duration_seconds = None +func DefaultBudgetConfig() BudgetConfig { + return BudgetConfig{ + ReconBudgetPct: 0.10, + HuntBudgetPct: 0.45, + ProveBudgetPct: 0.45, + MaxConcurrentHunters: 4, + MaxConcurrentProvers: 3, + HunterEarlyStopFileThreshold: 30, + } +} + +// UnmarshalJSON seeds the pydantic defaults before decoding, so keys absent from +// the payload keep their Python default rather than the Go zero value. +func (b *BudgetConfig) UnmarshalJSON(data []byte) error { + type alias BudgetConfig + v := alias(DefaultBudgetConfig()) + if err := json.Unmarshal(data, &v); err != nil { + return err + } + *b = BudgetConfig(v) + return nil +} diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go new file mode 100644 index 0000000..69d8ef9 --- /dev/null +++ b/go/internal/config/config_test.go @@ -0,0 +1,674 @@ +package config + +import ( + "encoding/json" + "math" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/agent" +) + +// This file ports the config-related tests from tests/test_config.py. Each Go +// test names the Python test it derives from so reviewers can diff coverage. + +// unsetEnv removes keys for the duration of the test and restores whatever was +// there afterwards — the Go equivalent of monkeypatch.delenv(..., raising=False). +func unsetEnv(t *testing.T, keys ...string) { + t.Helper() + for _, key := range keys { + key := key + if old, had := os.LookupEnv(key); had { + t.Cleanup(func() { _ = os.Setenv(key, old) }) + } else { + t.Cleanup(func() { _ = os.Unsetenv(key) }) + } + if err := os.Unsetenv(key); err != nil { + t.Fatalf("unset %s: %v", key, err) + } + } +} + +func approx(t *testing.T, got, want float64) { + t.Helper() + if math.Abs(got-want) > 1e-9 { + t.Errorf("got %v, want ~%v", got, want) + } +} + +func ptrF(f float64) *float64 { return &f } +func ptrI(i int) *int { return &i } + +// sampleAuditInput mirrors tests/conftest.py::sample_audit_input. +func sampleAuditInput() AuditInputFields { + return AuditInputFields{ + Depth: "standard", + SeverityThreshold: "low", + ScanTypes: []string{"sast", "secrets", "config"}, + OutputFormats: []string{"json", "sarif", "markdown"}, + ComplianceFrameworks: []string{"PCI-DSS", "SOC2", "OWASP"}, + IncludePaths: []string{"src/"}, + ExcludePaths: []string{"tests/", "vendor/", ".git/"}, + MaxCostUSD: ptrF(10.0), + MaxProvers: ptrI(4), + MaxDurationSeconds: ptrI(900), + } +} + +// TestDepthProfileValuesAreStable ports +// test_config.py::test_depth_profile_values_are_stable. +func TestDepthProfileValuesAreStable(t *testing.T) { + if DepthQuick != "quick" { + t.Errorf("DepthQuick = %q", DepthQuick) + } + if DepthStandard != "standard" { + t.Errorf("DepthStandard = %q", DepthStandard) + } + if DepthThorough != "thorough" { + t.Errorf("DepthThorough = %q", DepthThorough) + } +} + +// TestBudgetConfigDefaultsSumTo100Percent ports +// test_config.py::test_budget_config_defaults_sum_to_100_percent. +func TestBudgetConfigDefaultsSumTo100Percent(t *testing.T) { + b := DefaultBudgetConfig() + approx(t, b.ReconBudgetPct+b.HuntBudgetPct+b.ProveBudgetPct, 1.0) + if b.MaxCostUSD != nil || b.MaxProvers != nil || b.MaxDurationSeconds != nil { + t.Errorf("caps default to non-None: %#v", b) + } + // The remaining pydantic defaults, which the Python test does not cover but + // the phases depend on. + if b.MaxConcurrentHunters != 4 || b.MaxConcurrentProvers != 3 || b.HunterEarlyStopFileThreshold != 30 { + t.Errorf("concurrency/threshold defaults = %#v", b) + } +} + +// TestAuditConfigFromInputMapsFieldsAndBudget ports +// test_config.py::test_audit_config_from_input_maps_fields_and_budget. +func TestAuditConfigFromInputMapsFieldsAndBudget(t *testing.T) { + cfg, err := AuditConfig{}.FromInputFields(sampleAuditInput(), "/tmp/sec-af-repo") + if err != nil { + t.Fatalf("FromInputFields: %v", err) + } + if cfg.RepoPath != "/tmp/sec-af-repo" { + t.Errorf("RepoPath = %q", cfg.RepoPath) + } + if cfg.Depth != DepthStandard { + t.Errorf("Depth = %q", cfg.Depth) + } + if !reflect.DeepEqual(cfg.ScanTypes, []string{"sast", "secrets", "config"}) { + t.Errorf("ScanTypes = %#v", cfg.ScanTypes) + } + if !reflect.DeepEqual(cfg.OutputFormats, []string{"json", "sarif", "markdown"}) { + t.Errorf("OutputFormats = %#v", cfg.OutputFormats) + } + if cfg.Budget.MaxCostUSD == nil || *cfg.Budget.MaxCostUSD != 10.0 { + t.Errorf("Budget.MaxCostUSD = %#v", cfg.Budget.MaxCostUSD) + } + if cfg.Budget.MaxProvers == nil || *cfg.Budget.MaxProvers != 4 { + t.Errorf("Budget.MaxProvers = %#v", cfg.Budget.MaxProvers) + } + if cfg.Budget.MaxDurationSeconds == nil || *cfg.Budget.MaxDurationSeconds != 900 { + t.Errorf("Budget.MaxDurationSeconds = %#v", cfg.Budget.MaxDurationSeconds) + } + // BudgetConfig(...) is constructed with only the three caps, so everything + // else keeps its own default. + if cfg.Budget.MaxConcurrentHunters != 4 || cfg.Budget.MaxConcurrentProvers != 3 { + t.Errorf("budget concurrency lost its defaults: %#v", cfg.Budget) + } + approx(t, cfg.Budget.ReconBudgetPct, 0.10) + // Fields from_input does not pass keep their pydantic defaults. + if cfg.SeverityThreshold != "low" { + t.Errorf("SeverityThreshold = %q", cfg.SeverityThreshold) + } + if !reflect.DeepEqual(cfg.IncludePaths, []string{"src/"}) { + t.Errorf("IncludePaths = %#v", cfg.IncludePaths) + } + if !reflect.DeepEqual(cfg.ExcludePaths, []string{"tests/", "vendor/", ".git/"}) { + t.Errorf("ExcludePaths = %#v", cfg.ExcludePaths) + } + if !reflect.DeepEqual(cfg.ComplianceFrameworks, []string{"PCI-DSS", "SOC2", "OWASP"}) { + t.Errorf("ComplianceFrameworks = %#v", cfg.ComplianceFrameworks) + } +} + +// TestAuditConfigRejectsInvalidDepth ports +// test_config.py::test_audit_config_rejects_invalid_depth — from_input uses the +// STRICT enum constructor, so an unknown depth fails rather than falling back to +// STANDARD the way _normalize_depth would. +func TestAuditConfigRejectsInvalidDepth(t *testing.T) { + in := sampleAuditInput() + in.Depth = "invalid" + _, err := AuditConfig{}.FromInputFields(in, "/tmp/sec-af-repo") + if err == nil { + t.Fatal("FromInputFields accepted an invalid depth") + } + if want := "'invalid' is not a valid DepthProfile"; err.Error() != want { + t.Errorf("error = %q, python ValueError = %q", err.Error(), want) + } +} + +// TestAuditConfigDefaultsToAforgeProvider ports +// test_config.py::test_audit_config_defaults_to_aforge_provider. +func TestAuditConfigDefaultsToAforgeProvider(t *testing.T) { + cfg, err := AuditConfig{}.FromInputFields(sampleAuditInput(), "/tmp/sec-af-repo") + if err != nil { + t.Fatalf("FromInputFields: %v", err) + } + if cfg.Provider != "aforge" { + t.Errorf("Provider = %q, want aforge", cfg.Provider) + } +} + +// TestFromInputProjectsAnAuditInputShapedValue covers the `in any` projection +// path — the one the node package will use with schemas.AuditInput. +func TestFromInputProjectsAnAuditInputShapedValue(t *testing.T) { + // Deliberately a DIFFERENT struct with extra fields, standing in for + // schemas.AuditInput. + type auditInputLike struct { + RepoURL string `json:"repo_url"` + Branch string `json:"branch"` + Depth string `json:"depth"` + SeverityThreshold string `json:"severity_threshold"` + ScanTypes []string `json:"scan_types"` + OutputFormats []string `json:"output_formats"` + ComplianceFrameworks []string `json:"compliance_frameworks"` + IncludePaths []string `json:"include_paths"` + ExcludePaths []string `json:"exclude_paths"` + MaxCostUSD *float64 `json:"max_cost_usd"` + MaxProvers *int `json:"max_provers"` + MaxDurationSeconds *int `json:"max_duration_seconds"` + IsPR bool `json:"is_pr"` + } + in := auditInputLike{ + RepoURL: "https://github.com/Agent-Field/sec-af", + Branch: "main", + Depth: "thorough", + SeverityThreshold: "high", + ScanTypes: []string{"sast"}, + OutputFormats: []string{"json"}, + MaxCostUSD: ptrF(2.5), + IsPR: true, + } + cfg, err := AuditConfig{}.FromInput(in, "/repo") + if err != nil { + t.Fatalf("FromInput: %v", err) + } + if cfg.Depth != DepthThorough { + t.Errorf("Depth = %q", cfg.Depth) + } + if cfg.SeverityThreshold != "high" { + t.Errorf("SeverityThreshold = %q", cfg.SeverityThreshold) + } + if cfg.Budget.MaxCostUSD == nil || *cfg.Budget.MaxCostUSD != 2.5 { + t.Errorf("Budget.MaxCostUSD = %#v", cfg.Budget.MaxCostUSD) + } + if cfg.RepoPath != "/repo" { + t.Errorf("RepoPath = %q", cfg.RepoPath) + } +} + +// TestNormalizeDepth covers the lenient in-pipeline helper (phases.py:52 and its +// three verbatim copies): case-insensitive, and ANY unknown value becomes +// STANDARD. +func TestNormalizeDepth(t *testing.T) { + cases := []struct { + in string + want DepthProfile + }{ + {"quick", DepthQuick}, + {"standard", DepthStandard}, + {"thorough", DepthThorough}, + {"QUICK", DepthQuick}, + {"Thorough", DepthThorough}, + {"invalid", DepthStandard}, + {"", DepthStandard}, + {"deep", DepthStandard}, + {" quick", DepthStandard}, // python: DepthProfile(" quick") raises -> STANDARD + } + for _, c := range cases { + if got := NormalizeDepth(c.in); got != c.want { + t.Errorf("NormalizeDepth(%q) = %q, want %q", c.in, got, c.want) + } + } + // The business_logic.py `str | DepthProfile` variant: an already-typed + // profile round-trips unchanged. + if got := NormalizeDepth(string(DepthThorough)); got != DepthThorough { + t.Errorf("NormalizeDepth(DepthThorough) = %q", got) + } +} + +// TestAIIntegrationConfigUsesSecAfEnvPrecedence ports +// test_config.py::test_ai_integration_config_uses_sec_af_env_precedence. +func TestAIIntegrationConfigUsesSecAfEnvPrecedence(t *testing.T) { + t.Setenv("SEC_AF_PROVIDER", "custom-provider") + t.Setenv("HARNESS_PROVIDER", "fallback-provider") + t.Setenv("SEC_AF_MODEL", "provider/model-a") + t.Setenv("HARNESS_MODEL", "provider/model-b") + t.Setenv("SEC_AF_AI_MODEL", "provider/model-c") + t.Setenv("SEC_AF_MAX_TURNS", "75") + t.Setenv("SEC_AF_AI_MAX_RETRIES", "6") + t.Setenv("SEC_AF_AI_INITIAL_BACKOFF_SECONDS", "1.5") + t.Setenv("SEC_AF_AI_MAX_BACKOFF_SECONDS", "12") + t.Setenv("SEC_AF_OPENCODE_BIN", "/usr/local/bin/opencode") + t.Setenv("SEC_AF_AFORGE_BIN", "/usr/local/bin/aforge") + + cfg, err := AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + if cfg.Provider != "custom-provider" { + t.Errorf("Provider = %q", cfg.Provider) + } + if cfg.HarnessModel != "provider/model-a" { + t.Errorf("HarnessModel = %q", cfg.HarnessModel) + } + if cfg.AIModel != "provider/model-c" { + t.Errorf("AIModel = %q", cfg.AIModel) + } + if cfg.MaxTurns != 75 { + t.Errorf("MaxTurns = %d", cfg.MaxTurns) + } + if cfg.MaxRetries != 6 { + t.Errorf("MaxRetries = %d", cfg.MaxRetries) + } + approx(t, cfg.InitialBackoffSeconds, 1.5) + approx(t, cfg.MaxBackoffSeconds, 12) + if cfg.OpencodeBin != "/usr/local/bin/opencode" { + t.Errorf("OpencodeBin = %q", cfg.OpencodeBin) + } + if cfg.AforgeBin != "/usr/local/bin/aforge" { + t.Errorf("AforgeBin = %q", cfg.AforgeBin) + } +} + +// TestAIIntegrationConfigFallsBackToHarnessAndDefaults ports +// test_config.py::test_ai_integration_config_falls_back_to_harness_and_defaults. +func TestAIIntegrationConfigFallsBackToHarnessAndDefaults(t *testing.T) { + unsetEnv(t, + "SEC_AF_PROVIDER", "HARNESS_PROVIDER", + "SEC_AF_MODEL", "HARNESS_MODEL", + "SEC_AF_AI_MODEL", "AI_MODEL", + "SEC_AF_MAX_TURNS", "SEC_AF_AI_MAX_RETRIES", + "SEC_AF_AI_INITIAL_BACKOFF_SECONDS", "SEC_AF_AI_MAX_BACKOFF_SECONDS", + "SEC_AF_OPENCODE_BIN", "SEC_AF_AFORGE_BIN", "AFORGE_BIN", + ) + + cfg, err := AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + if cfg.Provider != "aforge" { + t.Errorf("Provider = %q", cfg.Provider) + } + if cfg.HarnessModel != "minimax/minimax-m2.5" { + t.Errorf("HarnessModel = %q", cfg.HarnessModel) + } + if cfg.AIModel != "minimax/minimax-m2.5" { + t.Errorf("AIModel = %q", cfg.AIModel) + } + if cfg.MaxTurns != 50 { + t.Errorf("MaxTurns = %d", cfg.MaxTurns) + } + if cfg.MaxRetries != 3 { + t.Errorf("MaxRetries = %d", cfg.MaxRetries) + } + approx(t, cfg.InitialBackoffSeconds, 2.0) + approx(t, cfg.MaxBackoffSeconds, 8.0) + if cfg.OpencodeBin != "opencode" { + t.Errorf("OpencodeBin = %q", cfg.OpencodeBin) + } + if cfg.AforgeBin != "aforge" { + t.Errorf("AforgeBin = %q", cfg.AforgeBin) + } + + env, err := cfg.ProviderEnv() + if err != nil { + t.Fatalf("ProviderEnv: %v", err) + } + if env["AGENTFIELD_AFORGE_COMMAND"] != "exec" { + t.Errorf("AGENTFIELD_AFORGE_COMMAND = %q, want exec", env["AGENTFIELD_AFORGE_COMMAND"]) + } +} + +// TestAIIntegrationConfigFallbackChains covers the per-variable fallbacks the +// Python test only exercises at their endpoints. +func TestAIIntegrationConfigFallbackChains(t *testing.T) { + unsetEnv(t, + "SEC_AF_PROVIDER", "SEC_AF_MODEL", "SEC_AF_AI_MODEL", "AI_MODEL", + "SEC_AF_AFORGE_BIN", "SEC_AF_OPENCODE_SERVER", "OPENCODE_SERVER", + ) + t.Setenv("HARNESS_PROVIDER", "opencode") + t.Setenv("HARNESS_MODEL", "provider/harness-model") + t.Setenv("AFORGE_BIN", "/opt/aforge") + + cfg, err := AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + if cfg.Provider != "opencode" { + t.Errorf("Provider = %q, want the HARNESS_PROVIDER fallback", cfg.Provider) + } + if cfg.HarnessModel != "provider/harness-model" { + t.Errorf("HarnessModel = %q, want the HARNESS_MODEL fallback", cfg.HarnessModel) + } + // ai_model falls back SEC_AF_AI_MODEL > AI_MODEL > SEC_AF_MODEL > default. + // HARNESS_MODEL is NOT in that chain, so the code default wins here. + if cfg.AIModel != "minimax/minimax-m2.5" { + t.Errorf("AIModel = %q — HARNESS_MODEL must not leak into the ai_model chain", cfg.AIModel) + } + if cfg.AforgeBin != "/opt/aforge" { + t.Errorf("AforgeBin = %q, want the AFORGE_BIN fallback", cfg.AforgeBin) + } + if cfg.OpencodeServer != nil { + t.Errorf("OpencodeServer = %v, want nil when neither key is set", *cfg.OpencodeServer) + } + + // ai_model's third rung: SEC_AF_MODEL. + t.Setenv("SEC_AF_MODEL", "provider/sec-af-model") + cfg, err = AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + if cfg.AIModel != "provider/sec-af-model" { + t.Errorf("AIModel = %q, want the SEC_AF_MODEL fallback", cfg.AIModel) + } +} + +// TestAIIntegrationConfigEmptyStringWins pins the os.getenv semantic Go's +// os.Getenv cannot express: an env var SET TO THE EMPTY STRING is a value, not +// an absent key, so it beats the fallback. +func TestAIIntegrationConfigEmptyStringWins(t *testing.T) { + unsetEnv(t, "OPENCODE_SERVER") + t.Setenv("SEC_AF_PROVIDER", "") + t.Setenv("HARNESS_PROVIDER", "aforge") + t.Setenv("SEC_AF_OPENCODE_SERVER", "") + + cfg, err := AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + if cfg.Provider != "" { + t.Errorf("Provider = %q, want \"\" (python os.getenv returns the empty value)", cfg.Provider) + } + if cfg.OpencodeServer == nil || *cfg.OpencodeServer != "" { + t.Errorf("OpencodeServer = %#v, want a pointer to \"\" (set-but-empty is not None)", cfg.OpencodeServer) + } +} + +// TestAIIntegrationConfigOpencodeServerFallback: SEC_AF_OPENCODE_SERVER > +// OPENCODE_SERVER > None. +func TestAIIntegrationConfigOpencodeServerFallback(t *testing.T) { + unsetEnv(t, "SEC_AF_OPENCODE_SERVER") + t.Setenv("OPENCODE_SERVER", "http://localhost:4096") + + cfg, err := AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + if cfg.OpencodeServer == nil || *cfg.OpencodeServer != "http://localhost:4096" { + t.Errorf("OpencodeServer = %#v", cfg.OpencodeServer) + } +} + +// TestAIIntegrationConfigMalformedNumbersAreFatal: Python's int()/float() raises +// inside the default_factory, which runs at app.py import — the node fails to +// boot rather than silently using a default. +func TestAIIntegrationConfigMalformedNumbersAreFatal(t *testing.T) { + for _, c := range []struct{ key, value string }{ + {"SEC_AF_MAX_TURNS", "fifty"}, + {"SEC_AF_MAX_TURNS", ""}, + {"SEC_AF_MAX_TURNS", "50.5"}, + {"SEC_AF_AI_MAX_RETRIES", "many"}, + {"SEC_AF_AI_INITIAL_BACKOFF_SECONDS", "slow"}, + {"SEC_AF_AI_MAX_BACKOFF_SECONDS", "8s"}, + } { + t.Run(c.key+"="+c.value, func(t *testing.T) { + t.Setenv(c.key, c.value) + if _, err := AIConfigFromEnv(); err == nil { + t.Errorf("AIConfigFromEnv accepted %s=%q", c.key, c.value) + } + }) + } +} + +// TestAIIntegrationConfigTolerantNumberSpellings: Python's int()/float() strip +// surrounding whitespace. +func TestAIIntegrationConfigTolerantNumberSpellings(t *testing.T) { + t.Setenv("SEC_AF_MAX_TURNS", " 75 ") + t.Setenv("SEC_AF_AI_INITIAL_BACKOFF_SECONDS", " 1.5 ") + cfg, err := AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + if cfg.MaxTurns != 75 { + t.Errorf("MaxTurns = %d", cfg.MaxTurns) + } + approx(t, cfg.InitialBackoffSeconds, 1.5) +} + +// TestProviderEnvOnlyIncludesPresentKeys ports +// test_config.py::test_provider_env_only_includes_present_keys. +func TestProviderEnvOnlyIncludesPresentKeys(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("OPENAI_API_KEY", "test-openai") + t.Setenv("GITHUB_TOKEN", "test-gh") + unsetEnv(t, "OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "GOOGLE_API_KEY", "GH_TOKEN") + + cfg, err := AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + env, err := cfg.ProviderEnv() + if err != nil { + t.Fatalf("ProviderEnv: %v", err) + } + + if env["OPENAI_API_KEY"] != "test-openai" { + t.Errorf("OPENAI_API_KEY = %q", env["OPENAI_API_KEY"]) + } + if env["GITHUB_TOKEN"] != "test-gh" { + t.Errorf("GITHUB_TOKEN = %q", env["GITHUB_TOKEN"]) + } + if _, present := env["OPENROUTER_API_KEY"]; present { + t.Error("OPENROUTER_API_KEY leaked into provider_env") + } + if _, present := env["XDG_DATA_HOME"]; !present { + t.Error("XDG_DATA_HOME missing from provider_env") + } +} + +// TestProviderEnvSkipsEmptyCredentials: the Python dict comprehension uses +// TRUTHINESS (`if (value := os.getenv(key))`), so a key set to "" is omitted — +// forwarding it would blank the variable in the harness subprocess (the Go SDK +// treats an empty Env value as "unset this"). +func TestProviderEnvSkipsEmptyCredentials(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("OPENROUTER_API_KEY", "") + t.Setenv("ANTHROPIC_API_KEY", "real") + + cfg, err := AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + env, err := cfg.ProviderEnv() + if err != nil { + t.Fatalf("ProviderEnv: %v", err) + } + if _, present := env["OPENROUTER_API_KEY"]; present { + t.Error("an empty credential was forwarded; python truthiness drops it") + } + if env["ANTHROPIC_API_KEY"] != "real" { + t.Errorf("ANTHROPIC_API_KEY = %q", env["ANTHROPIC_API_KEY"]) + } +} + +// TestProviderEnvAforgeCommandOverride: getenv-with-default, so an explicitly +// empty value IS forwarded and only an absent key becomes "exec". +func TestProviderEnvAforgeCommandOverride(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + + t.Setenv("AGENTFIELD_AFORGE_COMMAND", "run") + cfg, err := AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + env, err := cfg.ProviderEnv() + if err != nil { + t.Fatalf("ProviderEnv: %v", err) + } + if env["AGENTFIELD_AFORGE_COMMAND"] != "run" { + t.Errorf("AGENTFIELD_AFORGE_COMMAND = %q, want run", env["AGENTFIELD_AFORGE_COMMAND"]) + } + + t.Setenv("AGENTFIELD_AFORGE_COMMAND", "") + env, err = cfg.ProviderEnv() + if err != nil { + t.Fatalf("ProviderEnv: %v", err) + } + if got, present := env["AGENTFIELD_AFORGE_COMMAND"]; !present || got != "" { + t.Errorf("AGENTFIELD_AFORGE_COMMAND = %q/%v, want a present empty value", got, present) + } +} + +// TestProviderEnvCreatesXDGDataHome: Python calls os.makedirs(xdg, exist_ok=True) +// unconditionally, and the default is /opencode-shared-data. +func TestProviderEnvCreatesXDGDataHome(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "nested", "xdg") + t.Setenv("XDG_DATA_HOME", target) + + cfg, err := AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + env, err := cfg.ProviderEnv() + if err != nil { + t.Fatalf("ProviderEnv: %v", err) + } + if env["XDG_DATA_HOME"] != target { + t.Errorf("XDG_DATA_HOME = %q, want %q", env["XDG_DATA_HOME"], target) + } + if st, err := os.Stat(target); err != nil || !st.IsDir() { + t.Errorf("ProviderEnv did not create %q: %v", target, err) + } +} + +// TestProviderEnvDefaultXDGPath: an EMPTY XDG_DATA_HOME is falsy in Python's +// `or`, so it falls back to /opencode-shared-data — not to "". +func TestProviderEnvDefaultXDGPath(t *testing.T) { + t.Setenv("TMPDIR", t.TempDir()) + t.Setenv("XDG_DATA_HOME", "") + + cfg, err := AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + env, err := cfg.ProviderEnv() + if err != nil { + t.Fatalf("ProviderEnv: %v", err) + } + want := filepath.Join(os.TempDir(), "opencode-shared-data") + if env["XDG_DATA_HOME"] != want { + t.Errorf("XDG_DATA_HOME = %q, want %q", env["XDG_DATA_HOME"], want) + } + if st, err := os.Stat(want); err != nil || !st.IsDir() { + t.Errorf("ProviderEnv did not create the default dir %q: %v", want, err) + } +} + +// TestPinnedAgentFieldSDKExposesTheAforgeSurface ports +// test_config.py::test_pinned_agentfield_sdk_exposes_the_aforge_surface: the +// pinned SDK must accept the aforge harness settings app.py sends it. +// +// The Go SDK's HarnessConfig has ONE BinPath where Python has separate +// opencode_bin and aforge_bin, so the node picks the binary matching the +// provider (design §2). This asserts the config half of that mapping. +func TestPinnedAgentFieldSDKExposesTheAforgeSurface(t *testing.T) { + unsetEnv(t, "SEC_AF_PROVIDER", "HARNESS_PROVIDER", "SEC_AF_AFORGE_BIN", "AFORGE_BIN") + t.Setenv("XDG_DATA_HOME", t.TempDir()) + + cfg, err := AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + env, err := cfg.ProviderEnv() + if err != nil { + t.Fatalf("ProviderEnv: %v", err) + } + + hc := agent.HarnessConfig{ + Provider: cfg.Provider, + Model: cfg.HarnessModel, + MaxTurns: cfg.MaxTurns, + Env: env, + BinPath: cfg.AforgeBin, + PermissionMode: "auto", + } + if hc.Provider != "aforge" { + t.Errorf("HarnessConfig.Provider = %q, want aforge", hc.Provider) + } + if hc.BinPath != "aforge" { + t.Errorf("HarnessConfig.BinPath = %q, want aforge", hc.BinPath) + } + if hc.PermissionMode != "auto" { + t.Errorf("HarnessConfig.PermissionMode = %q", hc.PermissionMode) + } +} + +// TestBudgetConfigUnmarshalSeedsDefaults: a BudgetConfig arriving over the wire +// (checkpoint, phase payload) must not silently become "0 hunters". +func TestBudgetConfigUnmarshalSeedsDefaults(t *testing.T) { + var b BudgetConfig + if err := json.Unmarshal([]byte(`{"max_provers": 7}`), &b); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if b.MaxProvers == nil || *b.MaxProvers != 7 { + t.Errorf("MaxProvers = %#v", b.MaxProvers) + } + if b.MaxConcurrentHunters != 4 || b.MaxConcurrentProvers != 3 || b.HunterEarlyStopFileThreshold != 30 { + t.Errorf("defaults lost: %#v", b) + } + approx(t, b.HuntBudgetPct, 0.45) +} + +// TestAuditConfigUnmarshalSeedsDefaults. +func TestAuditConfigUnmarshalSeedsDefaults(t *testing.T) { + var c AuditConfig + if err := json.Unmarshal([]byte(`{"repo_path": "/r"}`), &c); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if c.RepoPath != "/r" { + t.Errorf("RepoPath = %q", c.RepoPath) + } + if c.Depth != DepthStandard || c.SeverityThreshold != "low" || c.Provider != "aforge" { + t.Errorf("defaults lost: %#v", c) + } + if !reflect.DeepEqual(c.ScanTypes, []string{"sast", "sca", "secrets", "config"}) { + t.Errorf("ScanTypes = %#v", c.ScanTypes) + } + if !reflect.DeepEqual(c.ExcludePaths, []string{"tests/", "vendor/", "node_modules/", ".git/"}) { + t.Errorf("ExcludePaths = %#v", c.ExcludePaths) + } + if c.IncludePaths != nil { + t.Errorf("IncludePaths = %#v, want nil (python None)", c.IncludePaths) + } + if c.Budget.MaxConcurrentHunters != 4 { + t.Errorf("nested budget defaults lost: %#v", c.Budget) + } +} + +// TestDefaultAuditConfigReturnsFreshSlices: pydantic's default_factory hands +// each model its own list; a shared package-level slice would let one audit's +// mutation leak into the next. +func TestDefaultAuditConfigReturnsFreshSlices(t *testing.T) { + a := DefaultAuditConfig() + b := DefaultAuditConfig() + a.ScanTypes[0] = "mutated" + if b.ScanTypes[0] != "sast" { + t.Error("DefaultAuditConfig shares its slices between calls") + } +} diff --git a/go/internal/config/depth.go b/go/internal/config/depth.go new file mode 100644 index 0000000..028ecbd --- /dev/null +++ b/go/internal/config/depth.go @@ -0,0 +1,65 @@ +// Package config ports src/sec_af/config.py in full: the depth profiles, the +// budget thresholds, the runtime AuditConfig the orchestrator phases consume, +// and the environment-derived AIIntegrationConfig the node hands to the SDK. +// +// Every environment variable is read at CALL time (inside FromEnv / +// ProviderEnv), never at package init, so a t.Setenv in a test is deterministic +// and nothing is frozen at import. That also matches Python, where the values +// come from `Field(default_factory=lambda: os.getenv(...))` — evaluated when the +// model is constructed, which app.py does at import. +package config + +import "strings" + +// DepthProfile ports config.py DepthProfile — the scan-depth profile +// (DESIGN.md §9). It is a string type so it marshals to and from the same JSON +// values as the Python `class DepthProfile(str, Enum)`. +type DepthProfile string + +// The three profiles, with the exact Python enum values. +const ( + DepthQuick DepthProfile = "quick" + DepthStandard DepthProfile = "standard" + DepthThorough DepthProfile = "thorough" +) + +// IsValid reports whether d is one of the three declared profiles — the +// membership test `DepthProfile(value)` performs in Python. +func (d DepthProfile) IsValid() bool { + switch d { + case DepthQuick, DepthStandard, DepthThorough: + return true + } + return false +} + +// String returns the profile's wire value. +func (d DepthProfile) String() string { return string(d) } + +// NormalizeDepth ports the _normalize_depth helper that SEC-AF repeats verbatim +// in four modules — src/sec_af/reasoners/phases.py:52, +// agents/recon/__init__.py:62, agents/hunt/__init__.py:73 and +// agents/prove/__init__.py:47: +// +// def _normalize_depth(depth: str) -> DepthProfile: +// try: +// return DepthProfile(depth.lower()) +// except ValueError: +// return DepthProfile.STANDARD +// +// Lower-case first, and ANY unrecognised value silently becomes STANDARD. This +// is the LENIENT path used everywhere inside the pipeline; it is deliberately +// NOT what AuditConfig.FromInput does, which uses the strict `DepthProfile(...)` +// constructor and fails on a bad value. +// +// src/sec_af/agents/hunt/business_logic.py:26 declares a fifth copy that accepts +// `str | DepthProfile` and short-circuits when it is already a profile. In Go +// DepthProfile IS a string type, so NormalizeDepth(string(d)) covers both arms +// with the same result. +func NormalizeDepth(depth string) DepthProfile { + p := DepthProfile(strings.ToLower(depth)) + if p.IsValid() { + return p + } + return DepthStandard +} diff --git a/go/internal/diffanalysis/diffanalysis.go b/go/internal/diffanalysis/diffanalysis.go new file mode 100644 index 0000000..af7d6e1 --- /dev/null +++ b/go/internal/diffanalysis/diffanalysis.go @@ -0,0 +1,340 @@ +// Package diffanalysis ports src/sec_af/diff_analysis.py — the diff-aware file +// selection SEC-AF uses in PR mode. +// +// The orchestrator builds one DiffAnalysis per run when the audit input is a PR +// (`is_pr` with a `base_commit_sha`) and feeds AllRelevantFiles to the hunt +// phase as `include_paths`, so the set of files this package computes decides +// what the hunters look at. +// +// self.diff_analysis = analyze_diff( +// str(self.repo_path), input.base_commit_sha, input.commit_sha or "HEAD", +// ) +// +// Everything here shells out to git and swallows failures the way Python does: +// a git that cannot be started, or that takes longer than 30 seconds, yields an +// EMPTY analysis (for the diff) or is skipped (for a blast-radius grep). A git +// that runs and exits non-zero is NOT a failure — Python passes check=False, so +// the (usually empty) stdout is used as-is. +package diffanalysis + +import ( + "bytes" + "context" + "os/exec" + "sort" + "strings" + "time" +) + +// DefaultHeadSHA is the value Python's `head_sha` parameter defaults to: +// +// def analyze_diff(repo_path: str, base_sha: str, head_sha: str = "HEAD") +// +// Go has no default arguments, so callers pass it explicitly. The only +// production caller (orchestrator.py) always supplies `input.commit_sha or +// "HEAD"`, i.e. this constant whenever the input carries no commit. +const DefaultHeadSHA = "HEAD" + +// gitTimeout is the per-invocation `timeout=30` both subprocess.run calls pass. +const gitTimeout = 30 * time.Second + +// DiffAnalysis is the result of analyzing the git diff between base and head. +// +// Ports the diff_analysis.py dataclass of the same name. The three list fields +// have `field(default_factory=list)`, so the zero value of a Go DiffAnalysis +// (nil slices) is NOT the Python default — use NewDiffAnalysis, which every +// constructor path in this package does, so the slices are always non-nil and +// marshal as `[]` rather than `null`. +type DiffAnalysis struct { + ChangedFiles []string `json:"changed_files"` + BlastRadiusFiles []string `json:"blast_radius_files"` + AllRelevantFiles []string `json:"all_relevant_files"` + BaseSHA string `json:"base_sha"` + HeadSHA string `json:"head_sha"` +} + +// NewDiffAnalysis returns the dataclass defaults: three empty lists, +// base_sha "" and head_sha "HEAD". +func NewDiffAnalysis() DiffAnalysis { + return DiffAnalysis{ + ChangedFiles: []string{}, + BlastRadiusFiles: []string{}, + AllRelevantFiles: []string{}, + BaseSHA: "", + HeadSHA: DefaultHeadSHA, + } +} + +// FileCount ports the `file_count` property: the number of files the hunters +// will actually be pointed at. +func (d DiffAnalysis) FileCount() int { return len(d.AllRelevantFiles) } + +// gitRunner runs one git invocation in dir and returns its stdout. +// +// It is a package variable purely so tests can script git, mirroring +// tests/test_diff_analysis.py's +// `monkeypatch.setattr("sec_af.diff_analysis.subprocess.run", _fake_run)`. +// argv includes "git" as its first element so a scripted runner can branch on +// argv[:2] exactly like the Python fake does. +var gitRunner = runGit + +// runGit is the production seam: `subprocess.run(argv, cwd=dir, +// capture_output=True, text=True, timeout=30, check=False)`. +// +// Python parity, in the two ways this differs from a naive exec.Command: +// +// - check=False means a NON-ZERO EXIT IS NOT AN ERROR. `git grep -l` exits 1 +// when nothing matched, and `git diff` exits non-zero for an unknown +// revision; Python reads result.stdout regardless. So an *exec.ExitError is +// reported back as (stdout, nil). +// - timeout=30 raises subprocess.TimeoutExpired, a subprocess.SubprocessError, +// which both call sites catch. A deadline hit here returns an error, which +// routes to the same handling. +// +// A git that cannot be spawned at all (not on PATH, cwd missing) is Python's +// OSError — also an error here, also caught by both call sites. +func runGit(ctx context.Context, dir string, argv []string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, gitTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + cmd.Dir = dir + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if ctxErr := ctx.Err(); ctxErr != nil { + // The deadline (or the caller's cancellation) killed git: + // subprocess.TimeoutExpired. + return "", ctxErr + } + if err != nil { + if _, isExit := err.(*exec.ExitError); isExit { + // check=False: a non-zero exit is a normal, non-raising outcome. + return stdout.String(), nil + } + return "", err + } + return stdout.String(), nil +} + +// AnalyzeDiff analyzes the git diff to find changed files and their blast +// radius. +// +// Ports src/sec_af/diff_analysis.py analyze_diff. Blast radius = files that +// import from, or are imported by, changed files — approximated by grepping the +// head tree for each changed file's module name. +// +// Never returns an error: every git failure degrades to an empty or partial +// result, exactly as the Python function does. +func AnalyzeDiff(ctx context.Context, repoPath, baseSHA, headSHA string) DiffAnalysis { + empty := NewDiffAnalysis() + empty.BaseSHA = baseSHA + empty.HeadSHA = headSHA + + stdout, err := gitRunner(ctx, repoPath, []string{ + "git", "diff", "--name-only", "--diff-filter=ACMR", baseSHA, headSHA, + }) + if err != nil { + // except (subprocess.SubprocessError, OSError): return DiffAnalysis(...) + return empty + } + + var changed []string + for _, line := range splitLines(stdout) { + if line != "" && IsScannable(line) { + changed = append(changed, line) + } + } + if len(changed) == 0 { + return empty + } + + // `changed` membership is tested per grep hit below; a set keeps that O(1) + // without changing behavior (Python's `file_path not in changed` is a list + // scan over the same elements). + changedSet := make(map[string]struct{}, len(changed)) + for _, f := range changed { + changedSet[f] = struct{}{} + } + + blastRadius := map[string]struct{}{} + for _, changedFile := range changed { + moduleName := FileToModule(changedFile) + if moduleName == "" { + continue + } + + grepOut, grepErr := gitRunner(ctx, repoPath, []string{ + "git", "grep", "-l", moduleName, headSHA, + "--", "*.py", "*.ts", "*.js", "*.go", "*.java", "*.rb", + }) + if grepErr != nil { + // except (subprocess.SubprocessError, OSError): continue + continue + } + for _, line := range splitLines(grepOut) { + if line == "" { + continue + } + // `git grep ` prefixes every hit with ":". + filePath := line + if i := strings.Index(line, ":"); i >= 0 { + filePath = line[i+1:] + } + if _, isChanged := changedSet[filePath]; isChanged { + continue + } + if IsScannable(filePath) { + blastRadius[filePath] = struct{}{} + } + } + } + + out := NewDiffAnalysis() + out.BaseSHA = baseSHA + out.HeadSHA = headSHA + out.ChangedFiles = sortedCopy(changed) + out.BlastRadiusFiles = sortedKeys(blastRadius) + + // all_relevant = sorted(set(changed) | blast_radius) + union := make(map[string]struct{}, len(changedSet)+len(blastRadius)) + for f := range changedSet { + union[f] = struct{}{} + } + for f := range blastRadius { + union[f] = struct{}{} + } + out.AllRelevantFiles = sortedKeys(union) + return out +} + +// skipDirs and skipExtensions are diff_analysis.py's tuples, in order. +var ( + skipDirs = []string{"tests/", "test/", "vendor/", "node_modules/", ".git/", "__pycache__/"} + skipExtensions = []string{".md", ".txt", ".yml", ".yaml", ".json", ".toml", ".cfg", ".ini", ".lock"} +) + +// IsScannable reports whether a file should be included in security scanning. +// +// Ports diff_analysis.py _is_scannable. Exported because the orchestrator's +// PR-mode path and the hunt include_paths plumbing want the same predicate; +// Python keeps it private only because the module is self-contained. +// +// Python parity, two quirks worth stating out loud: +// +// - The directory test is str.startswith, NOT a path-segment containment +// test. "src/tests/x.py" is scannable; only a TOP-LEVEL tests/ dir is +// skipped. ("atests/x.py" is likewise scannable — the prefix is "tests/", +// with the slash.) +// - The extension test is str.endswith and therefore case-sensitive: +// "a.lock" is skipped, "a.LOCK" is not. +func IsScannable(filePath string) bool { + for _, dir := range skipDirs { + if strings.HasPrefix(filePath, dir) { + return false + } + } + for _, ext := range skipExtensions { + if strings.HasSuffix(filePath, ext) { + return false + } + } + return true +} + +// FileToModule converts a file path to the importable module name used for the +// blast-radius grep. +// +// Ports diff_analysis.py _file_to_module. Exported for the same reason as +// IsScannable. +// +// Python parity — the three branches are literal transliterations, quirks +// included (VERIFIED against the venv interpreter): +// +// "src/service/user.py" -> "user" (dots, drop ".py", last segment) +// "src/service/user.test.py" -> "test" (same, so an infix dot wins) +// "foo..py" -> "" (empty last segment; callers skip it) +// "a.js.ts" -> "a" (".ts" stripped, then ".js") +// "a.ts.js" -> "a.ts" (".ts" does not match, ".js" does) +// "a/b.tar.gz" -> "b" (fallback: basename up to first dot) +// "a/.hidden" -> "" (fallback on a dotfile) +func FileToModule(filePath string) string { + if strings.HasSuffix(filePath, ".py") { + dotted := strings.ReplaceAll(filePath, "/", ".") + dotted = strings.TrimSuffix(dotted, ".py") + parts := strings.Split(dotted, ".") + return parts[len(parts)-1] + } + if strings.HasSuffix(filePath, ".ts") || strings.HasSuffix(filePath, ".js") { + base := lastSegment(filePath, "/") + base = strings.TrimSuffix(base, ".ts") + base = strings.TrimSuffix(base, ".js") + return base + } + base := lastSegment(filePath, "/") + return strings.Split(base, ".")[0] +} + +// lastSegment is Python's s.split(sep)[-1]. +func lastSegment(s, sep string) string { + if i := strings.LastIndex(s, sep); i >= 0 { + return s[i+len(sep):] + } + return s +} + +// splitLines reproduces Python's str.splitlines() for the separators git can +// realistically emit. +// +// Python parity: splitlines() splits on "\r\n", "\n" and "\r" (plus \v, \f, +// \x1c-\x1e, \x85, U+2028 and U+2029 — none of which appear in `git diff +// --name-only` or `git grep -l` output, and which are deliberately NOT handled +// here) and produces NO trailing empty element for text ending in a separator. +// strings.Split on "\n" would leave a trailing "" and keep a "\r" on every line +// of CRLF output; both call sites drop empty lines anyway, but the stray "\r" +// would corrupt a path. +func splitLines(s string) []string { + if s == "" { + return nil + } + var out []string + start := 0 + for i := 0; i < len(s); i++ { + switch s[i] { + case '\n': + out = append(out, s[start:i]) + start = i + 1 + case '\r': + out = append(out, s[start:i]) + if i+1 < len(s) && s[i+1] == '\n' { + i++ + } + start = i + 1 + } + } + if start < len(s) { + out = append(out, s[start:]) + } + return out +} + +// sortedCopy is Python's sorted(list) — a new, ascending, non-nil slice. +func sortedCopy(in []string) []string { + out := make([]string, len(in)) + copy(out, in) + sort.Strings(out) + return out +} + +// sortedKeys is Python's sorted(set) — ascending, non-nil even when empty +// (`blast_radius_files` has default_factory=list, never None). +func sortedKeys(set map[string]struct{}) []string { + out := make([]string, 0, len(set)) + for k := range set { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/go/internal/diffanalysis/diffanalysis_test.go b/go/internal/diffanalysis/diffanalysis_test.go new file mode 100644 index 0000000..c4a5735 --- /dev/null +++ b/go/internal/diffanalysis/diffanalysis_test.go @@ -0,0 +1,497 @@ +package diffanalysis + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// scriptGit swaps the package's git seam for the duration of one test — the Go +// equivalent of `monkeypatch.setattr("sec_af.diff_analysis.subprocess.run", ...)`. +func scriptGit(t *testing.T, fn func(ctx context.Context, dir string, argv []string) (string, error)) { + t.Helper() + prev := gitRunner + gitRunner = fn + t.Cleanup(func() { gitRunner = prev }) +} + +// argvHead is the Go form of the Python fake's `command[:2]` dispatch. +func argvHead(argv []string, n int) string { + if len(argv) < n { + n = len(argv) + } + return strings.Join(argv[:n], " ") +} + +// TestAnalyzeDiffCollectsChangedAndBlastRadius ports +// tests/test_diff_analysis.py::test_analyze_diff_collects_changed_and_blast_radius. +func TestAnalyzeDiffCollectsChangedAndBlastRadius(t *testing.T) { + scriptGit(t, func(_ context.Context, _ string, argv []string) (string, error) { + switch argvHead(argv, 2) { + case "git diff": + return "src/service/user.py\nREADME.md\n", nil + case "git grep": + return "HEAD:src/api/users.py\nHEAD:tests/test_users.py\n", nil + } + t.Fatalf("unexpected command: %v", argv) + return "", nil + }) + + analysis := AnalyzeDiff(context.Background(), "/tmp/repo", "base-sha", "head-sha") + + if analysis.BaseSHA != "base-sha" { + t.Errorf("BaseSHA = %q, want %q", analysis.BaseSHA, "base-sha") + } + if analysis.HeadSHA != "head-sha" { + t.Errorf("HeadSHA = %q, want %q", analysis.HeadSHA, "head-sha") + } + // README.md is filtered by the .md extension rule; tests/test_users.py by + // the tests/ prefix rule. + if want := []string{"src/service/user.py"}; !reflect.DeepEqual(analysis.ChangedFiles, want) { + t.Errorf("ChangedFiles = %v, want %v", analysis.ChangedFiles, want) + } + if want := []string{"src/api/users.py"}; !reflect.DeepEqual(analysis.BlastRadiusFiles, want) { + t.Errorf("BlastRadiusFiles = %v, want %v", analysis.BlastRadiusFiles, want) + } + if want := []string{"src/api/users.py", "src/service/user.py"}; !reflect.DeepEqual(analysis.AllRelevantFiles, want) { + t.Errorf("AllRelevantFiles = %v, want %v", analysis.AllRelevantFiles, want) + } + if analysis.FileCount() != 2 { + t.Errorf("FileCount() = %d, want 2", analysis.FileCount()) + } +} + +// TestAnalyzeDiffReturnsEmptyOnGitFailure ports +// tests/test_diff_analysis.py::test_analyze_diff_returns_empty_on_git_failure — +// Python's `raise OSError("git not available")` from the patched subprocess.run. +func TestAnalyzeDiffReturnsEmptyOnGitFailure(t *testing.T) { + scriptGit(t, func(context.Context, string, []string) (string, error) { + return "", errors.New("git not available") + }) + + analysis := AnalyzeDiff(context.Background(), "/tmp/repo", "base-sha", DefaultHeadSHA) + + if analysis.BaseSHA != "base-sha" { + t.Errorf("BaseSHA = %q, want %q", analysis.BaseSHA, "base-sha") + } + if analysis.HeadSHA != "HEAD" { + t.Errorf("HeadSHA = %q, want %q", analysis.HeadSHA, "HEAD") + } + if len(analysis.ChangedFiles) != 0 || len(analysis.BlastRadiusFiles) != 0 || len(analysis.AllRelevantFiles) != 0 { + t.Fatalf("expected an empty analysis, got %+v", analysis) + } + // The dataclass fields have default_factory=list, so they must marshal as + // [] rather than null even on the failure path. + b, err := json.Marshal(analysis) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(b), "null") { + t.Errorf("empty analysis marshaled with a null list: %s", b) + } +} + +// TestAnalyzeDiffEmptyDiffSkipsGrep proves the `if not changed: return` early +// exit: no blast-radius grep runs at all when every changed path is filtered. +func TestAnalyzeDiffEmptyDiffSkipsGrep(t *testing.T) { + var argvs [][]string + scriptGit(t, func(_ context.Context, _ string, argv []string) (string, error) { + argvs = append(argvs, argv) + if argvHead(argv, 2) == "git diff" { + return "README.md\ndocs/x.md\ntests/a.py\n\n", nil + } + t.Fatalf("grep must not run when nothing changed: %v", argv) + return "", nil + }) + + analysis := AnalyzeDiff(context.Background(), "/tmp/repo", "base", "head") + + if len(argvs) != 1 { + t.Fatalf("expected exactly one git invocation, got %d: %v", len(argvs), argvs) + } + if analysis.FileCount() != 0 { + t.Errorf("FileCount() = %d, want 0", analysis.FileCount()) + } +} + +// TestAnalyzeDiffGitInvocations pins the exact argv of both git calls — the +// port's contract with git, and the thing a "cleanup" refactor would silently +// change. +func TestAnalyzeDiffGitInvocations(t *testing.T) { + var argvs [][]string + var dirs []string + scriptGit(t, func(_ context.Context, dir string, argv []string) (string, error) { + argvs = append(argvs, argv) + dirs = append(dirs, dir) + if argvHead(argv, 2) == "git diff" { + return "src/a.py\n", nil + } + return "", nil + }) + + AnalyzeDiff(context.Background(), "/repo/root", "BASE", "HEADREV") + + wantDiff := []string{"git", "diff", "--name-only", "--diff-filter=ACMR", "BASE", "HEADREV"} + wantGrep := []string{"git", "grep", "-l", "a", "HEADREV", "--", "*.py", "*.ts", "*.js", "*.go", "*.java", "*.rb"} + if len(argvs) != 2 { + t.Fatalf("expected 2 git invocations, got %d: %v", len(argvs), argvs) + } + if !reflect.DeepEqual(argvs[0], wantDiff) { + t.Errorf("diff argv = %v, want %v", argvs[0], wantDiff) + } + if !reflect.DeepEqual(argvs[1], wantGrep) { + t.Errorf("grep argv = %v, want %v", argvs[1], wantGrep) + } + for i, dir := range dirs { + if dir != "/repo/root" { + t.Errorf("invocation %d ran in %q, want %q", i, dir, "/repo/root") + } + } +} + +// TestAnalyzeDiffGrepFailureIsSkipped covers the `except ...: continue` arm: +// one failing grep must not abort the others or the whole analysis. +func TestAnalyzeDiffGrepFailureIsSkipped(t *testing.T) { + scriptGit(t, func(_ context.Context, _ string, argv []string) (string, error) { + if argvHead(argv, 2) == "git diff" { + return "src/a.py\nsrc/b.py\n", nil + } + if argv[3] == "a" { // the module name for src/a.py + return "", errors.New("boom") + } + return "HEAD:src/uses_b.py\n", nil + }) + + analysis := AnalyzeDiff(context.Background(), "/tmp/repo", "base", "HEAD") + + if want := []string{"src/a.py", "src/b.py"}; !reflect.DeepEqual(analysis.ChangedFiles, want) { + t.Errorf("ChangedFiles = %v, want %v", analysis.ChangedFiles, want) + } + if want := []string{"src/uses_b.py"}; !reflect.DeepEqual(analysis.BlastRadiusFiles, want) { + t.Errorf("BlastRadiusFiles = %v, want %v", analysis.BlastRadiusFiles, want) + } +} + +// TestAnalyzeDiffSkipsEmptyModuleNames proves the `if not module_name: continue` +// guard: "foo..py" yields an empty module name, so no grep runs for it. +func TestAnalyzeDiffSkipsEmptyModuleNames(t *testing.T) { + var grepModules []string + scriptGit(t, func(_ context.Context, _ string, argv []string) (string, error) { + if argvHead(argv, 2) == "git diff" { + return "foo..py\nsrc/real.py\n", nil + } + grepModules = append(grepModules, argv[3]) + return "", nil + }) + + AnalyzeDiff(context.Background(), "/tmp/repo", "base", "HEAD") + + if want := []string{"real"}; !reflect.DeepEqual(grepModules, want) { + t.Errorf("grep module names = %v, want %v", grepModules, want) + } +} + +// TestAnalyzeDiffGrepLineWithoutColon covers `line.split(":", 1)[1] if ":" in +// line else line` — `git grep -l` without a tree-ish prints a bare path. +func TestAnalyzeDiffGrepLineWithoutColon(t *testing.T) { + scriptGit(t, func(_ context.Context, _ string, argv []string) (string, error) { + if argvHead(argv, 2) == "git diff" { + return "src/a.py\n", nil + } + return "src/plain.py\nHEAD:src/prefixed.py\n", nil + }) + + analysis := AnalyzeDiff(context.Background(), "/tmp/repo", "base", "HEAD") + + want := []string{"src/plain.py", "src/prefixed.py"} + if !reflect.DeepEqual(analysis.BlastRadiusFiles, want) { + t.Errorf("BlastRadiusFiles = %v, want %v", analysis.BlastRadiusFiles, want) + } +} + +// TestAnalyzeDiffDeduplicatesBlastRadius proves the set semantics of +// `blast_radius: set[str]` — the same hit reported for two changed files +// appears once — and that a changed file is never its own blast radius. +func TestAnalyzeDiffDeduplicatesBlastRadius(t *testing.T) { + scriptGit(t, func(_ context.Context, _ string, argv []string) (string, error) { + if argvHead(argv, 2) == "git diff" { + return "src/a.py\nsrc/b.py\n", nil + } + return "HEAD:src/shared.py\nHEAD:src/a.py\nHEAD:src/b.py\n", nil + }) + + analysis := AnalyzeDiff(context.Background(), "/tmp/repo", "base", "HEAD") + + if want := []string{"src/shared.py"}; !reflect.DeepEqual(analysis.BlastRadiusFiles, want) { + t.Errorf("BlastRadiusFiles = %v, want %v", analysis.BlastRadiusFiles, want) + } + if want := []string{"src/a.py", "src/b.py", "src/shared.py"}; !reflect.DeepEqual(analysis.AllRelevantFiles, want) { + t.Errorf("AllRelevantFiles = %v, want %v", analysis.AllRelevantFiles, want) + } +} + +// TestIsScannable is a table of the ground truth produced by running +// `sec_af.diff_analysis._is_scannable` under +// ~/.agentfield/packages/sec-af/venv/bin/python. +func TestIsScannable(t *testing.T) { + cases := []struct { + path string + want bool + }{ + {"src/service/user.py", true}, + {"README.md", false}, + {"a/b.tar.gz", true}, + {".hidden", true}, + {"a/.hidden", true}, + {"foo..py", true}, + {"main.go", true}, + {"Makefile", true}, + {"x/y", true}, + {"", true}, + {"tests/test_x.py", false}, + {"test/x.py", false}, + {"vendor/x.go", false}, + {"node_modules/x.js", false}, + {".git/config", false}, + {"__pycache__/x.pyc", false}, + // Python parity: startswith, not a path-segment test. + {"atests/x.py", true}, + {"src/tests/x.py", true}, + // Python parity: endswith is case-sensitive. + {"a.lock", false}, + {"a.LOCK", true}, + {"a.Yaml", true}, + {"pkg.toml", false}, + {"conf.cfg", false}, + {"conf.ini", false}, + {"data.json", false}, + {"data.yml", false}, + {"data.yaml", false}, + {"notes.txt", false}, + } + for _, tc := range cases { + if got := IsScannable(tc.path); got != tc.want { + t.Errorf("IsScannable(%q) = %v, want %v", tc.path, got, tc.want) + } + } +} + +// TestFileToModule is a table of the ground truth produced by running +// `sec_af.diff_analysis._file_to_module` under the venv interpreter. +func TestFileToModule(t *testing.T) { + cases := []struct{ path, want string }{ + {"src/service/user.py", "user"}, + {"README.md", "README"}, + {"a/b.tar.gz", "b"}, + {".hidden", ""}, + {"a/.hidden", ""}, + {"foo..py", ""}, + {"a.js.ts", "a"}, + {"a.ts.js", "a.ts"}, + {"lib/x.ts", "x"}, + {"lib/x.js", "x"}, + {"main.go", "main"}, + {"Makefile", "Makefile"}, + {"src/service/user.test.py", "test"}, + {"x/y", "y"}, + {"", ""}, + {"tests/test_x.py", "test_x"}, + {".git/config", "config"}, + } + for _, tc := range cases { + if got := FileToModule(tc.path); got != tc.want { + t.Errorf("FileToModule(%q) = %q, want %q", tc.path, got, tc.want) + } + } +} + +// TestSplitLines pins the Python-splitlines() behavior the two parsers depend +// on: no trailing empty element, and CRLF handled without leaving a stray \r on +// the path. +func TestSplitLines(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"", nil}, + {"a\n", []string{"a"}}, + {"a\nb\n", []string{"a", "b"}}, + {"a\nb", []string{"a", "b"}}, + {"a\r\nb\r\n", []string{"a", "b"}}, + {"a\rb\r", []string{"a", "b"}}, + {"\n", []string{""}}, + {"a\n\nb\n", []string{"a", "", "b"}}, + } + for _, tc := range cases { + if got := splitLines(tc.in); !reflect.DeepEqual(got, tc.want) { + t.Errorf("splitLines(%q) = %#v, want %#v", tc.in, got, tc.want) + } + } +} + +// TestNewDiffAnalysisDefaults pins the dataclass field defaults. +func TestNewDiffAnalysisDefaults(t *testing.T) { + d := NewDiffAnalysis() + if d.BaseSHA != "" { + t.Errorf("BaseSHA = %q, want empty", d.BaseSHA) + } + if d.HeadSHA != "HEAD" { + t.Errorf("HeadSHA = %q, want %q", d.HeadSHA, "HEAD") + } + if d.ChangedFiles == nil || d.BlastRadiusFiles == nil || d.AllRelevantFiles == nil { + t.Fatalf("default_factory=list fields must be non-nil: %+v", d) + } + if d.FileCount() != 0 { + t.Errorf("FileCount() = %d, want 0", d.FileCount()) + } +} + +// --------------------------------------------------------------------------- +// Real-git end-to-end coverage +// --------------------------------------------------------------------------- + +// gitExec runs a git command in dir with a hermetic environment (no user or +// system config, deterministic identity) and fails the test on error. +func gitExec(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_AUTHOR_NAME=sec-af", "GIT_AUTHOR_EMAIL=sec-af@example.com", + "GIT_COMMITTER_NAME=sec-af", "GIT_COMMITTER_EMAIL=sec-af@example.com", + "GIT_TERMINAL_PROMPT=0", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } +} + +func writeFile(t *testing.T, dir, rel, body string) { + t.Helper() + full := filepath.Join(dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", full, err) + } + if err := os.WriteFile(full, []byte(body), 0o644); err != nil { + t.Fatalf("write %s: %v", full, err) + } +} + +// TestAnalyzeDiffAgainstRealGitRepo drives the UNPATCHED runGit against a +// throwaway repository, so the argv this package builds is validated against +// real git semantics (the `:` grep prefix, the `-- *.py` +// pathspecs matching nested paths, `--diff-filter=ACMR` selecting adds and +// modifications). +func TestAnalyzeDiffAgainstRealGitRepo(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + repo := t.TempDir() + gitExec(t, repo, "init", "--quiet", "-b", "main") + + // Base commit: an importer and an unrelated test file already reference the + // module the next commit will touch. + writeFile(t, repo, "src/api/users.py", "from src.service.user import lookup\n") + writeFile(t, repo, "tests/test_users.py", "from src.service.user import lookup\n") + writeFile(t, repo, "docs/user.md", "user docs\n") + gitExec(t, repo, "add", ".") + gitExec(t, repo, "commit", "--quiet", "-m", "base") + + // Head commit: add the module itself plus a filtered-out README change. + writeFile(t, repo, "src/service/user.py", "def lookup():\n return None\n") + writeFile(t, repo, "README.md", "# readme\n") + gitExec(t, repo, "add", ".") + gitExec(t, repo, "commit", "--quiet", "-m", "head") + + analysis := AnalyzeDiff(context.Background(), repo, "HEAD~1", "HEAD") + + if want := []string{"src/service/user.py"}; !reflect.DeepEqual(analysis.ChangedFiles, want) { + t.Errorf("ChangedFiles = %v, want %v", analysis.ChangedFiles, want) + } + // docs/user.md is not a grep pathspec match (*.py/*.ts/*.js/*.go/*.java/*.rb) + // and would be filtered by the .md rule anyway; tests/test_users.py matches + // the grep but is filtered by the tests/ rule. + if want := []string{"src/api/users.py"}; !reflect.DeepEqual(analysis.BlastRadiusFiles, want) { + t.Errorf("BlastRadiusFiles = %v, want %v", analysis.BlastRadiusFiles, want) + } + if want := []string{"src/api/users.py", "src/service/user.py"}; !reflect.DeepEqual(analysis.AllRelevantFiles, want) { + t.Errorf("AllRelevantFiles = %v, want %v", analysis.AllRelevantFiles, want) + } + if analysis.FileCount() != 2 { + t.Errorf("FileCount() = %d, want 2", analysis.FileCount()) + } +} + +// TestAnalyzeDiffRealGitBadRevision proves the check=False contract end to end: +// `git diff` against a revision that does not exist exits non-zero, and Python +// reads its empty stdout rather than raising — so the result is an empty +// analysis, not a crash. +func TestAnalyzeDiffRealGitBadRevision(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + repo := t.TempDir() + gitExec(t, repo, "init", "--quiet", "-b", "main") + writeFile(t, repo, "src/a.py", "x = 1\n") + gitExec(t, repo, "add", ".") + gitExec(t, repo, "commit", "--quiet", "-m", "only") + + analysis := AnalyzeDiff(context.Background(), repo, "does-not-exist", "HEAD") + + if analysis.FileCount() != 0 { + t.Fatalf("expected an empty analysis, got %+v", analysis) + } + if analysis.BaseSHA != "does-not-exist" || analysis.HeadSHA != "HEAD" { + t.Errorf("SHAs not carried through: %+v", analysis) + } +} + +// TestRunGitMissingBinaryIsAnError proves the OSError arm of runGit: a binary +// that cannot be spawned is an error (not an empty success), which AnalyzeDiff +// turns into the empty analysis. +func TestRunGitMissingBinaryIsAnError(t *testing.T) { + _, err := runGit(context.Background(), t.TempDir(), []string{"sec-af-no-such-binary-xyz", "diff"}) + if err == nil { + t.Fatal("expected an error for a missing binary") + } +} + +// TestRunGitNonZeroExitIsNotAnError proves the check=False arm of runGit +// directly, without git: a command that prints to stdout and exits 1 yields +// (stdout, nil). +func TestRunGitNonZeroExitIsNotAnError(t *testing.T) { + sh, err := exec.LookPath("sh") + if err != nil { + t.Skip("sh not on PATH") + } + out, err := runGit(context.Background(), t.TempDir(), []string{sh, "-c", "printf 'a\\nb\\n'; exit 1"}) + if err != nil { + t.Fatalf("non-zero exit must not be an error, got %v", err) + } + if out != "a\nb\n" { + t.Errorf("stdout = %q, want %q", out, "a\nb\n") + } +} + +// TestRunGitCancelledContextIsAnError proves the TimeoutExpired arm: a +// cancelled context surfaces as an error, so AnalyzeDiff degrades rather than +// treating a killed git's empty stdout as "nothing changed". +func TestRunGitCancelledContextIsAnError(t *testing.T) { + sh, err := exec.LookPath("sh") + if err != nil { + t.Skip("sh not on PATH") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := runGit(ctx, t.TempDir(), []string{sh, "-c", "echo hi"}); err == nil { + t.Fatal("expected an error for a cancelled context") + } +} diff --git a/go/internal/gates/aigate.go b/go/internal/gates/aigate.go new file mode 100644 index 0000000..a61923b --- /dev/null +++ b/go/internal/gates/aigate.go @@ -0,0 +1,268 @@ +package gates + +import ( + "context" + "sync" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + + "github.com/Agent-Field/sec-af/go/internal/aix" + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/config" + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// AIGate ports harness.py AIGateWrapper — the retrying front door to +// `app.ai(system=..., user=..., schema=Model, model=config.ai_model)`. +// +// Python: +// +// class AIGateWrapper(_RetryMixin): +// def __init__(self, app, config=None): +// self.app = app +// self.config = config or AIIntegrationConfig.from_env() +// self._cost_tracker = _CostTracker() +// +// The Go value is safe for concurrent use: the orchestrator's +// _assess_reachability_parallel fans AssessReachability out under a +// semaphore(min(5, n)), so the cost tracker is mutex-guarded and every +// invocation gets its own response capture. +type AIGate struct { + // App is the `.ai(...)` seam. Only AIer is required, which lets a caller + // pass the live *agent.Agent, an appx.Fake, or the orchestrator's + // budget-checking proxy. + App appx.AIer + // Config supplies ai_model plus the retry schedule. + Config config.AIIntegrationConfig + // Sleep is the backoff sleeper; nil means the real clock. Tests set it to + // record the schedule without waiting. + Sleep Sleeper + + mu sync.Mutex + totalCostUSD float64 + invocationCount int +} + +// NewAIGate is `AIGateWrapper(app=app, config=config)`. +// +// Python parity: `config or AIIntegrationConfig.from_env()` runs from_env when +// no config is supplied, and from_env can FAIL in Go (a malformed +// SEC_AF_AI_MAX_RETRIES aborts Python at import time). Callers that already +// hold a config — every live one does, since node/orchestrator build it once at +// boot — should use the struct literal instead and skip the error return. +func NewAIGate(app appx.AIer, cfg *config.AIIntegrationConfig) (*AIGate, error) { + if cfg != nil { + return &AIGate{App: app, Config: *cfg}, nil + } + resolved, err := config.AIConfigFromEnv() + if err != nil { + return nil, err + } + return &AIGate{App: app, Config: resolved}, nil +} + +// TotalCostUSD ports the `total_cost_usd` property. +func (g *AIGate) TotalCostUSD() float64 { + g.mu.Lock() + defer g.mu.Unlock() + return g.totalCostUSD +} + +// InvocationCount ports the `invocation_count` property. +func (g *AIGate) InvocationCount() int { + g.mu.Lock() + defer g.mu.Unlock() + return g.invocationCount +} + +// registerInvocation ports _CostTracker.register_invocation. +func (g *AIGate) registerInvocation() { + g.mu.Lock() + g.invocationCount++ + g.mu.Unlock() +} + +// registerCost ports _CostTracker.register_cost: +// +// if cost_usd is None or cost_usd < 0: return +// self.total_cost_usd += cost_usd +func (g *AIGate) registerCost(costUSD *float64) { + if costUSD == nil || *costUSD < 0 { + return + } + g.mu.Lock() + g.totalCostUSD += *costUSD + g.mu.Unlock() +} + +// sleeper resolves the injected Sleeper or the real one. +func (g *AIGate) sleeper() Sleeper { + if g.Sleep != nil { + return g.Sleep + } + return sleepReal +} + +// responseCapture wraps an appx.AIer to keep the last *ai.Response the SDK +// returned, so Invoke can read its cost. One capture is allocated per Invoke +// call, which is what makes concurrent gate use race-free. +type responseCapture struct { + inner appx.AIer + resp *ai.Response +} + +func (c *responseCapture) AI(ctx context.Context, prompt string, opts ...ai.Option) (*ai.Response, error) { + resp, err := c.inner.AI(ctx, prompt, opts...) + if resp != nil { + c.resp = resp + } + return resp, err +} + +// Invoke ports AIGateWrapper.invoke: +// +// self._cost_tracker.register_invocation() +// async def _operation(): +// return await self.app.ai(system=system, user=user, schema=schema, model=self.config.ai_model) +// result = await self._run_with_retry(_operation, self.config) +// self._cost_tracker.register_cost(getattr(result, "cost_usd", None)) +// if isinstance(result, schema): return result +// if isinstance(result, dict): return schema(**result) +// raise AIIntegrationError(f"AI gate returned invalid payload for schema {schema.__name__}") +// +// It is a package-level function rather than a method because Go methods cannot +// carry their own type parameter, and the schema is exactly that. +// +// Python parity notes: +// +// - register_invocation fires ONCE per Invoke, before the first attempt — a +// retried call still counts as one invocation. +// - `system=None` is spelled as the empty string here; aix.Structured omits +// the system message for it, matching the Python SDK, which drops a None +// system. +// - The two isinstance branches collapse into aix.Structured's typed return: +// the Go SDK hands back raw text, aix parses it into T, and a parse failure +// is an error — the same outcome as the trailing `raise`. The dict branch +// exists in Python only because the SDK may hand back an unparsed dict. +// - COST: this is a DELIBERATE, design-doc-mandated divergence. Python reads +// `getattr(result, "cost_usd", None)` off the PARSED PYDANTIC MODEL the SDK +// returns for a `schema=` call (agent_ai.py:898 `return schema(**json_data)`), +// and no gate schema has a cost_usd field — so AIGateWrapper.total_cost_usd +// is permanently 0.0 in the Python node. DESIGN.md §2/§3 specifies the Go +// port take the cost from the SDK response's Usage.Cost instead, which is +// where the real number lives. Everything else about the tracker +// (invocation counting, the negative/None guard, the += accumulation) is +// byte-for-byte Python. +func Invoke[T any](ctx context.Context, g *AIGate, user, system string) (T, error) { + g.registerInvocation() + + capture := &responseCapture{inner: g.App} + operation := func() (T, error) { + return aix.StructuredOpts[T](ctx, capture, system, user, ai.WithModel(g.Config.AIModel)) + } + + result, err := runWithRetry(ctx, g.Config, g.sleeper(), operation) + if err != nil { + var zero T + return zero, err + } + if capture.resp != nil && capture.resp.Usage != nil { + g.registerCost(capture.resp.Usage.Cost) + } + return result, nil +} + +// ClassifySeverity ports AIGateWrapper.classify_severity. +// +// The prompt is three adjacent string literals plus the summary after a blank +// line; it is reproduced byte-for-byte, including the trailing space after +// "keep rationale brief." coming from the implicit concatenation. +func (g *AIGate) ClassifySeverity(ctx context.Context, findingSummary string) (schemas.SeverityClassification, error) { + prompt := "Classify severity for this potential security finding. " + + "Use only critical/high/medium/low and keep rationale brief.\n\n" + + findingSummary + return Invoke[schemas.SeverityClassification](ctx, g, prompt, "") +} + +// CheckDuplicate ports AIGateWrapper.check_duplicate: +// +// prompt = ("Decide whether candidate finding is a duplicate of existing finding. " +// "Return duplicate decision only.\n\n" +// f"Candidate: {candidate}\n" +// f"Existing: {existing}") +// +// `candidate` and `existing` are `dict[str, Any]` in Python and land in the +// prompt through an f-string, i.e. `str(dict)` — which is `repr(dict)`, the +// `{'key': 'value'}` spelling with SINGLE quotes and `True`/`False`/`None` +// literals, rendered in the dict's INSERTION order. +// +// They are typed `any` here so a caller can pass a pyfmt.Ordered built in that +// insertion order and get byte-identical output. A plain map[string]any also +// works but renders with SORTED keys (pyfmt.Repr's documented deviation), which +// is only a difference when the Python dict was not already in sorted order. +// +// Python parity: check_duplicate has NO caller in the Python tree — +// agents/dedup.py does its semantic duplicate pass with a direct +// `app.ai(..., schema=DuplicateCheck)` rather than through the gate. It is +// ported for completeness. +func (g *AIGate) CheckDuplicate(ctx context.Context, candidate, existing any) (schemas.DuplicateCheck, error) { + prompt := "Decide whether candidate finding is a duplicate of existing finding. " + + "Return duplicate decision only.\n\n" + + "Candidate: " + pyfmt.Str(candidate) + "\n" + + "Existing: " + pyfmt.Str(existing) + return Invoke[schemas.DuplicateCheck](ctx, g, prompt, "") +} + +// SelectStrategy ports AIGateWrapper.select_strategy: +// +// prompt = ("Select SEC-AF hunt strategies from recon context. Return only selected strategies and rationale.\n" +// f"Depth profile: {depth}\n" +// f"Default candidates: {default_candidates}\n" +// f"Recon summary: {recon_summary}") +// +// Python parity: `default_candidates` is a `list[str]` interpolated by an +// f-string, so it renders as a Python LIST REPR — +// `['injection', 'auth', 'crypto']`, single quotes and ", " separators, `[]` +// when empty. pyfmt.Repr produces exactly that. This is the live call site +// (reasoners/phases.py:262 passes `[s.value for s in default_candidates]`). +func (g *AIGate) SelectStrategy(ctx context.Context, reconSummary, depth string, defaultCandidates []string) (schemas.StrategySelection, error) { + prompt := "Select SEC-AF hunt strategies from recon context. Return only selected strategies and rationale.\n" + + "Depth profile: " + depth + "\n" + + "Default candidates: " + pyfmt.Repr(defaultCandidates) + "\n" + + "Recon summary: " + reconSummary + return Invoke[schemas.StrategySelection](ctx, g, prompt, "") +} + +// AssessReachability ports AIGateWrapper.assess_reachability — the gate the +// orchestrator actually leans on (orchestrator.py:587, inside +// _assess_reachability_parallel). +func (g *AIGate) AssessReachability(ctx context.Context, findingSummary string) (schemas.ReachabilityGate, error) { + prompt := "Assess the reachability of this security finding. " + + "Determine if it is externally_reachable, requires_auth, internal_only, or unreachable. " + + "Consider the attack surface, authentication requirements, and network exposure.\n\n" + + findingSummary + return Invoke[schemas.ReachabilityGate](ctx, g, prompt, "") +} + +// BuildAIIntegration ports harness.py build_ai_integration: +// +// resolved = config or AIIntegrationConfig.from_env() +// return HarnessWrapper(app=app, config=resolved), AIGateWrapper(app=app, config=resolved) +// +// Both wrappers share ONE resolved config instance in Python; they share one +// value here. Nothing in the Python tree calls this function — orchestrator.py +// constructs AIGateWrapper directly — so it exists for completeness. +func BuildAIIntegration(app appx.App, cfg *config.AIIntegrationConfig) (*HarnessWrapper, *AIGate, error) { + resolved := config.AIIntegrationConfig{} + if cfg != nil { + resolved = *cfg + } else { + fromEnv, err := config.AIConfigFromEnv() + if err != nil { + return nil, nil, err + } + resolved = fromEnv + } + return &HarnessWrapper{App: app, Config: resolved}, &AIGate{App: app, Config: resolved}, nil +} diff --git a/go/internal/gates/aigate_test.go b/go/internal/gates/aigate_test.go new file mode 100644 index 0000000..c2c887b --- /dev/null +++ b/go/internal/gates/aigate_test.go @@ -0,0 +1,319 @@ +package gates + +// Parity tests for harness.py AIGateWrapper. +// +// The four prompt builders are pinned against committed goldens produced by +// go/scripts/gen_golden.py from the same f-strings the Python methods use +// (CLASSIFY_SEVERITY_SUMMARY / CHECK_DUPLICATE_* / SELECT_STRATEGY_* / +// ASSESS_REACHABILITY_SUMMARY there). + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/config" + "github.com/Agent-Field/sec-af/go/internal/pyfmt" +) + +// The inputs, mirroring gen_golden.py. +const ( + classifySeveritySummary = "SQL injection in app/db/raw.py:42 — request.args['q'] reaches cursor.execute unsanitized." + selectStrategySummary = "General recon summary.\n\nProfile: 3 files, 120 LOC." + assessReachabilitySummary = "Hardcoded AWS key in config/prod.yaml:12, repository is public." +) + +// checkDuplicateCandidate / Existing mirror gen_golden.py's dicts. They are +// pyfmt.Ordered, not maps, because the Python f-string renders the dict in +// INSERTION order and these two are not alphabetically ordered. +var ( + checkDuplicateCandidate = pyfmt.O( + "id", "finding-1", + "file_path", "app/db/raw.py", + "start_line", 42, + "cwe_id", "CWE-89", + "confirmed", true, + "score", 9.5, + "notes", nil, + ) + checkDuplicateExisting = pyfmt.O( + "id", "finding-0", + "file_path", "app/db/raw.py", + "start_line", 41, + "cwe_id", "CWE-89", + "confirmed", false, + "score", 1.0, + "notes", "seen before", + ) +) + +// testConfig is the AIIntegrationConfig every gate test uses: one retry budget +// big enough to observe backoff, and an ai_model the WithModel assertion can +// look for. +func testConfig() config.AIIntegrationConfig { + return config.AIIntegrationConfig{ + AIModel: "minimax/minimax-m2.5", + MaxRetries: 3, + InitialBackoffSeconds: 2.0, + MaxBackoffSeconds: 8.0, + } +} + +// newGate wires an AIGate to a Fake whose AI seam answers with the given JSON, +// and to a sleeper that never waits. +func newGate(t *testing.T, body string) (*AIGate, *appx.Fake) { + t.Helper() + fake := &appx.Fake{ + AIFn: appx.AIJSON(func(string) (json.RawMessage, error) { return json.RawMessage(body), nil }), + } + gate := &AIGate{App: fake, Config: testConfig(), Sleep: func(context.Context, time.Duration) {}} + return gate, fake +} + +// TestAIGatePromptsMatchPython pins the four gate prompts. Each case drives the +// real method through a Fake and compares the prompt the SDK seam received. +func TestAIGatePromptsMatchPython(t *testing.T) { + ctx := context.Background() + + t.Run("classify_severity", func(t *testing.T) { + gate, fake := newGate(t, `{"severity":"high","confidence":0.9,"rationale":"r"}`) + if _, err := gate.ClassifySeverity(ctx, classifySeveritySummary); err != nil { + t.Fatalf("ClassifySeverity: %v", err) + } + assertPrompt(t, fake, golden(t, "ai_gate_classify_severity.txt")) + }) + + t.Run("check_duplicate", func(t *testing.T) { + gate, fake := newGate(t, `{"is_duplicate":false,"duplicate_of":null,"reason":"r"}`) + if _, err := gate.CheckDuplicate(ctx, checkDuplicateCandidate, checkDuplicateExisting); err != nil { + t.Fatalf("CheckDuplicate: %v", err) + } + assertPrompt(t, fake, golden(t, "ai_gate_check_duplicate.txt")) + }) + + t.Run("select_strategy", func(t *testing.T) { + gate, fake := newGate(t, `{"strategies":["injection"],"rationale":"r"}`) + _, err := gate.SelectStrategy(ctx, selectStrategySummary, "standard", []string{"injection", "auth", "crypto"}) + if err != nil { + t.Fatalf("SelectStrategy: %v", err) + } + assertPrompt(t, fake, golden(t, "ai_gate_select_strategy_standard.txt")) + }) + + t.Run("select_strategy with no candidates", func(t *testing.T) { + gate, fake := newGate(t, `{"strategies":[],"rationale":"r"}`) + // Python renders an empty list as "[]", not as "" or "None". + if _, err := gate.SelectStrategy(ctx, selectStrategySummary, "quick", []string{}); err != nil { + t.Fatalf("SelectStrategy: %v", err) + } + assertPrompt(t, fake, golden(t, "ai_gate_select_strategy_empty_candidates.txt")) + }) + + t.Run("assess_reachability", func(t *testing.T) { + gate, fake := newGate(t, `{"reachability":"externally_reachable","rationale":"r","confidence":"high"}`) + if _, err := gate.AssessReachability(ctx, assessReachabilitySummary); err != nil { + t.Fatalf("AssessReachability: %v", err) + } + assertPrompt(t, fake, golden(t, "ai_gate_assess_reachability.txt")) + }) +} + +func assertPrompt(t *testing.T, fake *appx.Fake, want string) { + t.Helper() + if len(fake.AIs) != 1 { + t.Fatalf("expected exactly one .ai() call, got %d", len(fake.AIs)) + } + if got := fake.AIs[0].Prompt; got != want { + t.Errorf("prompt mismatch:\n%s", firstDiff(want, got)) + } +} + +// TestAIGateParsesTheResponse checks the value actually returned, so the +// goldens are not the only thing keeping the methods honest. +func TestAIGateParsesTheResponse(t *testing.T) { + ctx := context.Background() + gate, _ := newGate(t, `{"reachability":"requires_auth","rationale":"behind login","confidence":"medium"}`) + + got, err := gate.AssessReachability(ctx, "summary") + if err != nil { + t.Fatalf("AssessReachability: %v", err) + } + if got.Reachability != "requires_auth" || got.Rationale != "behind login" || got.Confidence != "medium" { + t.Errorf("parsed = %+v", got) + } +} + +// TestAIGatePassesTheConfiguredModel pins `model=self.config.ai_model`, the one +// SDK option AIGateWrapper.invoke sets beyond system/user/schema. +func TestAIGatePassesTheConfiguredModel(t *testing.T) { + ctx := context.Background() + gate, fake := newGate(t, `{"severity":"low","confidence":0.1,"rationale":"r"}`) + + if _, err := gate.ClassifySeverity(ctx, "summary"); err != nil { + t.Fatalf("ClassifySeverity: %v", err) + } + + req := &ai.Request{} + for _, opt := range fake.AIs[0].Opts { + if err := opt(req); err != nil { + t.Fatalf("apply option: %v", err) + } + } + if req.Model != "minimax/minimax-m2.5" { + t.Errorf("Model = %q, want the config's ai_model", req.Model) + } + if req.ResponseFormat == nil || !req.ResponseFormat.JSONSchema.Strict { + t.Error("expected a strict json_schema response format (aix.Structured's WithSchema)") + } + // Python passes system=None for every gate method, so no system message is + // prepended. + for _, message := range req.Messages { + if message.Role == "system" { + t.Error("no gate method passes a system prompt") + } + } +} + +// TestAIGateCostTracking pins _CostTracker: one invocation per Invoke (not per +// retry attempt), costs summed, and None/negative costs ignored. +// +// Python parity: the SOURCE of the cost differs by design. Python reads +// `getattr(result, "cost_usd", None)` off the parsed pydantic model, which +// never has that attribute, so its total is permanently 0.0; DESIGN.md has the +// Go port read the SDK response's Usage.Cost instead. The accumulation rules +// below are Python's. +func TestAIGateCostTracking(t *testing.T) { + ctx := context.Background() + + costs := []*float64{ptr(0.25), nil, ptr(-1.0), ptr(0.5)} + call := 0 + fake := &appx.Fake{ + AIFn: func(context.Context, string, ...ai.Option) (*ai.Response, error) { + resp := &ai.Response{ + Choices: []ai.Choice{{Message: ai.Message{ + Role: "assistant", + Content: []ai.ContentPart{{Type: "text", Text: `{"severity":"low","confidence":0.1,"rationale":"r"}`}}, + }}}, + Usage: &ai.Usage{Cost: costs[call]}, + } + call++ + return resp, nil + }, + } + gate := &AIGate{App: fake, Config: testConfig()} + + for range costs { + if _, err := gate.ClassifySeverity(ctx, "summary"); err != nil { + t.Fatalf("ClassifySeverity: %v", err) + } + } + + if got := gate.InvocationCount(); got != len(costs) { + t.Errorf("InvocationCount = %d, want %d", got, len(costs)) + } + if got := gate.TotalCostUSD(); got != 0.75 { + t.Errorf("TotalCostUSD = %v, want 0.75 (nil and negative costs are ignored)", got) + } +} + +func ptr[T any](v T) *T { return &v } + +// TestAIGateIsConcurrencySafe exercises the shape the orchestrator uses +// (_assess_reachability_parallel fans out under a semaphore), so the cost +// tracker and the per-call response capture are proven race-free under -race. +func TestAIGateIsConcurrencySafe(t *testing.T) { + ctx := context.Background() + cost := 0.1 + fake := &appx.Fake{ + AIFn: func(context.Context, string, ...ai.Option) (*ai.Response, error) { + return &ai.Response{ + Choices: []ai.Choice{{Message: ai.Message{ + Role: "assistant", + Content: []ai.ContentPart{{Type: "text", Text: `{"reachability":"internal_only","rationale":"r","confidence":"low"}`}}, + }}}, + Usage: &ai.Usage{Cost: &cost}, + }, nil + }, + } + gate := &AIGate{App: fake, Config: testConfig()} + + const n = 16 + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := gate.AssessReachability(ctx, "summary"); err != nil { + t.Errorf("AssessReachability: %v", err) + } + }() + } + wg.Wait() + + if got := gate.InvocationCount(); got != n { + t.Errorf("InvocationCount = %d, want %d", got, n) + } + if got := gate.TotalCostUSD(); got < 1.59 || got > 1.61 { + t.Errorf("TotalCostUSD = %v, want ~1.6", got) + } +} + +// TestNewAIGateUsesSuppliedConfig pins `config or AIIntegrationConfig.from_env()`. +func TestNewAIGateUsesSuppliedConfig(t *testing.T) { + cfg := testConfig() + gate, err := NewAIGate(&appx.Fake{}, &cfg) + if err != nil { + t.Fatalf("NewAIGate: %v", err) + } + if gate.Config.AIModel != cfg.AIModel { + t.Errorf("Config not carried through: %+v", gate.Config) + } + + // With no config it reads the environment, which must not fail for a clean + // env. + if _, err := NewAIGate(&appx.Fake{}, nil); err != nil { + t.Fatalf("NewAIGate(nil config): %v", err) + } +} + +// TestBuildAIIntegrationSharesOneConfig pins build_ai_integration's contract: +// both wrappers get the SAME resolved config value. +func TestBuildAIIntegrationSharesOneConfig(t *testing.T) { + cfg := testConfig() + wrapper, gate, err := BuildAIIntegration(&appx.Fake{}, &cfg) + if err != nil { + t.Fatalf("BuildAIIntegration: %v", err) + } + if wrapper.Config != gate.Config { + t.Error("HarnessWrapper and AIGate must share the resolved config") + } + if wrapper.Config.AIModel != cfg.AIModel { + t.Errorf("config not carried through: %+v", wrapper.Config) + } +} + +// TestAIGateSurfacesNonTransientErrors checks that a hard failure propagates +// rather than being retried or swallowed. +func TestAIGateSurfacesNonTransientErrors(t *testing.T) { + ctx := context.Background() + boom := errors.New("bad request: unsupported model") + fake := &appx.Fake{ + AIFn: func(context.Context, string, ...ai.Option) (*ai.Response, error) { return nil, boom }, + } + gate := &AIGate{App: fake, Config: testConfig(), Sleep: func(context.Context, time.Duration) {}} + + if _, err := gate.ClassifySeverity(ctx, "summary"); err == nil { + t.Fatal("expected an error") + } else if !errors.Is(err, boom) { + t.Errorf("error = %v, want it to wrap %v", err, boom) + } + if len(fake.AIs) != 1 { + t.Errorf("a non-transient error must not be retried; got %d attempts", len(fake.AIs)) + } +} diff --git a/go/internal/gates/harnesswrapper.go b/go/internal/gates/harnesswrapper.go new file mode 100644 index 0000000..1ad9bde --- /dev/null +++ b/go/internal/gates/harnesswrapper.go @@ -0,0 +1,348 @@ +package gates + +import ( + "context" + "reflect" + "strconv" + "sync" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + "golang.org/x/sync/semaphore" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/config" + "github.com/Agent-Field/sec-af/go/internal/harnessx" +) + +// maxSchemaRetries ports HarnessWrapper._invoke_with_schema_retry's local +// `max_schema_retries = 3`. +const maxSchemaRetries = 3 + +// HarnessWrapper ports harness.py HarnessWrapper. +// +// NOT ON THE LIVE PATH. Nothing in the Python tree constructs a HarnessWrapper: +// every agent module calls `app.harness(...)` directly and hands the result to +// extract_harness_result (ported as internal/harnessx). The class is ported +// because the port is 1:1 and because its prompt assembly (prompts.go) is +// worth pinning; the invocation machinery below is kept deliberately compact. +type HarnessWrapper struct { + // App is the `.harness(...)` seam. + App appx.Harnesser + // Config supplies provider/model/max_turns/env plus the retry schedule. + Config config.AIIntegrationConfig + // Sleep is the backoff sleeper; nil means the real clock. + Sleep Sleeper + + mu sync.Mutex + totalCostUSD float64 + invocationCount int +} + +// NewHarnessWrapper is `HarnessWrapper(app=app, config=config)`; a nil config +// runs AIIntegrationConfig.from_env(). +func NewHarnessWrapper(app appx.Harnesser, cfg *config.AIIntegrationConfig) (*HarnessWrapper, error) { + if cfg != nil { + return &HarnessWrapper{App: app, Config: *cfg}, nil + } + resolved, err := config.AIConfigFromEnv() + if err != nil { + return nil, err + } + return &HarnessWrapper{App: app, Config: resolved}, nil +} + +// TotalCostUSD ports the `total_cost_usd` property. +func (w *HarnessWrapper) TotalCostUSD() float64 { + w.mu.Lock() + defer w.mu.Unlock() + return w.totalCostUSD +} + +// InvocationCount ports the `invocation_count` property. +func (w *HarnessWrapper) InvocationCount() int { + w.mu.Lock() + defer w.mu.Unlock() + return w.invocationCount +} + +func (w *HarnessWrapper) registerInvocation() { + w.mu.Lock() + w.invocationCount++ + w.mu.Unlock() +} + +// registerCost ports _CostTracker.register_cost. Unlike the AI gate's, this one +// is fed a real number: the Python SDK's HarnessResult carries `cost_usd`, and +// the Go SDK's harness.Result carries CostUSD. +func (w *HarnessWrapper) registerCost(costUSD *float64) { + if costUSD == nil || *costUSD < 0 { + return + } + w.mu.Lock() + w.totalCostUSD += *costUSD + w.mu.Unlock() +} + +func (w *HarnessWrapper) sleeper() Sleeper { + if w.Sleep != nil { + return w.Sleep + } + return sleepReal +} + +// InvokeRequest is the keyword-argument set of HarnessWrapper.invoke: +// +// invoke(*, prompt, schema, cwd, project_dir=None, model=None, +// max_turns=None, max_budget_usd=None, phase=None) +// +// `schema` is the Go type parameter of HarnessInvoke, not a field. The zero +// value of each remaining field is Python's None: an empty Model/ProjectDir/ +// Phase and a zero MaxTurns/MaxBudgetUSD all mean "not supplied", which matches +// the `model or self.config.harness_model` / `max_turns or self.config.max_turns` +// truthiness fallbacks exactly (Python treats 0 as absent too). +type InvokeRequest struct { + Prompt string + Cwd string + ProjectDir string + Model string + MaxTurns int + MaxBudgetUSD float64 + Phase string +} + +// harnessOptions builds the harness.Options for one invocation, reproducing the +// keyword arguments HarnessWrapper passes to app.harness: +// +// provider=self.config.provider, +// model=model or self.config.harness_model, +// max_turns=max_turns or self.config.max_turns, +// max_budget_usd=max_budget_usd, +// env=self.config.provider_env(), +// schema_max_retries=0, +// cwd=cwd, [project_dir=...], [opencode_server=...] +// +// Two divergences, both forced by the SDK surface and both harmless for dead +// code: +// +// - `opencode_server` has no field on harness.Options, so the +// `if self.config.opencode_server: extra_kwargs["opencode_server"] = ...` +// branch cannot be expressed and is dropped. +// - `schema_max_retries=0` disables the SDK's internal schema-retry loop in +// Python. The Go SDK treats a zero Options.SchemaMaxRetries as "unset" and +// substitutes its own default, so the Go node retries where Python would +// not. Reproducing the disable would need a negative sentinel the SDK does +// not define. +func (w *HarnessWrapper) harnessOptions(req InvokeRequest) (harness.Options, error) { + env, err := w.Config.ProviderEnv() + if err != nil { + return harness.Options{}, err + } + model := req.Model + if model == "" { + model = w.Config.HarnessModel + } + maxTurns := req.MaxTurns + if maxTurns == 0 { + maxTurns = w.Config.MaxTurns + } + return harness.Options{ + Provider: w.Config.Provider, + Model: model, + MaxTurns: maxTurns, + MaxBudgetUSD: req.MaxBudgetUSD, + Env: env, + SchemaMaxRetries: 0, + Cwd: req.Cwd, + ProjectDir: req.ProjectDir, + }, nil +} + +// HarnessInvoke ports HarnessWrapper.invoke. +// +// self._cost_tracker.register_invocation() +// enhanced_prompt = f"{_with_phase_guidance(prompt, phase, cwd)}\n\n{_schema_guidance(schema)}" +// result = await self._run_with_retry(_operation, self.config) +// self._cost_tracker.register_cost(result.cost_usd) +// if result.is_error: raise AIIntegrationError(f"Harness failure{f' ({phase})' if phase else ''}: {message}") +// if isinstance(parsed, schema): return parsed +// ... +// if not parsed and not result.is_error: return await self._invoke_with_schema_retry(...) +// raise AIIntegrationError(f"Harness returned invalid payload for schema {schema.__name__}") +// +// Python parity notes: +// +// - The retry wrapper covers the SDK CALL only. A harness that RAN and failed +// comes back with is_error set and a nil Go error, so it never reaches +// _run_with_retry's except clause — it goes straight to the +// AIIntegrationError raise, transient message or not. That is Python's +// behavior too. +// - The phase suffix on the failure message is the empty string when phase is +// falsy, so an empty phase contributes nothing to the text, not " ()". +// - The `isinstance(result, schema)` third branch is Python duck-typing for an +// SDK that returns the model itself; the Go SDK always returns a +// *harness.Result, so it is unreachable and not ported. +func HarnessInvoke[T any](ctx context.Context, w *HarnessWrapper, req InvokeRequest) (T, error) { + w.registerInvocation() + enhancedPrompt := WithPhaseGuidance(req.Prompt, req.Phase, req.Cwd) + "\n\n" + SchemaGuidance[T]() + + // Python passes prompt=enhanced_prompt down to _invoke_with_schema_retry, + // which never reads it (it rebuilds the prompt from the schema); carried + // through anyway so the two call graphs line up. + retryReq := req + retryReq.Prompt = enhancedPrompt + return harnessAttempt[T](ctx, w, enhancedPrompt, retryReq, 0, false) +} + +// harnessAttempt is the shared body of invoke and _invoke_with_schema_retry. +// schemaRetry == false runs invoke's operation with the caller's prompt; +// schemaRetry == true runs _invoke_with_schema_retry's, which rebuilds the +// prompt from _build_schema_retry_prompt at each level of the recursion. +func harnessAttempt[T any](ctx context.Context, w *HarnessWrapper, prompt string, req InvokeRequest, retryCount int, schemaRetry bool) (T, error) { + var zero T + typeName := reflect.TypeOf((*T)(nil)).Elem().Name() + + if schemaRetry { + // _invoke_with_schema_retry: + // if retry_count >= max_schema_retries: raise AIIntegrationError(...) + // error_detail = f"Retry attempt {retry_count + 1}/{max_schema_retries}" + // retry_task = _build_schema_retry_prompt(schema, error_detail, cwd) + // retry_prompt = f"{_with_phase_guidance(retry_task, phase, cwd)}\n\n{_schema_guidance(schema)}" + if retryCount >= maxSchemaRetries { + return zero, newAIIntegrationError( + "Schema validation failed after " + strconv.Itoa(maxSchemaRetries) + " retries with schema context") + } + errorDetail := "Retry attempt " + strconv.Itoa(retryCount+1) + "/" + strconv.Itoa(maxSchemaRetries) + retryTask := BuildSchemaRetryPrompt[T](errorDetail, req.Cwd) + prompt = WithPhaseGuidance(retryTask, req.Phase, req.Cwd) + "\n\n" + SchemaGuidance[T]() + } + + opts, err := w.harnessOptions(req) + if err != nil { + return zero, err + } + + type attemptResult struct { + dest *T + res *harness.Result + } + operation := func() (attemptResult, error) { + dest, res, runErr := harnessx.Run[T](ctx, w.App, prompt, opts) + return attemptResult{dest: dest, res: res}, runErr + } + + out, err := runWithRetry(ctx, w.Config, w.sleeper(), operation) + if err != nil { + return zero, err + } + if out.res != nil { + w.registerCost(out.res.CostUSD) + } + + if out.res != nil && out.res.IsError { + message := out.res.ErrorMessage + if message == "" { + message = "unknown harness error" + } + suffix := "" + if req.Phase != "" { + suffix = " (" + req.Phase + ")" + } + return zero, newAIIntegrationError("Harness failure" + suffix + ": " + message) + } + + if out.res != nil && out.res.Parsed != nil { + if out.dest != nil { + return *out.dest, nil + } + // Python's trailing `raise AIIntegrationError(f"Harness returned + // invalid payload for schema {schema.__name__}")`: reached when parsed + // is truthy but is neither the schema nor a dict. The Go SDK always + // stores the dest pointer it was handed, so this is unreachable here; + // it is kept so the error surface matches. + return zero, newAIIntegrationError("Harness returned invalid payload for schema " + typeName) + } + + // `if not parsed` — the SDK produced no schema-valid object, so recurse with + // the schema spelled out in the prompt. + // + // Python parity: invoke's fall-through calls _invoke_with_schema_retry with + // its DEFAULT retry_count=0, and only the schema-retry path increments. So + // a run that never produces a parsed object makes 1 + max_schema_retries == + // 4 harness calls, and the error details read "Retry attempt 1/3", "2/3", + // "3/3" in that order. + next := 0 + if schemaRetry { + next = retryCount + 1 + } + return harnessAttempt[T](ctx, w, prompt, req, next, true) +} + +// BatchResult is one slot of `asyncio.gather(..., return_exceptions=True)`: +// either the value or the exception, never both. +type BatchResult[T any] struct { + Value T + Err error +} + +// HarnessInvokeBatch ports HarnessWrapper.invoke_batch: +// +// semaphore = asyncio.Semaphore(max_concurrent) if max_concurrent else None +// tasks = [_run_request(r) for r in requests] +// return await asyncio.gather(*tasks, return_exceptions=True) +// +// Python parity notes: +// +// - RESULT ORDER matches REQUEST order (gather preserves it), so results are +// written into a pre-indexed slice rather than collected as they finish. +// - `return_exceptions=True` means one failure does NOT cancel the others; +// every request runs to completion and failures land in their own slot. +// There is no errgroup here for exactly that reason. +// - `max_concurrent=None` (spelled as a non-positive value in Go) means +// UNBOUNDED, which is why the semaphore is conditional. +// - Acquiring the weighted semaphore can fail only if ctx is already done; in +// that case the slot records the ctx error, which is the closest analogue of +// asyncio cancelling a queued task. +func HarnessInvokeBatch[T any](ctx context.Context, w *HarnessWrapper, requests []InvokeRequest, maxConcurrent int) []BatchResult[T] { + results := make([]BatchResult[T], len(requests)) + var sem *semaphore.Weighted + if maxConcurrent > 0 { + sem = semaphore.NewWeighted(int64(maxConcurrent)) + } + + var wg sync.WaitGroup + for i := range requests { + wg.Add(1) + go func(idx int, req InvokeRequest) { + defer wg.Done() + if sem != nil { + if err := sem.Acquire(ctx, 1); err != nil { + results[idx] = BatchResult[T]{Err: err} + return + } + defer sem.Release(1) + } + v, err := HarnessInvoke[T](ctx, w, req) + results[idx] = BatchResult[T]{Value: v, Err: err} + }(i, requests[i]) + } + wg.Wait() + return results +} + +// RunReconAnalysis ports HarnessWrapper.run_recon_analysis — invoke with +// phase="recon". +func RunReconAnalysis[T any](ctx context.Context, w *HarnessWrapper, prompt, cwd, projectDir string) (T, error) { + return HarnessInvoke[T](ctx, w, InvokeRequest{Prompt: prompt, Cwd: cwd, ProjectDir: projectDir, Phase: "recon"}) +} + +// RunHuntAnalysis ports HarnessWrapper.run_hunt_analysis — invoke with +// phase="hunt". +func RunHuntAnalysis[T any](ctx context.Context, w *HarnessWrapper, prompt, cwd, projectDir string) (T, error) { + return HarnessInvoke[T](ctx, w, InvokeRequest{Prompt: prompt, Cwd: cwd, ProjectDir: projectDir, Phase: "hunt"}) +} + +// RunProveAnalysis ports HarnessWrapper.run_prove_analysis — invoke with +// phase="prove". +func RunProveAnalysis[T any](ctx context.Context, w *HarnessWrapper, prompt, cwd, projectDir string) (T, error) { + return HarnessInvoke[T](ctx, w, InvokeRequest{Prompt: prompt, Cwd: cwd, ProjectDir: projectDir, Phase: "prove"}) +} diff --git a/go/internal/gates/harnesswrapper_test.go b/go/internal/gates/harnesswrapper_test.go new file mode 100644 index 0000000..eaea810 --- /dev/null +++ b/go/internal/gates/harnesswrapper_test.go @@ -0,0 +1,409 @@ +package gates + +// Parity tests for harness.py HarnessWrapper. +// +// HarnessWrapper is not on the live path (nothing constructs one in the Python +// tree), so these tests pin the parts that would silently rot: the assembled +// prompt, the harness.Options mapping, the cost/invocation bookkeeping, the +// schema-retry recursion depth, and invoke_batch's gather semantics. + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/config" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// isolateXDG points ProviderEnv's eager mkdir at a scratch directory so the +// tests never touch the shared /tmp/opencode-shared-data path. +func isolateXDG(t *testing.T) { + t.Helper() + t.Setenv("XDG_DATA_HOME", t.TempDir()) +} + +func harnessTestConfig() config.AIIntegrationConfig { + return config.AIIntegrationConfig{ + Provider: "aforge", + HarnessModel: "minimax/minimax-m2.5", + AIModel: "minimax/minimax-m2.5", + MaxTurns: 50, + MaxRetries: 3, + InitialBackoffSeconds: 2.0, + MaxBackoffSeconds: 8.0, + } +} + +const cweExpansionJSON = `{"additional_cwes":["CWE-918"],"rationale":"because"}` + +// TestHarnessInvokeAssemblesThePrompt pins +// `f"{_with_phase_guidance(prompt, phase, cwd)}\n\n{_schema_guidance(schema)}"`. +func TestHarnessInvokeAssemblesThePrompt(t *testing.T) { + isolateXDG(t) + ctx := context.Background() + + fake := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return json.RawMessage(cweExpansionJSON), nil + })} + wrapper := &HarnessWrapper{App: fake, Config: harnessTestConfig()} + + got, err := HarnessInvoke[schemas.CWEExpansion](ctx, wrapper, InvokeRequest{ + Prompt: "Expand the CWE list.", + Cwd: "/tmp/secaf-hw", + ProjectDir: "/repo", + Phase: "hunt", + }) + if err != nil { + t.Fatalf("HarnessInvoke: %v", err) + } + if len(got.AdditionalCwes) != 1 || got.AdditionalCwes[0] != "CWE-918" { + t.Errorf("parsed = %+v", got) + } + + if len(fake.Harnesses) != 1 { + t.Fatalf("expected one harness call, got %d", len(fake.Harnesses)) + } + want := WithPhaseGuidance("Expand the CWE list.", "hunt", "/tmp/secaf-hw") + + "\n\n" + SchemaGuidance[schemas.CWEExpansion]() + if fake.Harnesses[0].Prompt != want { + t.Errorf("prompt mismatch:\n%s", firstDiff(want, fake.Harnesses[0].Prompt)) + } + // The phase guidance must actually be the HUNT block, not the fallback. + if !strings.Contains(fake.Harnesses[0].Prompt, PhaseGuidance["hunt"]) { + t.Error("prompt does not carry the hunt phase guidance") + } +} + +// TestHarnessInvokeOptions pins the keyword arguments Python hands to +// app.harness, and the `x or self.config.y` fallbacks. +func TestHarnessInvokeOptions(t *testing.T) { + isolateXDG(t) + ctx := context.Background() + cfg := harnessTestConfig() + + t.Run("defaults from config", func(t *testing.T) { + fake := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return json.RawMessage(cweExpansionJSON), nil + })} + wrapper := &HarnessWrapper{App: fake, Config: cfg} + if _, err := HarnessInvoke[schemas.CWEExpansion](ctx, wrapper, InvokeRequest{ + Prompt: "p", Cwd: "/cwd", ProjectDir: "/repo", + }); err != nil { + t.Fatalf("HarnessInvoke: %v", err) + } + opts := fake.Harnesses[0].Opts + if opts.Provider != "aforge" || opts.Model != cfg.HarnessModel || opts.MaxTurns != 50 { + t.Errorf("opts = %+v", opts) + } + if opts.Cwd != "/cwd" || opts.ProjectDir != "/repo" { + t.Errorf("cwd/project_dir = %q/%q", opts.Cwd, opts.ProjectDir) + } + if opts.Env["AGENTFIELD_AFORGE_COMMAND"] != "exec" { + t.Errorf("env not populated from provider_env(): %v", opts.Env) + } + }) + + t.Run("per-call overrides", func(t *testing.T) { + fake := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return json.RawMessage(cweExpansionJSON), nil + })} + wrapper := &HarnessWrapper{App: fake, Config: cfg} + if _, err := HarnessInvoke[schemas.CWEExpansion](ctx, wrapper, InvokeRequest{ + Prompt: "p", Cwd: "/cwd", Model: "other/model", MaxTurns: 7, MaxBudgetUSD: 1.5, + }); err != nil { + t.Fatalf("HarnessInvoke: %v", err) + } + opts := fake.Harnesses[0].Opts + if opts.Model != "other/model" || opts.MaxTurns != 7 || opts.MaxBudgetUSD != 1.5 { + t.Errorf("overrides not applied: %+v", opts) + } + }) +} + +// TestHarnessInvokeErrorResult pins the AIIntegrationError message, including +// the parenthesised phase suffix (absent for an empty phase) and the +// "unknown harness error" default. +func TestHarnessInvokeErrorResult(t *testing.T) { + isolateXDG(t) + ctx := context.Background() + + cases := []struct { + name string + phase string + message string + want string + }{ + {"with phase", "prove", "provider exited 1", "Harness failure (prove): provider exited 1"}, + {"without phase", "", "provider exited 1", "Harness failure: provider exited 1"}, + {"missing message", "recon", "", "Harness failure (recon): unknown harness error"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fake := &appx.Fake{HarnessFn: func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return &harness.Result{IsError: true, ErrorMessage: tc.message}, nil + }} + wrapper := &HarnessWrapper{App: fake, Config: harnessTestConfig()} + + _, err := HarnessInvoke[schemas.CWEExpansion](ctx, wrapper, InvokeRequest{ + Prompt: "p", Cwd: "/cwd", Phase: tc.phase, + }) + aiErr, ok := AsAIIntegrationError(err) + if !ok { + t.Fatalf("error = %v (%T), want an AIIntegrationError", err, err) + } + if aiErr.Message != tc.want { + t.Errorf("message = %q, want %q", aiErr.Message, tc.want) + } + // A harness that RAN and failed is not retried: is_error comes back + // with a nil transport error, so _run_with_retry never sees an + // exception. Even a "rate limit" message stops here. + if len(fake.Harnesses) != 1 { + t.Errorf("attempts = %d, want 1", len(fake.Harnesses)) + } + }) + } +} + +// TestHarnessInvokeSchemaRetryDepth pins the recursion Python performs when the +// SDK returns no parsed object: one invoke attempt plus max_schema_retries (3) +// schema-context retries, then the "Schema validation failed after 3 retries" +// AIIntegrationError. The retry prompts must carry the schema JSON and the +// 1/3, 2/3, 3/3 error details in order. +func TestHarnessInvokeSchemaRetryDepth(t *testing.T) { + isolateXDG(t) + ctx := context.Background() + + fake := &appx.Fake{HarnessFn: func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return &harness.Result{}, nil // ran fine, produced nothing parseable + }} + wrapper := &HarnessWrapper{App: fake, Config: harnessTestConfig()} + + _, err := HarnessInvoke[schemas.CWEExpansion](ctx, wrapper, InvokeRequest{ + Prompt: "p", Cwd: "/tmp/secaf-hw", Phase: "hunt", + }) + aiErr, ok := AsAIIntegrationError(err) + if !ok { + t.Fatalf("error = %v (%T), want an AIIntegrationError", err, err) + } + if aiErr.Message != "Schema validation failed after 3 retries with schema context" { + t.Errorf("message = %q", aiErr.Message) + } + if len(fake.Harnesses) != 1+maxSchemaRetries { + t.Fatalf("attempts = %d, want %d", len(fake.Harnesses), 1+maxSchemaRetries) + } + for i, detail := range []string{"Retry attempt 1/3", "Retry attempt 2/3", "Retry attempt 3/3"} { + prompt := fake.Harnesses[i+1].Prompt + if !strings.Contains(prompt, "Error: "+detail) { + t.Errorf("retry %d prompt is missing %q", i+1, detail) + } + if !strings.Contains(prompt, "```json") { + t.Errorf("retry %d prompt is missing the embedded schema", i+1) + } + } +} + +// TestHarnessWrapperCostTracking pins _CostTracker on the harness side, where — +// unlike the AI gate — the SDK really does report a cost. +func TestHarnessWrapperCostTracking(t *testing.T) { + isolateXDG(t) + ctx := context.Background() + + costs := []*float64{ptr(0.5), nil, ptr(-2.0), ptr(0.25)} + call := 0 + fake := &appx.Fake{HarnessFn: func(_ context.Context, _ string, _ map[string]any, dest any, _ harness.Options) (*harness.Result, error) { + if err := json.Unmarshal([]byte(cweExpansionJSON), dest); err != nil { + return nil, err + } + res := &harness.Result{Parsed: dest, CostUSD: costs[call]} + call++ + return res, nil + }} + wrapper := &HarnessWrapper{App: fake, Config: harnessTestConfig()} + + for range costs { + if _, err := HarnessInvoke[schemas.CWEExpansion](ctx, wrapper, InvokeRequest{Prompt: "p", Cwd: "/cwd"}); err != nil { + t.Fatalf("HarnessInvoke: %v", err) + } + } + if got := wrapper.InvocationCount(); got != len(costs) { + t.Errorf("InvocationCount = %d, want %d", got, len(costs)) + } + if got := wrapper.TotalCostUSD(); got != 0.75 { + t.Errorf("TotalCostUSD = %v, want 0.75", got) + } +} + +// TestRunPhaseAnalysisHelpers pins run_recon_analysis / run_hunt_analysis / +// run_prove_analysis — each is invoke with its phase pinned. +func TestRunPhaseAnalysisHelpers(t *testing.T) { + isolateXDG(t) + ctx := context.Background() + + cases := []struct { + phase string + run func(*HarnessWrapper) error + }{ + {"recon", func(w *HarnessWrapper) error { + _, err := RunReconAnalysis[schemas.CWEExpansion](ctx, w, "p", "/cwd", "/repo") + return err + }}, + {"hunt", func(w *HarnessWrapper) error { + _, err := RunHuntAnalysis[schemas.CWEExpansion](ctx, w, "p", "/cwd", "/repo") + return err + }}, + {"prove", func(w *HarnessWrapper) error { + _, err := RunProveAnalysis[schemas.CWEExpansion](ctx, w, "p", "/cwd", "/repo") + return err + }}, + } + for _, tc := range cases { + t.Run(tc.phase, func(t *testing.T) { + fake := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return json.RawMessage(cweExpansionJSON), nil + })} + wrapper := &HarnessWrapper{App: fake, Config: harnessTestConfig()} + if err := tc.run(wrapper); err != nil { + t.Fatalf("run: %v", err) + } + if !strings.Contains(fake.Harnesses[0].Prompt, PhaseGuidance[tc.phase]) { + t.Errorf("prompt does not carry the %s phase guidance", tc.phase) + } + if fake.Harnesses[0].Opts.ProjectDir != "/repo" { + t.Errorf("project_dir = %q", fake.Harnesses[0].Opts.ProjectDir) + } + }) + } +} + +// TestHarnessInvokeBatch pins invoke_batch's gather semantics: results are +// indexed by REQUEST position, one failure does not cancel the rest +// (return_exceptions=True), and max_concurrent bounds the in-flight count. +func TestHarnessInvokeBatch(t *testing.T) { + isolateXDG(t) + ctx := context.Background() + + fake := &appx.Fake{HarnessFn: func(_ context.Context, prompt string, _ map[string]any, dest any, _ harness.Options) (*harness.Result, error) { + if strings.Contains(prompt, "Task:\nBOOM") { + return &harness.Result{IsError: true, ErrorMessage: "provider exited 1"}, nil + } + if err := json.Unmarshal([]byte(cweExpansionJSON), dest); err != nil { + return nil, err + } + return &harness.Result{Parsed: dest}, nil + }} + wrapper := &HarnessWrapper{App: fake, Config: harnessTestConfig()} + + requests := []InvokeRequest{ + {Prompt: "one", Cwd: "/cwd"}, + {Prompt: "BOOM", Cwd: "/cwd"}, + {Prompt: "three", Cwd: "/cwd"}, + } + results := HarnessInvokeBatch[schemas.CWEExpansion](ctx, wrapper, requests, 2) + + if len(results) != len(requests) { + t.Fatalf("results = %d, want %d", len(results), len(requests)) + } + if results[0].Err != nil || results[2].Err != nil { + t.Errorf("healthy requests failed: %v / %v", results[0].Err, results[2].Err) + } + if results[1].Err == nil { + t.Error("the failing request must record its error in ITS slot") + } + if _, ok := AsAIIntegrationError(results[1].Err); !ok { + t.Errorf("results[1].Err = %v, want an AIIntegrationError", results[1].Err) + } + if len(results[0].Value.AdditionalCwes) != 1 { + t.Errorf("results[0] not parsed: %+v", results[0].Value) + } + if got := fake.MaxConcurrentHarness(); got > 2 { + t.Errorf("max concurrency = %d, want <= 2", got) + } +} + +// TestHarnessInvokeBatchUnbounded pins `max_concurrent=None`: no semaphore, so +// every request may run at once. +func TestHarnessInvokeBatchUnbounded(t *testing.T) { + isolateXDG(t) + ctx := context.Background() + + const n = 5 + var ready sync.WaitGroup + ready.Add(n) + release := make(chan struct{}) + + fake := &appx.Fake{HarnessFn: func(_ context.Context, _ string, _ map[string]any, dest any, _ harness.Options) (*harness.Result, error) { + ready.Done() + <-release // hold every call open until all n are in flight + if err := json.Unmarshal([]byte(cweExpansionJSON), dest); err != nil { + return nil, err + } + return &harness.Result{Parsed: dest}, nil + }} + wrapper := &HarnessWrapper{App: fake, Config: harnessTestConfig()} + + requests := make([]InvokeRequest, n) + for i := range requests { + requests[i] = InvokeRequest{Prompt: "p", Cwd: "/cwd"} + } + + done := make(chan []BatchResult[schemas.CWEExpansion], 1) + go func() { done <- HarnessInvokeBatch[schemas.CWEExpansion](ctx, wrapper, requests, 0) }() + + ready.Wait() // would deadlock if the batch were serialized + close(release) + results := <-done + + for i, r := range results { + if r.Err != nil { + t.Errorf("result[%d]: %v", i, r.Err) + } + } + if got := fake.MaxConcurrentHarness(); got != n { + t.Errorf("max concurrency = %d, want %d (unbounded)", got, n) + } +} + +// TestHarnessInvokeBatchCancelledContext pins the one Go-only branch: a +// semaphore acquire that fails because ctx is already done records the ctx +// error in that slot rather than panicking. +func TestHarnessInvokeBatchCancelledContext(t *testing.T) { + isolateXDG(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + fake := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return json.RawMessage(cweExpansionJSON), nil + })} + wrapper := &HarnessWrapper{App: fake, Config: harnessTestConfig()} + + results := HarnessInvokeBatch[schemas.CWEExpansion](ctx, wrapper, []InvokeRequest{{Prompt: "p", Cwd: "/cwd"}}, 1) + if len(results) != 1 { + t.Fatalf("results = %d, want 1", len(results)) + } + if !errors.Is(results[0].Err, context.Canceled) { + t.Errorf("err = %v, want context.Canceled", results[0].Err) + } +} + +// TestNewHarnessWrapperUsesSuppliedConfig pins +// `config or AIIntegrationConfig.from_env()`. +func TestNewHarnessWrapperUsesSuppliedConfig(t *testing.T) { + cfg := harnessTestConfig() + wrapper, err := NewHarnessWrapper(&appx.Fake{}, &cfg) + if err != nil { + t.Fatalf("NewHarnessWrapper: %v", err) + } + if wrapper.Config.HarnessModel != cfg.HarnessModel { + t.Errorf("config not carried through: %+v", wrapper.Config) + } + if _, err := NewHarnessWrapper(&appx.Fake{}, nil); err != nil { + t.Fatalf("NewHarnessWrapper(nil config): %v", err) + } +} diff --git a/go/internal/gates/prompts.go b/go/internal/gates/prompts.go new file mode 100644 index 0000000..1069035 --- /dev/null +++ b/go/internal/gates/prompts.go @@ -0,0 +1,349 @@ +package gates + +import ( + "reflect" + "sort" + "strings" + "unicode" + + "github.com/Agent-Field/sec-af/go/internal/harnessx" + "github.com/Agent-Field/sec-af/go/internal/pyfmt" +) + +// PhaseGuidance ports harness.py PHASE_GUIDANCE verbatim — the per-phase +// "APPROACH / PROCESS / CONSTRAINTS" block WithPhaseGuidance prepends. +// +// The Python literals are implicit string concatenations with explicit "\n" +// terminators and NO trailing newline on the last line; the Go strings below +// reproduce that byte for byte (verified by the golden test against the Python +// dict). +var PhaseGuidance = map[string]string{ + "recon": "APPROACH: You are performing reconnaissance on a codebase to build an accurate structural map.\n" + + "PROCESS:\n" + + "1. Survey the codebase structure — identify key directories, entry points, and configuration\n" + + "2. Identify the technology stack — languages, frameworks, and external services\n" + + "3. Map security-relevant boundaries — auth layers, data inputs, API surfaces\n" + + "4. Only after surveying, synthesize findings into the required schema\n" + + "CONSTRAINTS:\n" + + "- Report what IS there, not what MIGHT be there\n" + + "- If uncertain about a detail, omit it rather than guess\n" + + "- Prioritize breadth over depth — cover the full surface", + "hunt": "APPROACH: You are hunting for a specific class of security vulnerability with recon context.\n" + + "PROCESS:\n" + + "1. Review the recon context to understand the codebase topology\n" + + "2. Identify files and patterns relevant to your specific vulnerability class\n" + + "3. For each candidate: read the code, trace data flow, assess exploitability\n" + + "4. Only report findings where you have concrete code evidence\n" + + "CONSTRAINTS:\n" + + "- Every finding MUST cite specific file paths and line numbers you have read\n" + + "- Do not report theoretical vulnerabilities without code evidence\n" + + "- False negatives are better than false positives\n" + + "- If a file is sanitized properly, do NOT report it", + "prove": "APPROACH: You are verifying a specific candidate vulnerability for exploitability.\n" + + "PROCESS:\n" + + "1. Read the specific code location cited in the finding\n" + + "2. Trace the data flow from source to sink\n" + + "3. Check for sanitization, validation, or other mitigations on the path\n" + + "4. If exploitable, construct a concrete exploit hypothesis\n" + + "5. Synthesize your verdict with evidence level\n" + + "CONSTRAINTS:\n" + + "- You must READ the actual code — do not rely on the finding description alone\n" + + "- INCONCLUSIVE is a valid verdict — do not force confirmation or denial\n" + + "- Cite specific lines where sanitization exists or is missing\n" + + "- If code has changed since the finding was generated, note the discrepancy", +} + +// defaultPhaseContext is PHASE_GUIDANCE.get(phase, ) — the fallback +// paragraph for any phase name that is not recon/hunt/prove (including the +// empty string, which is what `phase=None` normalizes to). +const defaultPhaseContext = "Build conclusions from repository evidence in iterative passes. " + + "Prefer explicit evidence over speculation, and clearly separate confirmed facts from uncertainty." + +// outputFileName is the basename harness.py appends to cwd when it tells the +// model where to write large output. +const outputFileName = ".agentfield_output.json" + +// WithFileWriteHint ports harness.py _with_file_write_hint: +// +// output_path = Path(cwd) / ".agentfield_output.json" +// return (f"{prompt.rstrip()}\n" +// f"- If output is large or complex, use the file-write pattern and ensure final JSON is written to {output_path}.") +// +// Python parity notes: +// +// - `str.rstrip()` with no argument strips ALL trailing whitespace, not just +// newlines; strings.TrimRight with unicode.IsSpace is the same set, and +// strings.TrimRightFunc(s, unicode.IsSpace) is what TrimSpace uses. Here +// strings.TrimRight over " \t\n\r\v\f" plus TrimRightFunc would be +// equivalent for every real prompt, so TrimRightFunc is used for exactness. +// - `Path(cwd) / name` is NOT filepath.Join. pathlib collapses redundant +// separators and lone "." components but deliberately keeps "..", because +// resolving it without touching the filesystem would be wrong in the +// presence of symlinks; filepath.Join runs Clean, which eats "..". So +// Path("./work/../work")/"f" is "work/../work/f" where Join gives +// "work/f". pyPathJoin below reproduces pathlib. In the live node cwd is +// always an absolute tempfile.mkdtemp() path where the two agree, but the +// divergence is real and the golden test pins it. +func WithFileWriteHint(prompt, cwd string) string { + outputPath := pyPathJoin(cwd, outputFileName) + return pyRstrip(prompt) + "\n" + + "- If output is large or complex, use the file-write pattern and ensure final JSON is written to " + + outputPath + "." +} + +// WithPhaseGuidance ports harness.py _with_phase_guidance: +// +// normalized_phase = (phase or "").strip().lower() +// phase_context = PHASE_GUIDANCE.get(normalized_phase, ) +// constraints = _with_file_write_hint("Constraints:\n- ...\n- ...\n- ...", cwd) +// return (f"Context:\n- {phase_context}\n\n" +// f"{constraints}\n\n" +// f"Task:\n{prompt.rstrip()}\n\n" +// "Output:\n" +// "- Return a single JSON object matching the requested schema.") +// +// `phase` is `str | None` in Python; the Go signature takes a plain string +// because `(phase or "")` maps None and "" to the same normalized value. +func WithPhaseGuidance(prompt, phase, cwd string) string { + normalizedPhase := strings.ToLower(strings.TrimSpace(phase)) + phaseContext, ok := PhaseGuidance[normalizedPhase] + if !ok { + phaseContext = defaultPhaseContext + } + + constraints := WithFileWriteHint( + "Constraints:\n"+ + "- Use evidence-first reasoning; do not speculate beyond available artifacts.\n"+ + "- Keep analysis bounded to the task scope and produce only schema-conformant output.\n"+ + "- Cite concrete repository evidence whenever making security-relevant claims.", + cwd, + ) + + return "Context:\n- " + phaseContext + "\n\n" + + constraints + "\n\n" + + "Task:\n" + pyRstrip(prompt) + "\n\n" + + "Output:\n" + + "- Return a single JSON object matching the requested schema." +} + +// SchemaGuidance ports harness.py _schema_guidance: +// +// field_lines = [] +// for field_name, field in schema.model_fields.items(): +// description = (field.description or "").strip() +// if description: +// field_lines.append(f"- `{field_name}`: {description}") +// if not field_lines: +// return ("Output format:\n" +// "- Return valid JSON only (no markdown fences, no extra text).\n" +// "- Follow the provided Pydantic schema exactly.") +// return ("Output format:\n" +// "- Return valid JSON only (no markdown fences, no extra text).\n" +// "- Follow the provided Pydantic schema exactly.\n" +// "- Field guidance from schema descriptions:\n" +// f"{chr(10).join(field_lines)}") +// +// The two Python inputs — the field ORDER and each field's DESCRIPTION — come +// from different places in Go: +// +// - ORDER is `schema.model_fields.items()`, pydantic declaration order. The +// port's cross-package contract is that a Go struct's fields are declared in +// the same order as its pydantic counterpart's, so reflect.Type field order +// IS model_fields order. (The committed JSON-Schema fixture cannot supply +// it: go/scripts/gen_schemas.py writes with sort_keys=True, so its +// `properties` object is alphabetical.) +// - DESCRIPTION is `FieldInfo.description`, which pydantic copies verbatim +// into `properties[].description` of model_json_schema() — including +// for Optional and nested-model fields, where the description sits beside +// the anyOf/$ref rather than inside it. harnessx.SchemaFor[T] serves that +// document. +// +// A field with no description contributes no line, exactly as in Python; a +// model with no described fields at all takes the short fallback. +func SchemaGuidance[T any]() string { + schema := harnessx.SchemaFor[T]() + properties, _ := schema["properties"].(map[string]any) + + var fieldLines []string + for _, name := range harnessx.JSONFieldNames(reflect.TypeOf((*T)(nil)).Elem()) { + prop, ok := properties[name].(map[string]any) + if !ok { + continue + } + description, _ := prop["description"].(string) + description = strings.TrimSpace(description) + if description == "" { + continue + } + fieldLines = append(fieldLines, "- `"+name+"`: "+description) + } + + const head = "Output format:\n" + + "- Return valid JSON only (no markdown fences, no extra text).\n" + + "- Follow the provided Pydantic schema exactly." + if len(fieldLines) == 0 { + return head + } + return head + "\n" + + "- Field guidance from schema descriptions:\n" + + strings.Join(fieldLines, "\n") +} + +// BuildSchemaRetryPrompt ports harness.py _build_schema_retry_prompt: +// +// output_path = Path(cwd) / ".agentfield_output.json" +// schema_json_str = json.dumps(schema.model_json_schema(), indent=2) +// return (f"The JSON output at {output_path} failed validation.\n" +// f"Error: {error_detail}\n\n" +// f"Your response must conform to this JSON schema:\n" +// f"```json\n{schema_json_str}\n```\n\n" +// f"Rewrite the COMPLETE, corrected JSON to: {output_path}\n" +// f"The file must contain ONLY valid JSON matching the schema above. " +// f"No markdown fences, no extra text, no comments.") +// +// The embedded schema JSON is byte-reproducible from the committed fixture. The +// fixture is written with sort_keys=True, and pydantic's own +// model_json_schema() dict happens to be in sorted key order at EVERY level +// except one: the `properties` object of the root model and of each `$defs` +// entry, which are in field DECLARATION order. declarationOrdered restores +// exactly those, using the Go struct field order reachable from T — verified +// against all 23 committed fixtures. +func BuildSchemaRetryPrompt[T any](errorDetail, cwd string) string { + outputPath := pyPathJoin(cwd, outputFileName) + schemaJSON := pyfmt.Dumps(declarationOrdered[T](harnessx.SchemaFor[T]()), 2) + + return "The JSON output at " + outputPath + " failed validation.\n" + + "Error: " + errorDetail + "\n\n" + + "Your response must conform to this JSON schema:\n" + + "```json\n" + schemaJSON + "\n```\n\n" + + "Rewrite the COMPLETE, corrected JSON to: " + outputPath + "\n" + + "The file must contain ONLY valid JSON matching the schema above. " + + "No markdown fences, no extra text, no comments." +} + +// --------------------------------------------------------------------------- +// reflection helpers +// --------------------------------------------------------------------------- + +// jsonFieldNames and collectFieldOrders live in internal/harnessx (fieldorder.go) +// because internal/aix needs the same declaration order for the `.ai()` request +// schema and cannot import this package (gates imports aix, not the reverse). + +// declarationOrdered rewrites a JSON-Schema document so that the root +// `properties` object, and the `properties` object of every `$defs` entry whose +// name matches a Go struct reachable from T, render in field DECLARATION order +// instead of the sorted order pyfmt.Dumps gives a Go map. Every other object in +// the document keeps sorted rendering, which is what pydantic emits anyway. +// +// Unknown property names (a fixture key with no matching json tag) are appended +// in sorted order rather than dropped, so a drifted fixture degrades to a +// visible ordering difference instead of silently losing schema content. +func declarationOrdered[T any](schema map[string]any) any { + rootType := reflect.TypeOf((*T)(nil)).Elem() + orders := harnessx.FieldOrders(rootType) + + out := make(map[string]any, len(schema)) + for k, v := range schema { + out[k] = v + } + if props, ok := out["properties"].(map[string]any); ok { + out["properties"] = orderProperties(props, orders[rootType.Name()]) + } + if defs, ok := out["$defs"].(map[string]any); ok { + newDefs := make(map[string]any, len(defs)) + for defName, defSchema := range defs { + sub, isMap := defSchema.(map[string]any) + if !isMap { + newDefs[defName] = defSchema + continue + } + copied := make(map[string]any, len(sub)) + for k, v := range sub { + copied[k] = v + } + if props, hasProps := copied["properties"].(map[string]any); hasProps { + copied["properties"] = orderProperties(props, orders[defName]) + } + newDefs[defName] = copied + } + out["$defs"] = newDefs + } + return out +} + +// orderProperties turns a properties map into a pyfmt.Ordered following `order`, +// appending any leftover keys in sorted order. +func orderProperties(props map[string]any, order []string) pyfmt.Ordered { + ordered := make(pyfmt.Ordered, 0, len(props)) + emitted := make(map[string]struct{}, len(props)) + for _, name := range order { + if v, ok := props[name]; ok { + ordered = append(ordered, pyfmt.KV{Key: name, Value: v}) + emitted[name] = struct{}{} + } + } + leftovers := make([]string, 0) + for name := range props { + if _, done := emitted[name]; !done { + leftovers = append(leftovers, name) + } + } + sort.Strings(leftovers) + for _, name := range leftovers { + ordered = append(ordered, pyfmt.KV{Key: name, Value: props[name]}) + } + return ordered +} + +// pyPathJoin reproduces `str(PurePosixPath(dir) / name)`. +// +// PurePath parsing drops empty components (so "a//b" and a trailing "/" both +// normalize) and lone "." components, but PRESERVES "..", which filepath.Clean +// — and therefore filepath.Join — resolves away. Reproducing pathlib rather +// than reusing Join keeps the emitted output path byte-identical for every cwd +// spelling. +// +// The POSIX rule that EXACTLY two leading slashes are significant is honoured: +// pathlib keeps `//` as the root and collapses one or three-or-more to `/` +// (VERIFIED: `"//a//b//"` -> `//a/b/...`, `"///a"` -> `/a/...`, `"//"` -> +// `//...`). One pathlib corner is still deliberately not reproduced, and is +// unreachable from SEC-AF (cwd always comes from os.MkdirTemp): Windows +// drive/UNC handling. +func pyPathJoin(dir, name string) string { + root := "" + rest := dir + if strings.HasPrefix(dir, "/") { + root = "/" + if strings.HasPrefix(dir, "//") && !strings.HasPrefix(dir, "///") { + root = "//" + } + rest = strings.TrimLeft(dir, "/") + } + parts := make([]string, 0, 8) + for _, part := range strings.Split(rest, "/") { + if part == "" || part == "." { + continue + } + parts = append(parts, part) + } + parts = append(parts, name) + return root + strings.Join(parts, "/") +} + +// pyRstrip reproduces Python's str.rstrip() with no argument: strip every +// trailing Unicode whitespace character. +func pyRstrip(s string) string { + return strings.TrimRightFunc(s, isPySpace) +} + +// isPySpace matches the character class str.strip() removes. Python uses +// str.isspace(), which is Unicode's whitespace property plus the ASCII control +// characters \x1c-\x1f; Go's unicode.IsSpace covers the same set except those +// four separators, which are added explicitly. +func isPySpace(r rune) bool { + switch r { + case '\x1c', '\x1d', '\x1e', '\x1f': + return true + } + return unicode.IsSpace(r) +} diff --git a/go/internal/gates/prompts_test.go b/go/internal/gates/prompts_test.go new file mode 100644 index 0000000..b3692aa --- /dev/null +++ b/go/internal/gates/prompts_test.go @@ -0,0 +1,328 @@ +package gates + +// Parity tests for the pure prompt builders of src/sec_af/harness.py. +// +// Every expectation is a COMMITTED GOLDEN produced by calling the real Python +// function with the SAME literals declared below: +// +// PYTHONPATH=src ~/.agentfield/packages/sec-af/venv/bin/python go/scripts/gen_golden.py +// +// (see gen_golden.py's GATES_CWD / GATES_PROMPT / PHASE_CASES / +// FILE_WRITE_HINT_CASES / GATES_SCHEMAS / SCHEMA_RETRY_ERROR_DETAIL). + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// The inputs, mirroring gen_golden.py. +const ( + goldenCWD = "/tmp/secaf-golden" + // Trailing spaces and blank lines on purpose: _with_phase_guidance rstrips + // the task text, and a Go port that used TrimSpace or TrimSuffix("\n") + // would produce different bytes. + goldenPrompt = "Analyze the repository for SQL injection.\nCite file:line for every claim. \n\n" + schemaRetryErrorDetail = "Retry attempt 1/3" +) + +func golden(t *testing.T, name string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", "golden", name)) + if err != nil { + t.Fatalf("read golden %s: %v (regenerate with go/scripts/gen_golden.py)", name, err) + } + return string(raw) +} + +func firstDiff(want, got string) string { + wantLines, gotLines := splitLines(want), splitLines(got) + n := len(wantLines) + if len(gotLines) < n { + n = len(gotLines) + } + for i := 0; i < n; i++ { + if wantLines[i] != gotLines[i] { + w, _ := json.Marshal(wantLines[i]) + g, _ := json.Marshal(gotLines[i]) + return "first difference at line " + itoa(i+1) + "\n want: " + string(w) + "\n got: " + string(g) + } + } + if len(wantLines) != len(gotLines) { + return "line counts differ: want " + itoa(len(wantLines)) + ", got " + itoa(len(gotLines)) + } + return "(no line differs; check trailing bytes)" +} + +func splitLines(s string) []string { + out := []string{} + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + out = append(out, s[start:i]) + start = i + 1 + } + } + return append(out, s[start:]) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [24]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} + +// TestPhaseGuidanceMatchesPython pins the three multi-paragraph blocks verbatim +// against the Python dict, so a dropped bullet or a changed em dash is a test +// failure rather than a quietly different instruction to the model. +func TestPhaseGuidanceMatchesPython(t *testing.T) { + var want map[string]string + if err := json.Unmarshal([]byte(golden(t, "phase_guidance.json")), &want); err != nil { + t.Fatalf("parse phase_guidance.json: %v", err) + } + if len(PhaseGuidance) != len(want) { + t.Fatalf("PhaseGuidance has %d entries, want %d", len(PhaseGuidance), len(want)) + } + for phase, wantText := range want { + gotText, ok := PhaseGuidance[phase] + if !ok { + t.Errorf("PhaseGuidance missing %q", phase) + continue + } + if gotText != wantText { + t.Errorf("PhaseGuidance[%q] mismatch:\n%s", phase, firstDiff(wantText, gotText)) + } + } +} + +// TestWithPhaseGuidanceMatchesPython covers every branch of the phase lookup: +// the three known phases, Python's `phase=None` and its `phase=""` twin (which +// normalize to the same string and therefore the same golden), a phase that +// needs strip+lower before it matches, and an unknown phase that takes the +// fallback paragraph. +func TestWithPhaseGuidanceMatchesPython(t *testing.T) { + cases := []struct { + name string + phase string + }{ + {"recon", "recon"}, + {"hunt", "hunt"}, + {"prove", "prove"}, + {"none", ""}, // Python `phase=None` + {"empty", ""}, // Python `phase=""` + {"padded_mixed_case", " Recon "}, + {"unknown", "unknown-phase"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + want := golden(t, "with_phase_guidance_"+tc.name+".txt") + if got := WithPhaseGuidance(goldenPrompt, tc.phase, goldenCWD); got != want { + t.Errorf("mismatch:\n%s", firstDiff(want, got)) + } + }) + } + + // None and "" must be indistinguishable, which is why the Go signature can + // take a plain string. + if WithPhaseGuidance(goldenPrompt, "", goldenCWD) != golden(t, "with_phase_guidance_none.txt") { + t.Error(`phase="" must render the same text Python's phase=None does`) + } +} + +// TestWithFileWriteHintMatchesPython covers the rstrip and the Path join, +// including a cwd with a trailing separator, an empty cwd (Path("") / name == +// name) and a cwd needing normalization. +func TestWithFileWriteHintMatchesPython(t *testing.T) { + cases := []struct { + name string + prompt string + cwd string + }{ + {"basic", "Constraints:\n- first\n- second", goldenCWD}, + {"trailing_whitespace", "keep me\t \n\n ", goldenCWD + "/"}, + {"empty_cwd", "no directory", ""}, + {"relative_cwd", "relative", "./work/../work"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + want := golden(t, "with_file_write_hint_"+tc.name+".txt") + if got := WithFileWriteHint(tc.prompt, tc.cwd); got != want { + t.Errorf("mismatch:\n%s", firstDiff(want, got)) + } + }) + } +} + +// TestPyPathJoinMatchesPathlib transcribes `str(PurePosixPath(cwd) / +// ".agentfield_output.json")` from the pinned interpreter for every cwd +// spelling that exercises a parsing rule. The POSIX "exactly two leading +// slashes" corner is the one this table exists for: pathlib keeps `//` as the +// root and collapses one or three-or-more slashes to `/`. +func TestPyPathJoinMatchesPathlib(t *testing.T) { + const name = ".agentfield_output.json" + for _, tc := range []struct{ cwd, want string }{ + {"", name}, + {"/", "/" + name}, + {"//", "//" + name}, + {"///", "/" + name}, + {"////", "/" + name}, + {"//a", "//a/" + name}, + {"///a", "/a/" + name}, + {"//a//b//", "//a/b/" + name}, + {"//tmp//", "//tmp/" + name}, + {"//.", "//" + name}, + {"//..", "//../" + name}, + {"//a/../b", "//a/../b/" + name}, + {"/a//b", "/a/b/" + name}, + {".", name}, + {"..", "../" + name}, + {"/tmp/..", "/tmp/../" + name}, + {"./work/../work", "work/../work/" + name}, + {"a/./b", "a/b/" + name}, + {"rel/dir", "rel/dir/" + name}, + {"/tmp/x/", "/tmp/x/" + name}, + {"//\u65e5\u672c/x", "//\u65e5\u672c/x/" + name}, + } { + if got := pyPathJoin(tc.cwd, name); got != tc.want { + t.Errorf("pyPathJoin(%q, name) = %q, want %q", tc.cwd, got, tc.want) + } + } +} + +// TestWithFileWriteHintKeepsADoubleSlashRoot is the same rule seen through the +// prompt the model actually receives. VERIFIED: +// `_with_file_write_hint("Constraints:\n- first", "//a//b//")` ends in +// `... written to //a/b/.agentfield_output.json.` +func TestWithFileWriteHintKeepsADoubleSlashRoot(t *testing.T) { + const prompt = "Constraints:\n- first" + for _, tc := range []struct{ cwd, want string }{ + {"//a//b//", prompt + "\n- If output is large or complex, use the file-write pattern and ensure final JSON is written to //a/b/.agentfield_output.json."}, + {"//", prompt + "\n- If output is large or complex, use the file-write pattern and ensure final JSON is written to //.agentfield_output.json."}, + } { + if got := WithFileWriteHint(prompt, tc.cwd); got != tc.want { + t.Errorf("cwd %q:\n got: %q\nwant: %q", tc.cwd, got, tc.want) + } + } +} + +// schemaCase binds a model NAME to the two generic builders instantiated for +// its Go struct. The name is simultaneously the pydantic class name, the +// embedded schema fixture's basename and the golden's suffix — the three-way +// identity the port is built on. +type schemaCase struct { + name string + guidance func() string + retry func(errorDetail, cwd string) string +} + +func newSchemaCase[T any](name string) schemaCase { + return schemaCase{ + name: name, + guidance: SchemaGuidance[T], + retry: BuildSchemaRetryPrompt[T], + } +} + +// schemaCases covers every model go/scripts/gen_schemas.py emits a fixture for. +// Running all 23 through both builders is what makes this a repo-wide check of +// the "Go struct field order == pydantic declaration order" contract: a +// reordered or renamed Go field changes the guidance lines and the schema's +// `properties` order, and both are pinned. +var schemaCases = []schemaCase{ + newSchemaCase[schemas.ArchitectureMapRaw]("ArchitectureMapRaw"), + newSchemaCase[schemas.DependencyReportRaw]("DependencyReportRaw"), + newSchemaCase[schemas.ConfigReportRaw]("ConfigReportRaw"), + newSchemaCase[schemas.DataFlowMapRaw]("DataFlowMapRaw"), + newSchemaCase[schemas.SecurityContextRaw]("SecurityContextRaw"), + newSchemaCase[schemas.ScanLocationsResult]("ScanLocationsResult"), + newSchemaCase[schemas.EnrichedFinding]("EnrichedFinding"), + newSchemaCase[schemas.ChainCorrelationResult]("ChainCorrelationResult"), + newSchemaCase[schemas.DataFlowTrace]("DataFlowTrace"), + newSchemaCase[schemas.SanitizationResult]("SanitizationResult"), + newSchemaCase[schemas.ExploitHypothesis]("ExploitHypothesis"), + newSchemaCase[schemas.ReachabilityProof]("ReachabilityProof"), + newSchemaCase[schemas.DastVerificationResult]("DastVerificationResult"), + newSchemaCase[schemas.CrossServiceFinding]("CrossServiceFinding"), + newSchemaCase[schemas.RemediationSuggestion]("RemediationSuggestion"), + newSchemaCase[schemas.PolicyEvalResult]("PolicyEvalResult"), + newSchemaCase[schemas.VerdictDecision]("VerdictDecision"), + newSchemaCase[schemas.CWEExpansion]("CWEExpansion"), + newSchemaCase[schemas.SeverityClassification]("SeverityClassification"), + newSchemaCase[schemas.DuplicateCheck]("DuplicateCheck"), + newSchemaCase[schemas.StrategySelection]("StrategySelection"), + newSchemaCase[schemas.ReachabilityGate]("ReachabilityGate"), + newSchemaCase[schemas.ComplianceGate]("ComplianceGate"), +} + +// TestSchemaGuidanceMatchesPython pins _schema_guidance for all 23 models. +// +// It is simultaneously the strongest available check that each Go struct +// declares its fields in pydantic order and tags them with the pydantic field +// names: the guidance lines are emitted in Go field order and keyed by json +// tag, while the descriptions come from the pydantic-generated fixture. +// +// The DuplicateCheck / StrategySelection / ComplianceGate goldens are the short +// fallback text — those models describe no field, so `field_lines` is empty. +func TestSchemaGuidanceMatchesPython(t *testing.T) { + for _, tc := range schemaCases { + t.Run(tc.name, func(t *testing.T) { + want := golden(t, "schema_guidance_"+tc.name+".txt") + if got := tc.guidance(); got != want { + t.Errorf("SchemaGuidance[%s] mismatch:\n%s", tc.name, firstDiff(want, got)) + } + }) + } +} + +// TestBuildSchemaRetryPromptMatchesPython pins _build_schema_retry_prompt for +// all 23 models, embedded schema JSON included. +// +// That JSON is `json.dumps(model_json_schema(), indent=2)` in Python — key +// order and all — so a byte match proves three separate things at once: +// pyfmt.Dumps renders a decoded JSON document exactly like CPython's json +// module (including \uXXXX escaping and the ": " separator), the committed +// fixture still equals the live pydantic schema, and declarationOrdered +// correctly restores the one place pydantic's order is not alphabetical (the +// root `properties` object and each `$defs` entry's — ScanLocationsResult and +// ComplianceGate are the two models with `$defs`). +func TestBuildSchemaRetryPromptMatchesPython(t *testing.T) { + for _, tc := range schemaCases { + t.Run(tc.name, func(t *testing.T) { + want := golden(t, "schema_retry_"+tc.name+".txt") + if got := tc.retry(schemaRetryErrorDetail, goldenCWD); got != want { + t.Errorf("BuildSchemaRetryPrompt[%s] mismatch:\n%s", tc.name, firstDiff(want, got)) + } + }) + } +} + +// TestPyRstrip pins the rstrip() character class, which is wider than +// TrimSuffix("\n") and includes the four ASCII separators str.isspace() counts +// but unicode.IsSpace does not. +func TestPyRstrip(t *testing.T) { + cases := map[string]string{ + "a\n": "a", + "a \t\r\n\v\f": "a", + "a\x1c\x1d\x1e\x1f": "a", + "a ": "a", // NBSP is whitespace to both Python and Go + " a ": " a", + "": "", + "\n\n": "", + } + for in, want := range cases { + if got := pyRstrip(in); got != want { + t.Errorf("pyRstrip(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/go/internal/gates/retry.go b/go/internal/gates/retry.go new file mode 100644 index 0000000..74cfce5 --- /dev/null +++ b/go/internal/gates/retry.go @@ -0,0 +1,181 @@ +// Package gates ports src/sec_af/harness.py — the two thin wrappers SEC-AF +// puts between its phases and the AgentField SDK. +// +// - AIGateWrapper (here: AIGate) is LIVE: orchestrator.py builds one in its +// constructor and calls assess_reachability during scoring, phases.py calls +// select_strategy before the hunt fan-out, and compliance/mapping.py calls +// invoke directly for the compliance gate. +// - HarnessWrapper is NOT reachable from any live path — nothing constructs +// one, and build_ai_integration (the only place that would) has no callers. +// It is ported anyway, because the port is meant to be 1:1 and because its +// prompt-assembly helpers (PHASE_GUIDANCE, SchemaGuidance, +// WithPhaseGuidance, WithFileWriteHint, BuildSchemaRetryPrompt) are pure +// string functions worth pinning with golden tests. +// +// The package is called `gates` rather than `harness` so it cannot be confused +// with the SDK's harness package, and because internal/harnessx already owns +// the piece of harness.py-adjacent behavior that IS on the live path +// (extract_harness_result). +package gates + +import ( + "context" + "errors" + "math" + "strings" + "time" + + "github.com/Agent-Field/sec-af/go/internal/config" +) + +// transientPatterns ports harness.py _TRANSIENT_PATTERNS, in Python order. +// +// Python parity: these are substring probes against the LOWERCASED error text, +// and three of them ("503", "502", "504", "500") are bare status codes, so any +// message containing those digits anywhere — a port number, a line number, a +// byte count — counts as transient and buys a retry. That is deliberately +// preserved; it is the behavior the live node has. +var transientPatterns = []string{ + "rate limit", + "rate_limit", + "overloaded", + "timeout", + "timed out", + "connection reset", + "connection refused", + "temporarily unavailable", + "service unavailable", + "503", + "502", + "504", + "internal server error", + "500", +} + +// AIIntegrationError ports harness.py's `class AIIntegrationError(RuntimeError)`. +type AIIntegrationError struct { + Message string +} + +func (e *AIIntegrationError) Error() string { return e.Message } + +// newAIIntegrationError is the `raise AIIntegrationError(msg)` shorthand. +func newAIIntegrationError(msg string) error { return &AIIntegrationError{Message: msg} } + +// IsTransientError ports harness.py _is_transient_error: +// +// lowered = error.lower() +// return any(pattern in lowered for pattern in _TRANSIENT_PATTERNS) +func IsTransientError(errText string) bool { + lowered := strings.ToLower(errText) + for _, pattern := range transientPatterns { + if strings.Contains(lowered, pattern) { + return true + } + } + return false +} + +// Sleeper is the injectable stand-in for `await asyncio.sleep(seconds)`. +// +// Tests supply a recorder so the backoff schedule can be asserted without the +// wall-clock wait; production uses sleepReal. +type Sleeper func(ctx context.Context, d time.Duration) + +// sleepReal is the default Sleeper. It honors context cancellation, which +// asyncio.sleep also does (the task is cancelled), so a shutting-down node does +// not sit out an 8-second backoff. +func sleepReal(ctx context.Context, d time.Duration) { + if d <= 0 { + return + } + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + case <-timer.C: + } +} + +// backoffFor computes the delay before the retry that follows attempt n +// (0-based), reproducing +// +// min(config.initial_backoff_seconds * (2 ** attempt), config.max_backoff_seconds) +// +// as a time.Duration. Both operands are seconds-as-float in Python, so the +// multiplication happens in float64 here too and only the final value is +// converted. +func backoffFor(cfg config.AIIntegrationConfig, attempt int) time.Duration { + delay := cfg.InitialBackoffSeconds * math.Pow(2, float64(attempt)) + if delay > cfg.MaxBackoffSeconds { + delay = cfg.MaxBackoffSeconds + } + if delay <= 0 { + return 0 + } + return time.Duration(delay * float64(time.Second)) +} + +// runWithRetry ports _RetryMixin._run_with_retry: +// +// last_error = None +// for attempt in range(config.max_retries + 1): +// try: +// return await operation() +// except Exception as exc: +// last_error = exc +// if attempt >= config.max_retries or not _is_transient_error(str(exc)): +// raise +// await asyncio.sleep(min(initial * 2**attempt, max)) +// if last_error is not None: +// raise last_error +// raise AIIntegrationError("AI operation failed without an error payload") +// +// Python parity notes: +// +// - max_retries is a count of RETRIES, so the loop makes max_retries+1 +// attempts in total; max_retries == 0 means "one attempt, never retry". +// - The transience test runs on `str(exc)`, i.e. the message only. Go's +// err.Error() is the same text. +// - A NEGATIVE max_retries makes `range()` empty, so nothing is ever +// attempted, last_error stays None and the function raises the +// "failed without an error payload" AIIntegrationError. That dead-looking +// branch is reproduced rather than smoothed over, because config values +// come from the environment (SEC_AF_AI_MAX_RETRIES=-1 reaches it). +// - There is NO context-cancellation check between attempts beyond the one +// inside the Sleeper: Python's loop has none either, and the operation +// itself carries the ctx. +func runWithRetry[T any](ctx context.Context, cfg config.AIIntegrationConfig, sleep Sleeper, operation func() (T, error)) (T, error) { + var zero T + var lastError error + attempted := false + + for attempt := 0; attempt < cfg.MaxRetries+1; attempt++ { + attempted = true + v, err := operation() + if err == nil { + return v, nil + } + lastError = err + if attempt >= cfg.MaxRetries || !IsTransientError(err.Error()) { + return zero, err + } + sleep(ctx, backoffFor(cfg, attempt)) + } + + if lastError != nil { + return zero, lastError + } + _ = attempted + return zero, newAIIntegrationError("AI operation failed without an error payload") +} + +// AsAIIntegrationError reports whether err is (or wraps) an AIIntegrationError, +// the Go form of `except AIIntegrationError`. +func AsAIIntegrationError(err error) (*AIIntegrationError, bool) { + var target *AIIntegrationError + if errors.As(err, &target) { + return target, true + } + return nil, false +} diff --git a/go/internal/gates/retry_test.go b/go/internal/gates/retry_test.go new file mode 100644 index 0000000..88a91c3 --- /dev/null +++ b/go/internal/gates/retry_test.go @@ -0,0 +1,264 @@ +package gates + +// Parity tests for harness.py _is_transient_error, _RetryMixin._run_with_retry +// and the backoff schedule. + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/config" +) + +// TestIsTransientError pins _TRANSIENT_PATTERNS, including the three bare +// status-code patterns whose over-eagerness is real behavior, not a bug to fix. +func TestIsTransientError(t *testing.T) { + transient := []string{ + "Rate limit exceeded", + "rate_limit_error", + "Model is OVERLOADED", + "request timeout after 60s", + "the call timed out", + "connection reset by peer", + "connection refused", + "Service temporarily unavailable", + "503 Service Unavailable", + "502 Bad Gateway", + "504 Gateway Timeout", + "Internal Server Error", + "HTTP 500", + // Python parity: the bare "500" pattern matches ANY message containing + // those digits. This is over-eager and deliberately preserved. + "failed to parse line 500 of the report", + "listening on port 5027", + } + for _, message := range transient { + if !IsTransientError(message) { + t.Errorf("IsTransientError(%q) = false, want true", message) + } + } + + permanent := []string{ + "", + "bad request: unsupported model", + "invalid api key", + "context length exceeded", + "schema validation failed for field severity", + } + for _, message := range permanent { + if IsTransientError(message) { + t.Errorf("IsTransientError(%q) = true, want false", message) + } + } +} + +// TestBackoffSchedule pins `min(initial * 2**attempt, max)`. +func TestBackoffSchedule(t *testing.T) { + cfg := config.AIIntegrationConfig{InitialBackoffSeconds: 2.0, MaxBackoffSeconds: 8.0} + want := []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second, 8 * time.Second} + for attempt, wantDelay := range want { + if got := backoffFor(cfg, attempt); got != wantDelay { + t.Errorf("backoffFor(attempt=%d) = %v, want %v", attempt, got, wantDelay) + } + } + + // Fractional seconds survive the float multiplication. + frac := config.AIIntegrationConfig{InitialBackoffSeconds: 0.5, MaxBackoffSeconds: 10} + if got := backoffFor(frac, 1); got != time.Second { + t.Errorf("backoffFor(0.5s, attempt=1) = %v, want 1s", got) + } + // A zero or negative schedule never sleeps. + if got := backoffFor(config.AIIntegrationConfig{}, 0); got != 0 { + t.Errorf("backoffFor(zero config) = %v, want 0", got) + } +} + +// flakyAI answers with a transient error `failures` times, then succeeds. +func flakyAI(failures int, body string) (func(context.Context, string, ...ai.Option) (*ai.Response, error), *int) { + calls := 0 + fn := func(context.Context, string, ...ai.Option) (*ai.Response, error) { + calls++ + if calls <= failures { + return nil, errors.New("rate limit exceeded, retry later") + } + return &ai.Response{Choices: []ai.Choice{{Message: ai.Message{ + Role: "assistant", + Content: []ai.ContentPart{{Type: "text", Text: body}}, + }}}}, nil + } + return fn, &calls +} + +// recordingSleeper captures the backoff delays instead of waiting them out. +type recordingSleeper struct { + mu sync.Mutex + delays []time.Duration +} + +func (r *recordingSleeper) sleep(_ context.Context, d time.Duration) { + r.mu.Lock() + r.delays = append(r.delays, d) + r.mu.Unlock() +} + +// TestRetryOnTransientError pins the retry loop: max_retries+1 attempts, the +// exact backoff schedule between them, and one invocation counted for the whole +// sequence. +func TestRetryOnTransientError(t *testing.T) { + ctx := context.Background() + fn, calls := flakyAI(2, `{"severity":"high","confidence":0.9,"rationale":"r"}`) + sleeper := &recordingSleeper{} + gate := &AIGate{App: &appx.Fake{AIFn: fn}, Config: testConfig(), Sleep: sleeper.sleep} + + if _, err := gate.ClassifySeverity(ctx, "summary"); err != nil { + t.Fatalf("ClassifySeverity: %v", err) + } + if *calls != 3 { + t.Errorf("attempts = %d, want 3 (two failures then a success)", *calls) + } + want := []time.Duration{2 * time.Second, 4 * time.Second} + if len(sleeper.delays) != len(want) { + t.Fatalf("slept %v, want %v", sleeper.delays, want) + } + for i := range want { + if sleeper.delays[i] != want[i] { + t.Errorf("delay[%d] = %v, want %v", i, sleeper.delays[i], want[i]) + } + } + if got := gate.InvocationCount(); got != 1 { + t.Errorf("InvocationCount = %d, want 1 (retries are not new invocations)", got) + } +} + +// TestRetryGivesUpAfterMaxRetries pins the `attempt >= config.max_retries` +// bound: max_retries+1 attempts total, then the last error propagates. +func TestRetryGivesUpAfterMaxRetries(t *testing.T) { + ctx := context.Background() + fn, calls := flakyAI(99, "") + sleeper := &recordingSleeper{} + gate := &AIGate{App: &appx.Fake{AIFn: fn}, Config: testConfig(), Sleep: sleeper.sleep} + + if _, err := gate.ClassifySeverity(ctx, "summary"); err == nil { + t.Fatal("expected the last error to propagate") + } + if *calls != testConfig().MaxRetries+1 { + t.Errorf("attempts = %d, want max_retries+1 = %d", *calls, testConfig().MaxRetries+1) + } + if len(sleeper.delays) != testConfig().MaxRetries { + t.Errorf("slept %d times, want max_retries = %d", len(sleeper.delays), testConfig().MaxRetries) + } +} + +// TestRetryZeroMaxRetries pins that max_retries=0 means exactly one attempt. +func TestRetryZeroMaxRetries(t *testing.T) { + ctx := context.Background() + fn, calls := flakyAI(99, "") + cfg := testConfig() + cfg.MaxRetries = 0 + sleeper := &recordingSleeper{} + gate := &AIGate{App: &appx.Fake{AIFn: fn}, Config: cfg, Sleep: sleeper.sleep} + + if _, err := gate.ClassifySeverity(ctx, "summary"); err == nil { + t.Fatal("expected an error") + } + if *calls != 1 { + t.Errorf("attempts = %d, want 1", *calls) + } + if len(sleeper.delays) != 0 { + t.Errorf("slept %v, want nothing", sleeper.delays) + } +} + +// TestRetryNegativeMaxRetriesRaisesTheEmptyPayloadError pins the branch Python +// reaches when `range(max_retries + 1)` is empty: nothing is attempted, +// last_error stays None, and AIIntegrationError("AI operation failed without an +// error payload") is raised. SEC_AF_AI_MAX_RETRIES=-1 in the environment is how +// a deployment gets here. +func TestRetryNegativeMaxRetriesRaisesTheEmptyPayloadError(t *testing.T) { + ctx := context.Background() + cfg := testConfig() + cfg.MaxRetries = -1 + fake := &appx.Fake{AIFn: appx.AIJSON(func(string) (json.RawMessage, error) { + return json.RawMessage(`{"severity":"low","confidence":0,"rationale":""}`), nil + })} + gate := &AIGate{App: fake, Config: cfg} + + _, err := gate.ClassifySeverity(ctx, "summary") + if err == nil { + t.Fatal("expected an error") + } + aiErr, ok := AsAIIntegrationError(err) + if !ok { + t.Fatalf("error = %v (%T), want an AIIntegrationError", err, err) + } + if aiErr.Message != "AI operation failed without an error payload" { + t.Errorf("message = %q", aiErr.Message) + } + if len(fake.AIs) != 0 { + t.Errorf("nothing should have been attempted; got %d calls", len(fake.AIs)) + } +} + +// TestRunWithRetryReturnsTheValue is a direct unit test of the generic helper, +// independent of the gate that uses it. +func TestRunWithRetryReturnsTheValue(t *testing.T) { + ctx := context.Background() + cfg := config.AIIntegrationConfig{MaxRetries: 2, InitialBackoffSeconds: 1, MaxBackoffSeconds: 4} + sleeper := &recordingSleeper{} + + attempts := 0 + got, err := runWithRetry(ctx, cfg, sleeper.sleep, func() (string, error) { + attempts++ + if attempts < 2 { + return "", errors.New("connection reset by peer") + } + return "ok", nil + }) + if err != nil { + t.Fatalf("runWithRetry: %v", err) + } + if got != "ok" { + t.Errorf("value = %q, want ok", got) + } + if attempts != 2 { + t.Errorf("attempts = %d, want 2", attempts) + } +} + +// TestAIIntegrationErrorUnwrapping pins that AsAIIntegrationError finds the +// error through a wrapping chain, the Go analogue of `except AIIntegrationError`. +func TestAIIntegrationErrorUnwrapping(t *testing.T) { + base := newAIIntegrationError("boom") + wrapped := errors.Join(errors.New("context"), base) + if got, ok := AsAIIntegrationError(wrapped); !ok || got.Message != "boom" { + t.Errorf("AsAIIntegrationError(wrapped) = %v, %v", got, ok) + } + if _, ok := AsAIIntegrationError(errors.New("plain")); ok { + t.Error("a plain error must not be reported as an AIIntegrationError") + } + if base.Error() != "boom" { + t.Errorf("Error() = %q", base.Error()) + } +} + +// TestSleepRealHonorsContextCancellation guards against a shutting-down node +// sitting out a full 8-second backoff. +func TestSleepRealHonorsContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + start := time.Now() + sleepReal(ctx, time.Hour) + if elapsed := time.Since(start); elapsed > time.Second { + t.Errorf("sleepReal ignored cancellation; waited %v", elapsed) + } + // A non-positive delay returns immediately without allocating a timer. + sleepReal(context.Background(), 0) +} diff --git a/go/internal/gates/testdata/golden/ai_gate_assess_reachability.txt b/go/internal/gates/testdata/golden/ai_gate_assess_reachability.txt new file mode 100644 index 0000000..1fe7320 --- /dev/null +++ b/go/internal/gates/testdata/golden/ai_gate_assess_reachability.txt @@ -0,0 +1,3 @@ +Assess the reachability of this security finding. Determine if it is externally_reachable, requires_auth, internal_only, or unreachable. Consider the attack surface, authentication requirements, and network exposure. + +Hardcoded AWS key in config/prod.yaml:12, repository is public. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/ai_gate_check_duplicate.txt b/go/internal/gates/testdata/golden/ai_gate_check_duplicate.txt new file mode 100644 index 0000000..cdbb723 --- /dev/null +++ b/go/internal/gates/testdata/golden/ai_gate_check_duplicate.txt @@ -0,0 +1,4 @@ +Decide whether candidate finding is a duplicate of existing finding. Return duplicate decision only. + +Candidate: {'id': 'finding-1', 'file_path': 'app/db/raw.py', 'start_line': 42, 'cwe_id': 'CWE-89', 'confirmed': True, 'score': 9.5, 'notes': None} +Existing: {'id': 'finding-0', 'file_path': 'app/db/raw.py', 'start_line': 41, 'cwe_id': 'CWE-89', 'confirmed': False, 'score': 1.0, 'notes': 'seen before'} \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/ai_gate_classify_severity.txt b/go/internal/gates/testdata/golden/ai_gate_classify_severity.txt new file mode 100644 index 0000000..c9a6e8f --- /dev/null +++ b/go/internal/gates/testdata/golden/ai_gate_classify_severity.txt @@ -0,0 +1,3 @@ +Classify severity for this potential security finding. Use only critical/high/medium/low and keep rationale brief. + +SQL injection in app/db/raw.py:42 — request.args['q'] reaches cursor.execute unsanitized. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/ai_gate_select_strategy_empty_candidates.txt b/go/internal/gates/testdata/golden/ai_gate_select_strategy_empty_candidates.txt new file mode 100644 index 0000000..20ca59a --- /dev/null +++ b/go/internal/gates/testdata/golden/ai_gate_select_strategy_empty_candidates.txt @@ -0,0 +1,6 @@ +Select SEC-AF hunt strategies from recon context. Return only selected strategies and rationale. +Depth profile: quick +Default candidates: [] +Recon summary: General recon summary. + +Profile: 3 files, 120 LOC. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/ai_gate_select_strategy_standard.txt b/go/internal/gates/testdata/golden/ai_gate_select_strategy_standard.txt new file mode 100644 index 0000000..5c98c5c --- /dev/null +++ b/go/internal/gates/testdata/golden/ai_gate_select_strategy_standard.txt @@ -0,0 +1,6 @@ +Select SEC-AF hunt strategies from recon context. Return only selected strategies and rationale. +Depth profile: standard +Default candidates: ['injection', 'auth', 'crypto'] +Recon summary: General recon summary. + +Profile: 3 files, 120 LOC. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/phase_guidance.json b/go/internal/gates/testdata/golden/phase_guidance.json new file mode 100644 index 0000000..f1b7961 --- /dev/null +++ b/go/internal/gates/testdata/golden/phase_guidance.json @@ -0,0 +1,5 @@ +{ + "hunt": "APPROACH: You are hunting for a specific class of security vulnerability with recon context.\nPROCESS:\n1. Review the recon context to understand the codebase topology\n2. Identify files and patterns relevant to your specific vulnerability class\n3. For each candidate: read the code, trace data flow, assess exploitability\n4. Only report findings where you have concrete code evidence\nCONSTRAINTS:\n- Every finding MUST cite specific file paths and line numbers you have read\n- Do not report theoretical vulnerabilities without code evidence\n- False negatives are better than false positives\n- If a file is sanitized properly, do NOT report it", + "prove": "APPROACH: You are verifying a specific candidate vulnerability for exploitability.\nPROCESS:\n1. Read the specific code location cited in the finding\n2. Trace the data flow from source to sink\n3. Check for sanitization, validation, or other mitigations on the path\n4. If exploitable, construct a concrete exploit hypothesis\n5. Synthesize your verdict with evidence level\nCONSTRAINTS:\n- You must READ the actual code \u2014 do not rely on the finding description alone\n- INCONCLUSIVE is a valid verdict \u2014 do not force confirmation or denial\n- Cite specific lines where sanitization exists or is missing\n- If code has changed since the finding was generated, note the discrepancy", + "recon": "APPROACH: You are performing reconnaissance on a codebase to build an accurate structural map.\nPROCESS:\n1. Survey the codebase structure \u2014 identify key directories, entry points, and configuration\n2. Identify the technology stack \u2014 languages, frameworks, and external services\n3. Map security-relevant boundaries \u2014 auth layers, data inputs, API surfaces\n4. Only after surveying, synthesize findings into the required schema\nCONSTRAINTS:\n- Report what IS there, not what MIGHT be there\n- If uncertain about a detail, omit it rather than guess\n- Prioritize breadth over depth \u2014 cover the full surface" +} diff --git a/go/internal/gates/testdata/golden/schema_guidance_ArchitectureMapRaw.txt b/go/internal/gates/testdata/golden/schema_guidance_ArchitectureMapRaw.txt new file mode 100644 index 0000000..71db987 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_ArchitectureMapRaw.txt @@ -0,0 +1,10 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. +- Field guidance from schema descriptions: +- `app_type`: Application type: web_api, cli_tool, library, microservice, monolith +- `modules`: One string per module. Format: 'name | path | language | description'. Example: 'auth | src/auth/ | python | Authentication and session management' +- `entry_points`: One string per entry point. Format: 'kind | route_or_id | file_path:line | auth_required'. Example: 'http | POST /api/login | src/routes.py:42 | false' +- `trust_boundaries`: One string per boundary. Format: 'name | source_zone | target_zone | description'. Example: 'API Gateway | external | internal | Rate limiting and auth' +- `services`: One string per external service. Format: 'name | type | endpoint | auth_mechanism'. Example: 'PostgreSQL | database | localhost:5432 | password' +- `api_endpoints`: One string per API endpoint. Format: 'method | path | handler | file_path:line | auth_required | rate_limited'. Example: 'GET | /api/users | get_users | src/api.py:15 | true | false' \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_CWEExpansion.txt b/go/internal/gates/testdata/golden/schema_guidance_CWEExpansion.txt new file mode 100644 index 0000000..aab9b48 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_CWEExpansion.txt @@ -0,0 +1,5 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. +- Field guidance from schema descriptions: +- `additional_cwes`: CWE IDs to add beyond baseline, e.g. ['CWE-918', 'CWE-611']. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_ChainCorrelationResult.txt b/go/internal/gates/testdata/golden/schema_guidance_ChainCorrelationResult.txt new file mode 100644 index 0000000..c9a299d --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_ChainCorrelationResult.txt @@ -0,0 +1,6 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. +- Field guidance from schema descriptions: +- `chains`: Multi-step attack chains found. Format per entry: 'title | finding_id1,finding_id2,... | combined_impact | severity'. Example: 'SSRF to Internal API | f1,f2 | Access internal services | high' +- `duplicate_ids`: Finding IDs that are duplicates missed by programmatic dedup (to drop) \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_ComplianceGate.txt b/go/internal/gates/testdata/golden/schema_guidance_ComplianceGate.txt new file mode 100644 index 0000000..fc721ca --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_ComplianceGate.txt @@ -0,0 +1,3 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_ConfigReportRaw.txt b/go/internal/gates/testdata/golden/schema_guidance_ConfigReportRaw.txt new file mode 100644 index 0000000..c7937a1 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_ConfigReportRaw.txt @@ -0,0 +1,6 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. +- Field guidance from schema descriptions: +- `secrets`: One string per secret finding. Format: 'type | file_path:line | match_preview | confidence | is_test(true/false)'. Example: 'aws_access_key | .env:3 | AKIA... | high | false' +- `misconfigs`: One string per misconfiguration. Format: 'category | file_path:line | key | risk | remediation'. Example: 'debug_mode | config.py:15 | DEBUG=True | Exposes stack traces | Set DEBUG=False' \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_CrossServiceFinding.txt b/go/internal/gates/testdata/golden/schema_guidance_CrossServiceFinding.txt new file mode 100644 index 0000000..7d15b4c --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_CrossServiceFinding.txt @@ -0,0 +1,8 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. +- Field guidance from schema descriptions: +- `chain_description`: Description of the cross-service attack path +- `services_involved`: Service names in the attack chain +- `entry_point`: Public-facing entry point where attack begins +- `impact`: Impact if the cross-service chain is exploited \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_DastVerificationResult.txt b/go/internal/gates/testdata/golden/schema_guidance_DastVerificationResult.txt new file mode 100644 index 0000000..f0c852f --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_DastVerificationResult.txt @@ -0,0 +1,8 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. +- Field guidance from schema descriptions: +- `payload_sent`: The exploit payload or request that was sent +- `response_summary`: Summary of the application response +- `exploit_confirmed`: Whether the exploit was confirmed at runtime +- `safety_notes`: Safety measures taken during verification (sandbox, timeout, etc.) \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_DataFlowMapRaw.txt b/go/internal/gates/testdata/golden/schema_guidance_DataFlowMapRaw.txt new file mode 100644 index 0000000..bedcbcd --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_DataFlowMapRaw.txt @@ -0,0 +1,7 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. +- Field guidance from schema descriptions: +- `flows`: One string per data flow. Format: 'source | sink | sanitized(true/false) | file1, file2, ...'. Example: 'request.body | sql.execute | false | src/db.py, src/routes.py' +- `sanitization_points`: One string per sanitization point. Format: 'file_path:line | function_name | type | protects_against'. Example: 'src/utils.py:42 | sanitize_html | html_encoding | CWE-79' +- `sinks`: One string per security-critical sink. Format: 'sink_type | file_path:line | function_name | notes'. Example: 'sql_execute | src/db.py:55 | run_query | Direct string concatenation' \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_DataFlowTrace.txt b/go/internal/gates/testdata/golden/schema_guidance_DataFlowTrace.txt new file mode 100644 index 0000000..7654609 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_DataFlowTrace.txt @@ -0,0 +1,8 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. +- Field guidance from schema descriptions: +- `source`: Where tainted input enters (e.g. 'request.params.id') +- `sink`: Security-sensitive operation reached (e.g. 'sql.execute(query)') +- `steps`: Ordered list of file:line descriptions showing flow path +- `sink_reached`: Whether tainted data actually reaches the sink \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_DependencyReportRaw.txt b/go/internal/gates/testdata/golden/schema_guidance_DependencyReportRaw.txt new file mode 100644 index 0000000..739755d --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_DependencyReportRaw.txt @@ -0,0 +1,7 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. +- Field guidance from schema descriptions: +- `sbom`: One string per dependency. Format: 'name | version | ecosystem | direct(true/false) | license'. Example: 'express | 4.18.2 | npm | true | MIT' +- `known_cves`: One string per CVE. Format: 'cve_id | package | installed_version | fixed_version | cvss_score | direct | reachable'. Example: 'CVE-2023-1234 | lodash | 4.17.15 | 4.17.21 | 7.5 | true | unknown' +- `outdated`: One string per outdated dep. Format: 'package | current_version | latest_version | direct(true/false)'. Example: 'express | 4.17.0 | 4.18.2 | true' \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_DuplicateCheck.txt b/go/internal/gates/testdata/golden/schema_guidance_DuplicateCheck.txt new file mode 100644 index 0000000..fc721ca --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_DuplicateCheck.txt @@ -0,0 +1,3 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_EnrichedFinding.txt b/go/internal/gates/testdata/golden/schema_guidance_EnrichedFinding.txt new file mode 100644 index 0000000..ec9f965 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_EnrichedFinding.txt @@ -0,0 +1,10 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. +- Field guidance from schema descriptions: +- `title`: Human-readable title for the finding +- `description`: Detailed description of the vulnerability +- `cwe_id`: CWE identifier (e.g. 'CWE-89') +- `severity`: One of: "critical", "high", "medium", "low", "info" +- `confidence`: One of: "high", "medium", "low" +- `data_flow_summary`: Natural language summary of the data flow (string, not nested) \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_ExploitHypothesis.txt b/go/internal/gates/testdata/golden/schema_guidance_ExploitHypothesis.txt new file mode 100644 index 0000000..edb2b5d --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_ExploitHypothesis.txt @@ -0,0 +1,7 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. +- Field guidance from schema descriptions: +- `hypothesis`: Natural language description of exploit scenario +- `payload`: Concrete exploit payload or input +- `expected_outcome`: What would happen if exploit succeeds \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_PolicyEvalResult.txt b/go/internal/gates/testdata/golden/schema_guidance_PolicyEvalResult.txt new file mode 100644 index 0000000..a0f6649 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_PolicyEvalResult.txt @@ -0,0 +1,8 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. +- Field guidance from schema descriptions: +- `violated`: Whether the policy is violated +- `description`: How the policy is violated, or 'No violation' if compliant +- `file_path`: Primary file where violation occurs, or 'N/A' +- `severity`: Severity: "high", "medium", or "low" \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_ReachabilityGate.txt b/go/internal/gates/testdata/golden/schema_guidance_ReachabilityGate.txt new file mode 100644 index 0000000..7adfb60 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_ReachabilityGate.txt @@ -0,0 +1,6 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. +- Field guidance from schema descriptions: +- `reachability`: One of: "externally_reachable", "requires_auth", "internal_only", "unreachable". +- `confidence`: One of: "high", "medium", "low". \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_ReachabilityProof.txt b/go/internal/gates/testdata/golden/schema_guidance_ReachabilityProof.txt new file mode 100644 index 0000000..277112b --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_ReachabilityProof.txt @@ -0,0 +1,8 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. +- Field guidance from schema descriptions: +- `vulnerable_function`: The vulnerable function/method in the dependency +- `call_chain`: Import/call chain from app code to vulnerable function +- `reachable`: Whether the vulnerable function is actually called +- `direct`: Whether the dependency is direct or transitive \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_RemediationSuggestion.txt b/go/internal/gates/testdata/golden/schema_guidance_RemediationSuggestion.txt new file mode 100644 index 0000000..2feca45 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_RemediationSuggestion.txt @@ -0,0 +1,7 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. +- Field guidance from schema descriptions: +- `fix_description`: Natural language description of the recommended fix +- `patch_diff`: Unified diff format patch showing the code changes needed +- `confidence`: Confidence in the fix: "high", "medium", or "low" \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_SanitizationResult.txt b/go/internal/gates/testdata/golden/schema_guidance_SanitizationResult.txt new file mode 100644 index 0000000..ddadffa --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_SanitizationResult.txt @@ -0,0 +1,8 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. +- Field guidance from schema descriptions: +- `found`: Whether any sanitization/validation was found on the path +- `type`: Type of sanitization (e.g. 'parameterized query', 'html encoding') +- `sufficient`: Whether sanitization is sufficient to prevent exploit +- `bypass_method`: How sanitization could be bypassed, if applicable \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_ScanLocationsResult.txt b/go/internal/gates/testdata/golden/schema_guidance_ScanLocationsResult.txt new file mode 100644 index 0000000..fc721ca --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_ScanLocationsResult.txt @@ -0,0 +1,3 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_SecurityContextRaw.txt b/go/internal/gates/testdata/golden/schema_guidance_SecurityContextRaw.txt new file mode 100644 index 0000000..cfbbad7 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_SecurityContextRaw.txt @@ -0,0 +1,8 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. +- Field guidance from schema descriptions: +- `auth_model`: Authentication model: jwt, session_cookie, oauth2, api_key, none, or other +- `auth_details`: Brief description of auth implementation details +- `crypto_usage`: One string per crypto usage. Format: 'algorithm | key_size | mode | usage_context | is_weak(true/false)'. Example: 'AES | 256 | GCM | data encryption | false' +- `security_signals`: Framework security features, security headers, and deployment signals. One signal per entry. Examples: 'CSRF protection enabled', 'HSTS header present', 'Runs in Docker' \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_SeverityClassification.txt b/go/internal/gates/testdata/golden/schema_guidance_SeverityClassification.txt new file mode 100644 index 0000000..94c3223 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_SeverityClassification.txt @@ -0,0 +1,5 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. +- Field guidance from schema descriptions: +- `severity`: One of: "critical", "high", "medium", "low". \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_StrategySelection.txt b/go/internal/gates/testdata/golden/schema_guidance_StrategySelection.txt new file mode 100644 index 0000000..fc721ca --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_StrategySelection.txt @@ -0,0 +1,3 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_guidance_VerdictDecision.txt b/go/internal/gates/testdata/golden/schema_guidance_VerdictDecision.txt new file mode 100644 index 0000000..7980b05 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_guidance_VerdictDecision.txt @@ -0,0 +1,7 @@ +Output format: +- Return valid JSON only (no markdown fences, no extra text). +- Follow the provided Pydantic schema exactly. +- Field guidance from schema descriptions: +- `verdict`: One of: "confirmed", "likely", "inconclusive", "not_exploitable" +- `evidence_level`: 1-6 scale: 1=STATIC_MATCH to 6=FULL_EXPLOIT +- `confidence`: One of: "high", "medium", "low" \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_ArchitectureMapRaw.txt b/go/internal/gates/testdata/golden/schema_retry_ArchitectureMapRaw.txt new file mode 100644 index 0000000..0618249 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_ArchitectureMapRaw.txt @@ -0,0 +1,62 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "description": "Flat harness output for architecture mapper. All list[str], no nesting.", + "properties": { + "app_type": { + "default": "unknown", + "description": "Application type: web_api, cli_tool, library, microservice, monolith", + "title": "App Type", + "type": "string" + }, + "modules": { + "description": "One string per module. Format: 'name | path | language | description'. Example: 'auth | src/auth/ | python | Authentication and session management'", + "items": { + "type": "string" + }, + "title": "Modules", + "type": "array" + }, + "entry_points": { + "description": "One string per entry point. Format: 'kind | route_or_id | file_path:line | auth_required'. Example: 'http | POST /api/login | src/routes.py:42 | false'", + "items": { + "type": "string" + }, + "title": "Entry Points", + "type": "array" + }, + "trust_boundaries": { + "description": "One string per boundary. Format: 'name | source_zone | target_zone | description'. Example: 'API Gateway | external | internal | Rate limiting and auth'", + "items": { + "type": "string" + }, + "title": "Trust Boundaries", + "type": "array" + }, + "services": { + "description": "One string per external service. Format: 'name | type | endpoint | auth_mechanism'. Example: 'PostgreSQL | database | localhost:5432 | password'", + "items": { + "type": "string" + }, + "title": "Services", + "type": "array" + }, + "api_endpoints": { + "description": "One string per API endpoint. Format: 'method | path | handler | file_path:line | auth_required | rate_limited'. Example: 'GET | /api/users | get_users | src/api.py:15 | true | false'", + "items": { + "type": "string" + }, + "title": "Api Endpoints", + "type": "array" + } + }, + "title": "ArchitectureMapRaw", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_CWEExpansion.txt b/go/internal/gates/testdata/golden/schema_retry_CWEExpansion.txt new file mode 100644 index 0000000..e6ae8d7 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_CWEExpansion.txt @@ -0,0 +1,32 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "description": "AI-suggested CWE additions based on recon context.", + "properties": { + "additional_cwes": { + "description": "CWE IDs to add beyond baseline, e.g. ['CWE-918', 'CWE-611'].", + "items": { + "type": "string" + }, + "title": "Additional Cwes", + "type": "array" + }, + "rationale": { + "title": "Rationale", + "type": "string" + } + }, + "required": [ + "additional_cwes", + "rationale" + ], + "title": "CWEExpansion", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_ChainCorrelationResult.txt b/go/internal/gates/testdata/golden/schema_retry_ChainCorrelationResult.txt new file mode 100644 index 0000000..6e770d3 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_ChainCorrelationResult.txt @@ -0,0 +1,32 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "description": "Flat harness schema for chain correlation. LLM identifies chains only.", + "properties": { + "chains": { + "description": "Multi-step attack chains found. Format per entry: 'title | finding_id1,finding_id2,... | combined_impact | severity'. Example: 'SSRF to Internal API | f1,f2 | Access internal services | high'", + "items": { + "type": "string" + }, + "title": "Chains", + "type": "array" + }, + "duplicate_ids": { + "description": "Finding IDs that are duplicates missed by programmatic dedup (to drop)", + "items": { + "type": "string" + }, + "title": "Duplicate Ids", + "type": "array" + } + }, + "title": "ChainCorrelationResult", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_ComplianceGate.txt b/go/internal/gates/testdata/golden/schema_retry_ComplianceGate.txt new file mode 100644 index 0000000..c00d06b --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_ComplianceGate.txt @@ -0,0 +1,55 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "$defs": { + "ComplianceSuggestion": { + "properties": { + "framework": { + "title": "Framework", + "type": "string" + }, + "control_id": { + "title": "Control Id", + "type": "string" + }, + "control_name": { + "title": "Control Name", + "type": "string" + } + }, + "required": [ + "framework", + "control_id", + "control_name" + ], + "title": "ComplianceSuggestion", + "type": "object" + } + }, + "properties": { + "mappings": { + "items": { + "$ref": "#/$defs/ComplianceSuggestion" + }, + "title": "Mappings", + "type": "array" + }, + "confidence": { + "title": "Confidence", + "type": "string" + } + }, + "required": [ + "mappings", + "confidence" + ], + "title": "ComplianceGate", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_ConfigReportRaw.txt b/go/internal/gates/testdata/golden/schema_retry_ConfigReportRaw.txt new file mode 100644 index 0000000..22fc490 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_ConfigReportRaw.txt @@ -0,0 +1,32 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "description": "Flat harness output for config scanner. All list[str], no nesting.", + "properties": { + "secrets": { + "description": "One string per secret finding. Format: 'type | file_path:line | match_preview | confidence | is_test(true/false)'. Example: 'aws_access_key | .env:3 | AKIA... | high | false'", + "items": { + "type": "string" + }, + "title": "Secrets", + "type": "array" + }, + "misconfigs": { + "description": "One string per misconfiguration. Format: 'category | file_path:line | key | risk | remediation'. Example: 'debug_mode | config.py:15 | DEBUG=True | Exposes stack traces | Set DEBUG=False'", + "items": { + "type": "string" + }, + "title": "Misconfigs", + "type": "array" + } + }, + "title": "ConfigReportRaw", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_CrossServiceFinding.txt b/go/internal/gates/testdata/golden/schema_retry_CrossServiceFinding.txt new file mode 100644 index 0000000..062fd83 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_CrossServiceFinding.txt @@ -0,0 +1,45 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "description": "Flat schema for cross-service attack chain analysis. 4 fields.", + "properties": { + "chain_description": { + "description": "Description of the cross-service attack path", + "title": "Chain Description", + "type": "string" + }, + "services_involved": { + "description": "Service names in the attack chain", + "items": { + "type": "string" + }, + "title": "Services Involved", + "type": "array" + }, + "entry_point": { + "description": "Public-facing entry point where attack begins", + "title": "Entry Point", + "type": "string" + }, + "impact": { + "description": "Impact if the cross-service chain is exploited", + "title": "Impact", + "type": "string" + } + }, + "required": [ + "chain_description", + "services_involved", + "entry_point", + "impact" + ], + "title": "CrossServiceFinding", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_DastVerificationResult.txt b/go/internal/gates/testdata/golden/schema_retry_DastVerificationResult.txt new file mode 100644 index 0000000..1b54f03 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_DastVerificationResult.txt @@ -0,0 +1,42 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "description": "Flat schema for DAST-like runtime verification. 4 fields.", + "properties": { + "payload_sent": { + "description": "The exploit payload or request that was sent", + "title": "Payload Sent", + "type": "string" + }, + "response_summary": { + "description": "Summary of the application response", + "title": "Response Summary", + "type": "string" + }, + "exploit_confirmed": { + "description": "Whether the exploit was confirmed at runtime", + "title": "Exploit Confirmed", + "type": "boolean" + }, + "safety_notes": { + "description": "Safety measures taken during verification (sandbox, timeout, etc.)", + "title": "Safety Notes", + "type": "string" + } + }, + "required": [ + "payload_sent", + "response_summary", + "exploit_confirmed", + "safety_notes" + ], + "title": "DastVerificationResult", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_DataFlowMapRaw.txt b/go/internal/gates/testdata/golden/schema_retry_DataFlowMapRaw.txt new file mode 100644 index 0000000..7af7a3d --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_DataFlowMapRaw.txt @@ -0,0 +1,40 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "description": "Flat harness output for data flow mapper. All list[str], no nesting.", + "properties": { + "flows": { + "description": "One string per data flow. Format: 'source | sink | sanitized(true/false) | file1, file2, ...'. Example: 'request.body | sql.execute | false | src/db.py, src/routes.py'", + "items": { + "type": "string" + }, + "title": "Flows", + "type": "array" + }, + "sanitization_points": { + "description": "One string per sanitization point. Format: 'file_path:line | function_name | type | protects_against'. Example: 'src/utils.py:42 | sanitize_html | html_encoding | CWE-79'", + "items": { + "type": "string" + }, + "title": "Sanitization Points", + "type": "array" + }, + "sinks": { + "description": "One string per security-critical sink. Format: 'sink_type | file_path:line | function_name | notes'. Example: 'sql_execute | src/db.py:55 | run_query | Direct string concatenation'", + "items": { + "type": "string" + }, + "title": "Sinks", + "type": "array" + } + }, + "title": "DataFlowMapRaw", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_DataFlowTrace.txt b/go/internal/gates/testdata/golden/schema_retry_DataFlowTrace.txt new file mode 100644 index 0000000..d03b8d7 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_DataFlowTrace.txt @@ -0,0 +1,45 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "description": "Flat schema for data flow tracing sub-agent. 4 fields.", + "properties": { + "source": { + "description": "Where tainted input enters (e.g. 'request.params.id')", + "title": "Source", + "type": "string" + }, + "sink": { + "description": "Security-sensitive operation reached (e.g. 'sql.execute(query)')", + "title": "Sink", + "type": "string" + }, + "steps": { + "description": "Ordered list of file:line descriptions showing flow path", + "items": { + "type": "string" + }, + "title": "Steps", + "type": "array" + }, + "sink_reached": { + "description": "Whether tainted data actually reaches the sink", + "title": "Sink Reached", + "type": "boolean" + } + }, + "required": [ + "source", + "sink", + "steps", + "sink_reached" + ], + "title": "DataFlowTrace", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_DependencyReportRaw.txt b/go/internal/gates/testdata/golden/schema_retry_DependencyReportRaw.txt new file mode 100644 index 0000000..0f2e499 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_DependencyReportRaw.txt @@ -0,0 +1,40 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "description": "Flat harness output for dependency auditor. All list[str], no nesting.", + "properties": { + "sbom": { + "description": "One string per dependency. Format: 'name | version | ecosystem | direct(true/false) | license'. Example: 'express | 4.18.2 | npm | true | MIT'", + "items": { + "type": "string" + }, + "title": "Sbom", + "type": "array" + }, + "known_cves": { + "description": "One string per CVE. Format: 'cve_id | package | installed_version | fixed_version | cvss_score | direct | reachable'. Example: 'CVE-2023-1234 | lodash | 4.17.15 | 4.17.21 | 7.5 | true | unknown'", + "items": { + "type": "string" + }, + "title": "Known Cves", + "type": "array" + }, + "outdated": { + "description": "One string per outdated dep. Format: 'package | current_version | latest_version | direct(true/false)'. Example: 'express | 4.17.0 | 4.18.2 | true'", + "items": { + "type": "string" + }, + "title": "Outdated", + "type": "array" + } + }, + "title": "DependencyReportRaw", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_DuplicateCheck.txt b/go/internal/gates/testdata/golden/schema_retry_DuplicateCheck.txt new file mode 100644 index 0000000..4c098f4 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_DuplicateCheck.txt @@ -0,0 +1,40 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "description": "DESIGN.md \u00a75.5: quick duplicate check gate for dedup decisions.", + "properties": { + "is_duplicate": { + "title": "Is Duplicate", + "type": "boolean" + }, + "duplicate_of": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Duplicate Of" + }, + "reason": { + "title": "Reason", + "type": "string" + } + }, + "required": [ + "is_duplicate", + "reason" + ], + "title": "DuplicateCheck", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_EnrichedFinding.txt b/go/internal/gates/testdata/golden/schema_retry_EnrichedFinding.txt new file mode 100644 index 0000000..671e69f --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_EnrichedFinding.txt @@ -0,0 +1,54 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "description": "Flat schema for Step 2: finding enrichment. 6 fields.", + "properties": { + "title": { + "description": "Human-readable title for the finding", + "title": "Title", + "type": "string" + }, + "description": { + "description": "Detailed description of the vulnerability", + "title": "Description", + "type": "string" + }, + "cwe_id": { + "description": "CWE identifier (e.g. 'CWE-89')", + "title": "Cwe Id", + "type": "string" + }, + "severity": { + "description": "One of: \"critical\", \"high\", \"medium\", \"low\", \"info\"", + "title": "Severity", + "type": "string" + }, + "confidence": { + "description": "One of: \"high\", \"medium\", \"low\"", + "title": "Confidence", + "type": "string" + }, + "data_flow_summary": { + "description": "Natural language summary of the data flow (string, not nested)", + "title": "Data Flow Summary", + "type": "string" + } + }, + "required": [ + "title", + "description", + "cwe_id", + "severity", + "confidence", + "data_flow_summary" + ], + "title": "EnrichedFinding", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_ExploitHypothesis.txt b/go/internal/gates/testdata/golden/schema_retry_ExploitHypothesis.txt new file mode 100644 index 0000000..7b8d884 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_ExploitHypothesis.txt @@ -0,0 +1,43 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "description": "Flat schema for exploit construction sub-agent. 3 fields.", + "properties": { + "hypothesis": { + "description": "Natural language description of exploit scenario", + "title": "Hypothesis", + "type": "string" + }, + "payload": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Concrete exploit payload or input", + "title": "Payload" + }, + "expected_outcome": { + "description": "What would happen if exploit succeeds", + "title": "Expected Outcome", + "type": "string" + } + }, + "required": [ + "hypothesis", + "expected_outcome" + ], + "title": "ExploitHypothesis", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_PolicyEvalResult.txt b/go/internal/gates/testdata/golden/schema_retry_PolicyEvalResult.txt new file mode 100644 index 0000000..feafd20 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_PolicyEvalResult.txt @@ -0,0 +1,42 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "description": "Flat schema for AI policy evaluation. 4 fields.", + "properties": { + "violated": { + "description": "Whether the policy is violated", + "title": "Violated", + "type": "boolean" + }, + "description": { + "description": "How the policy is violated, or 'No violation' if compliant", + "title": "Description", + "type": "string" + }, + "file_path": { + "description": "Primary file where violation occurs, or 'N/A'", + "title": "File Path", + "type": "string" + }, + "severity": { + "description": "Severity: \"high\", \"medium\", or \"low\"", + "title": "Severity", + "type": "string" + } + }, + "required": [ + "violated", + "description", + "file_path", + "severity" + ], + "title": "PolicyEvalResult", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_ReachabilityGate.txt b/go/internal/gates/testdata/golden/schema_retry_ReachabilityGate.txt new file mode 100644 index 0000000..9c47409 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_ReachabilityGate.txt @@ -0,0 +1,35 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "description": "Reachability assessment for findings without explicit reachability tags.", + "properties": { + "reachability": { + "description": "One of: \"externally_reachable\", \"requires_auth\", \"internal_only\", \"unreachable\".", + "title": "Reachability", + "type": "string" + }, + "rationale": { + "title": "Rationale", + "type": "string" + }, + "confidence": { + "description": "One of: \"high\", \"medium\", \"low\".", + "title": "Confidence", + "type": "string" + } + }, + "required": [ + "reachability", + "rationale", + "confidence" + ], + "title": "ReachabilityGate", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_ReachabilityProof.txt b/go/internal/gates/testdata/golden/schema_retry_ReachabilityProof.txt new file mode 100644 index 0000000..885d73d --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_ReachabilityProof.txt @@ -0,0 +1,45 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "description": "Flat schema for dependency reachability analysis. 4 fields.", + "properties": { + "vulnerable_function": { + "description": "The vulnerable function/method in the dependency", + "title": "Vulnerable Function", + "type": "string" + }, + "call_chain": { + "description": "Import/call chain from app code to vulnerable function", + "items": { + "type": "string" + }, + "title": "Call Chain", + "type": "array" + }, + "reachable": { + "description": "Whether the vulnerable function is actually called", + "title": "Reachable", + "type": "boolean" + }, + "direct": { + "description": "Whether the dependency is direct or transitive", + "title": "Direct", + "type": "boolean" + } + }, + "required": [ + "vulnerable_function", + "call_chain", + "reachable", + "direct" + ], + "title": "ReachabilityProof", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_RemediationSuggestion.txt b/go/internal/gates/testdata/golden/schema_retry_RemediationSuggestion.txt new file mode 100644 index 0000000..1a51fed --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_RemediationSuggestion.txt @@ -0,0 +1,36 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "description": "Flat schema for AI-generated remediation suggestion. 3 fields.", + "properties": { + "fix_description": { + "description": "Natural language description of the recommended fix", + "title": "Fix Description", + "type": "string" + }, + "patch_diff": { + "description": "Unified diff format patch showing the code changes needed", + "title": "Patch Diff", + "type": "string" + }, + "confidence": { + "description": "Confidence in the fix: \"high\", \"medium\", or \"low\"", + "title": "Confidence", + "type": "string" + } + }, + "required": [ + "fix_description", + "patch_diff", + "confidence" + ], + "title": "RemediationSuggestion", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_SanitizationResult.txt b/go/internal/gates/testdata/golden/schema_retry_SanitizationResult.txt new file mode 100644 index 0000000..5fc1402 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_SanitizationResult.txt @@ -0,0 +1,63 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "description": "Flat schema for sanitization analysis sub-agent. 4 fields.", + "properties": { + "found": { + "description": "Whether any sanitization/validation was found on the path", + "title": "Found", + "type": "boolean" + }, + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Type of sanitization (e.g. 'parameterized query', 'html encoding')", + "title": "Type" + }, + "sufficient": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Whether sanitization is sufficient to prevent exploit", + "title": "Sufficient" + }, + "bypass_method": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "How sanitization could be bypassed, if applicable", + "title": "Bypass Method" + } + }, + "required": [ + "found" + ], + "title": "SanitizationResult", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_ScanLocationsResult.txt b/go/internal/gates/testdata/golden/schema_retry_ScanLocationsResult.txt new file mode 100644 index 0000000..93a7495 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_ScanLocationsResult.txt @@ -0,0 +1,58 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "$defs": { + "VulnLocation": { + "description": "Flat schema for Step 1: location scanning. 4 fields.", + "properties": { + "file_path": { + "description": "Path to the file containing the potential vulnerability", + "title": "File Path", + "type": "string" + }, + "start_line": { + "description": "Starting line number of the vulnerable code", + "title": "Start Line", + "type": "integer" + }, + "code_snippet": { + "description": "Relevant code snippet around the vulnerability", + "title": "Code Snippet", + "type": "string" + }, + "pattern_type": { + "description": "Type of vulnerability pattern detected (e.g. 'sql_injection', 'command_injection')", + "title": "Pattern Type", + "type": "string" + } + }, + "required": [ + "file_path", + "start_line", + "code_snippet", + "pattern_type" + ], + "title": "VulnLocation", + "type": "object" + } + }, + "description": "Container for Step 1 results.", + "properties": { + "locations": { + "items": { + "$ref": "#/$defs/VulnLocation" + }, + "title": "Locations", + "type": "array" + } + }, + "title": "ScanLocationsResult", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_SecurityContextRaw.txt b/go/internal/gates/testdata/golden/schema_retry_SecurityContextRaw.txt new file mode 100644 index 0000000..83ca317 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_SecurityContextRaw.txt @@ -0,0 +1,46 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "description": "Flat harness output for security context profiler. All flat, no nesting.", + "properties": { + "auth_model": { + "description": "Authentication model: jwt, session_cookie, oauth2, api_key, none, or other", + "title": "Auth Model", + "type": "string" + }, + "auth_details": { + "default": "", + "description": "Brief description of auth implementation details", + "title": "Auth Details", + "type": "string" + }, + "crypto_usage": { + "description": "One string per crypto usage. Format: 'algorithm | key_size | mode | usage_context | is_weak(true/false)'. Example: 'AES | 256 | GCM | data encryption | false'", + "items": { + "type": "string" + }, + "title": "Crypto Usage", + "type": "array" + }, + "security_signals": { + "description": "Framework security features, security headers, and deployment signals. One signal per entry. Examples: 'CSRF protection enabled', 'HSTS header present', 'Runs in Docker'", + "items": { + "type": "string" + }, + "title": "Security Signals", + "type": "array" + } + }, + "required": [ + "auth_model" + ], + "title": "SecurityContextRaw", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_SeverityClassification.txt b/go/internal/gates/testdata/golden/schema_retry_SeverityClassification.txt new file mode 100644 index 0000000..983147b --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_SeverityClassification.txt @@ -0,0 +1,34 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "description": "DESIGN.md \u00a72.4: quick severity classification gate used in scoring.", + "properties": { + "severity": { + "description": "One of: \"critical\", \"high\", \"medium\", \"low\".", + "title": "Severity", + "type": "string" + }, + "confidence": { + "title": "Confidence", + "type": "number" + }, + "rationale": { + "title": "Rationale", + "type": "string" + } + }, + "required": [ + "severity", + "confidence", + "rationale" + ], + "title": "SeverityClassification", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_StrategySelection.txt b/go/internal/gates/testdata/golden/schema_retry_StrategySelection.txt new file mode 100644 index 0000000..8e04a60 --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_StrategySelection.txt @@ -0,0 +1,31 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "description": "DESIGN.md \u00a75.3: strategy selection gate for HUNT routing.", + "properties": { + "strategies": { + "items": { + "type": "string" + }, + "title": "Strategies", + "type": "array" + }, + "rationale": { + "title": "Rationale", + "type": "string" + } + }, + "required": [ + "strategies", + "rationale" + ], + "title": "StrategySelection", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/schema_retry_VerdictDecision.txt b/go/internal/gates/testdata/golden/schema_retry_VerdictDecision.txt new file mode 100644 index 0000000..d74877d --- /dev/null +++ b/go/internal/gates/testdata/golden/schema_retry_VerdictDecision.txt @@ -0,0 +1,41 @@ +The JSON output at /tmp/secaf-golden/.agentfield_output.json failed validation. +Error: Retry attempt 1/3 + +Your response must conform to this JSON schema: +```json +{ + "description": "Flat schema for verdict sub-agent. Uses .ai() not .harness(). 4 fields.", + "properties": { + "verdict": { + "description": "One of: \"confirmed\", \"likely\", \"inconclusive\", \"not_exploitable\"", + "title": "Verdict", + "type": "string" + }, + "evidence_level": { + "description": "1-6 scale: 1=STATIC_MATCH to 6=FULL_EXPLOIT", + "title": "Evidence Level", + "type": "integer" + }, + "rationale": { + "title": "Rationale", + "type": "string" + }, + "confidence": { + "description": "One of: \"high\", \"medium\", \"low\"", + "title": "Confidence", + "type": "string" + } + }, + "required": [ + "verdict", + "evidence_level", + "rationale", + "confidence" + ], + "title": "VerdictDecision", + "type": "object" +} +``` + +Rewrite the COMPLETE, corrected JSON to: /tmp/secaf-golden/.agentfield_output.json +The file must contain ONLY valid JSON matching the schema above. No markdown fences, no extra text, no comments. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/with_file_write_hint_basic.txt b/go/internal/gates/testdata/golden/with_file_write_hint_basic.txt new file mode 100644 index 0000000..fd07516 --- /dev/null +++ b/go/internal/gates/testdata/golden/with_file_write_hint_basic.txt @@ -0,0 +1,4 @@ +Constraints: +- first +- second +- If output is large or complex, use the file-write pattern and ensure final JSON is written to /tmp/secaf-golden/.agentfield_output.json. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/with_file_write_hint_empty_cwd.txt b/go/internal/gates/testdata/golden/with_file_write_hint_empty_cwd.txt new file mode 100644 index 0000000..afd6c08 --- /dev/null +++ b/go/internal/gates/testdata/golden/with_file_write_hint_empty_cwd.txt @@ -0,0 +1,2 @@ +no directory +- If output is large or complex, use the file-write pattern and ensure final JSON is written to .agentfield_output.json. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/with_file_write_hint_relative_cwd.txt b/go/internal/gates/testdata/golden/with_file_write_hint_relative_cwd.txt new file mode 100644 index 0000000..9caa1d7 --- /dev/null +++ b/go/internal/gates/testdata/golden/with_file_write_hint_relative_cwd.txt @@ -0,0 +1,2 @@ +relative +- If output is large or complex, use the file-write pattern and ensure final JSON is written to work/../work/.agentfield_output.json. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/with_file_write_hint_trailing_whitespace.txt b/go/internal/gates/testdata/golden/with_file_write_hint_trailing_whitespace.txt new file mode 100644 index 0000000..d6ea64d --- /dev/null +++ b/go/internal/gates/testdata/golden/with_file_write_hint_trailing_whitespace.txt @@ -0,0 +1,2 @@ +keep me +- If output is large or complex, use the file-write pattern and ensure final JSON is written to /tmp/secaf-golden/.agentfield_output.json. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/with_phase_guidance_empty.txt b/go/internal/gates/testdata/golden/with_phase_guidance_empty.txt new file mode 100644 index 0000000..9802154 --- /dev/null +++ b/go/internal/gates/testdata/golden/with_phase_guidance_empty.txt @@ -0,0 +1,15 @@ +Context: +- Build conclusions from repository evidence in iterative passes. Prefer explicit evidence over speculation, and clearly separate confirmed facts from uncertainty. + +Constraints: +- Use evidence-first reasoning; do not speculate beyond available artifacts. +- Keep analysis bounded to the task scope and produce only schema-conformant output. +- Cite concrete repository evidence whenever making security-relevant claims. +- If output is large or complex, use the file-write pattern and ensure final JSON is written to /tmp/secaf-golden/.agentfield_output.json. + +Task: +Analyze the repository for SQL injection. +Cite file:line for every claim. + +Output: +- Return a single JSON object matching the requested schema. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/with_phase_guidance_hunt.txt b/go/internal/gates/testdata/golden/with_phase_guidance_hunt.txt new file mode 100644 index 0000000..a2eb69a --- /dev/null +++ b/go/internal/gates/testdata/golden/with_phase_guidance_hunt.txt @@ -0,0 +1,25 @@ +Context: +- APPROACH: You are hunting for a specific class of security vulnerability with recon context. +PROCESS: +1. Review the recon context to understand the codebase topology +2. Identify files and patterns relevant to your specific vulnerability class +3. For each candidate: read the code, trace data flow, assess exploitability +4. Only report findings where you have concrete code evidence +CONSTRAINTS: +- Every finding MUST cite specific file paths and line numbers you have read +- Do not report theoretical vulnerabilities without code evidence +- False negatives are better than false positives +- If a file is sanitized properly, do NOT report it + +Constraints: +- Use evidence-first reasoning; do not speculate beyond available artifacts. +- Keep analysis bounded to the task scope and produce only schema-conformant output. +- Cite concrete repository evidence whenever making security-relevant claims. +- If output is large or complex, use the file-write pattern and ensure final JSON is written to /tmp/secaf-golden/.agentfield_output.json. + +Task: +Analyze the repository for SQL injection. +Cite file:line for every claim. + +Output: +- Return a single JSON object matching the requested schema. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/with_phase_guidance_none.txt b/go/internal/gates/testdata/golden/with_phase_guidance_none.txt new file mode 100644 index 0000000..9802154 --- /dev/null +++ b/go/internal/gates/testdata/golden/with_phase_guidance_none.txt @@ -0,0 +1,15 @@ +Context: +- Build conclusions from repository evidence in iterative passes. Prefer explicit evidence over speculation, and clearly separate confirmed facts from uncertainty. + +Constraints: +- Use evidence-first reasoning; do not speculate beyond available artifacts. +- Keep analysis bounded to the task scope and produce only schema-conformant output. +- Cite concrete repository evidence whenever making security-relevant claims. +- If output is large or complex, use the file-write pattern and ensure final JSON is written to /tmp/secaf-golden/.agentfield_output.json. + +Task: +Analyze the repository for SQL injection. +Cite file:line for every claim. + +Output: +- Return a single JSON object matching the requested schema. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/with_phase_guidance_padded_mixed_case.txt b/go/internal/gates/testdata/golden/with_phase_guidance_padded_mixed_case.txt new file mode 100644 index 0000000..4f02ddc --- /dev/null +++ b/go/internal/gates/testdata/golden/with_phase_guidance_padded_mixed_case.txt @@ -0,0 +1,24 @@ +Context: +- APPROACH: You are performing reconnaissance on a codebase to build an accurate structural map. +PROCESS: +1. Survey the codebase structure — identify key directories, entry points, and configuration +2. Identify the technology stack — languages, frameworks, and external services +3. Map security-relevant boundaries — auth layers, data inputs, API surfaces +4. Only after surveying, synthesize findings into the required schema +CONSTRAINTS: +- Report what IS there, not what MIGHT be there +- If uncertain about a detail, omit it rather than guess +- Prioritize breadth over depth — cover the full surface + +Constraints: +- Use evidence-first reasoning; do not speculate beyond available artifacts. +- Keep analysis bounded to the task scope and produce only schema-conformant output. +- Cite concrete repository evidence whenever making security-relevant claims. +- If output is large or complex, use the file-write pattern and ensure final JSON is written to /tmp/secaf-golden/.agentfield_output.json. + +Task: +Analyze the repository for SQL injection. +Cite file:line for every claim. + +Output: +- Return a single JSON object matching the requested schema. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/with_phase_guidance_prove.txt b/go/internal/gates/testdata/golden/with_phase_guidance_prove.txt new file mode 100644 index 0000000..0978bef --- /dev/null +++ b/go/internal/gates/testdata/golden/with_phase_guidance_prove.txt @@ -0,0 +1,26 @@ +Context: +- APPROACH: You are verifying a specific candidate vulnerability for exploitability. +PROCESS: +1. Read the specific code location cited in the finding +2. Trace the data flow from source to sink +3. Check for sanitization, validation, or other mitigations on the path +4. If exploitable, construct a concrete exploit hypothesis +5. Synthesize your verdict with evidence level +CONSTRAINTS: +- You must READ the actual code — do not rely on the finding description alone +- INCONCLUSIVE is a valid verdict — do not force confirmation or denial +- Cite specific lines where sanitization exists or is missing +- If code has changed since the finding was generated, note the discrepancy + +Constraints: +- Use evidence-first reasoning; do not speculate beyond available artifacts. +- Keep analysis bounded to the task scope and produce only schema-conformant output. +- Cite concrete repository evidence whenever making security-relevant claims. +- If output is large or complex, use the file-write pattern and ensure final JSON is written to /tmp/secaf-golden/.agentfield_output.json. + +Task: +Analyze the repository for SQL injection. +Cite file:line for every claim. + +Output: +- Return a single JSON object matching the requested schema. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/with_phase_guidance_recon.txt b/go/internal/gates/testdata/golden/with_phase_guidance_recon.txt new file mode 100644 index 0000000..4f02ddc --- /dev/null +++ b/go/internal/gates/testdata/golden/with_phase_guidance_recon.txt @@ -0,0 +1,24 @@ +Context: +- APPROACH: You are performing reconnaissance on a codebase to build an accurate structural map. +PROCESS: +1. Survey the codebase structure — identify key directories, entry points, and configuration +2. Identify the technology stack — languages, frameworks, and external services +3. Map security-relevant boundaries — auth layers, data inputs, API surfaces +4. Only after surveying, synthesize findings into the required schema +CONSTRAINTS: +- Report what IS there, not what MIGHT be there +- If uncertain about a detail, omit it rather than guess +- Prioritize breadth over depth — cover the full surface + +Constraints: +- Use evidence-first reasoning; do not speculate beyond available artifacts. +- Keep analysis bounded to the task scope and produce only schema-conformant output. +- Cite concrete repository evidence whenever making security-relevant claims. +- If output is large or complex, use the file-write pattern and ensure final JSON is written to /tmp/secaf-golden/.agentfield_output.json. + +Task: +Analyze the repository for SQL injection. +Cite file:line for every claim. + +Output: +- Return a single JSON object matching the requested schema. \ No newline at end of file diff --git a/go/internal/gates/testdata/golden/with_phase_guidance_unknown.txt b/go/internal/gates/testdata/golden/with_phase_guidance_unknown.txt new file mode 100644 index 0000000..9802154 --- /dev/null +++ b/go/internal/gates/testdata/golden/with_phase_guidance_unknown.txt @@ -0,0 +1,15 @@ +Context: +- Build conclusions from repository evidence in iterative passes. Prefer explicit evidence over speculation, and clearly separate confirmed facts from uncertainty. + +Constraints: +- Use evidence-first reasoning; do not speculate beyond available artifacts. +- Keep analysis bounded to the task scope and produce only schema-conformant output. +- Cite concrete repository evidence whenever making security-relevant claims. +- If output is large or complex, use the file-write pattern and ensure final JSON is written to /tmp/secaf-golden/.agentfield_output.json. + +Task: +Analyze the repository for SQL injection. +Cite file:line for every claim. + +Output: +- Return a single JSON object matching the requested schema. \ No newline at end of file diff --git a/go/internal/harnessx/fieldorder.go b/go/internal/harnessx/fieldorder.go new file mode 100644 index 0000000..bc65417 --- /dev/null +++ b/go/internal/harnessx/fieldorder.go @@ -0,0 +1,98 @@ +package harnessx + +import ( + "reflect" + "strings" +) + +// fieldorder.go exposes the DECLARATION order of a Go struct's json fields — +// the Go side of the port's "struct fields are in pydantic declaration order" +// invariant. +// +// It matters because a pydantic `model_json_schema()` is an insertion-ordered +// dict: `properties` renders in field-declaration order, and the Python SDK's +// `_strictify_openai_schema` builds `required` as `list(props.keys())` over +// that dict (agentfield/agent_ai.py:319). A Go `map[string]any` decoded from +// the committed fixture carries no order at all, so anything that has to +// reproduce a pydantic-ordered document — the `.ai()` request schema +// (internal/aix) and the schema block embedded in a retry prompt +// (internal/gates) — recovers the order here, from the Go type. + +// FieldOrders returns, for t and every struct type reachable from it, the +// ordered json field names of that struct keyed by its Go type name. Anonymous +// (embedded) structs are flattened, `json:"-"` fields are dropped, and a field +// with no json tag keeps its Go name — the same rules encoding/json applies. +func FieldOrders(t reflect.Type) map[string][]string { + out := make(map[string][]string) + collectFieldOrders(t, out) + return out +} + +// FieldOrdersFor is FieldOrders for a type parameter. +func FieldOrdersFor[T any]() map[string][]string { + return FieldOrders(reflect.TypeOf((*T)(nil)).Elem()) +} + +func collectFieldOrders(t reflect.Type, out map[string][]string) { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + switch t.Kind() { + case reflect.Slice, reflect.Array, reflect.Map: + collectFieldOrders(t.Elem(), out) + return + case reflect.Struct: + default: + return + } + name := t.Name() + if name == "" { + return + } + if _, seen := out[name]; seen { + return // also the recursion guard for self-referential models + } + out[name] = JSONFieldNames(t) + for i := 0; i < t.NumField(); i++ { + if !t.Field(i).IsExported() { + continue + } + collectFieldOrders(t.Field(i).Type, out) + } +} + +// JSONFieldNames returns t's json field names in declaration order. +func JSONFieldNames(t reflect.Type) []string { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return nil + } + names := make([]string, 0, t.NumField()) + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !f.IsExported() { + continue + } + name, _, _ := strings.Cut(f.Tag.Get("json"), ",") + if name == "-" { + continue + } + if name == "" { + if f.Anonymous { + inner := f.Type + for inner.Kind() == reflect.Pointer { + inner = inner.Elem() + } + if inner.Kind() == reflect.Struct { + names = append(names, JSONFieldNames(inner)...) + continue + } + } + name = f.Name + } + names = append(names, name) + } + return names +} diff --git a/go/internal/harnessx/harnessx_test.go b/go/internal/harnessx/harnessx_test.go new file mode 100644 index 0000000..5b484fc --- /dev/null +++ b/go/internal/harnessx/harnessx_test.go @@ -0,0 +1,417 @@ +package harnessx + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/harness" +) + +// --------------------------------------------------------------------------- +// fake harness +// --------------------------------------------------------------------------- + +type fakeHarness struct { + gotPrompt string + gotSchema map[string]any + gotDest any + gotOpts harness.Options + calls int + + // fill populates dest before the result is returned, simulating the SDK's + // unmarshal into the destination pointer. + fill func(dest any) + res *harness.Result + err error +} + +func (f *fakeHarness) Harness(ctx context.Context, prompt string, schema map[string]any, dest any, opts harness.Options) (*harness.Result, error) { + f.calls++ + f.gotPrompt = prompt + f.gotSchema = schema + f.gotDest = dest + f.gotOpts = opts + if f.fill != nil { + f.fill(dest) + } + if f.res != nil && f.res.Parsed == nil && !f.res.IsError && f.err == nil { + // The SDK sets Parsed to the dest pointer it was handed on success. + f.res.Parsed = dest + } + return f.res, f.err +} + +// captureStdout runs fn with os.Stdout replaced by a pipe and returns what was +// written. Extract's diagnostics go through fmt.Printf, which reads os.Stdout at +// call time, so swapping the variable is enough. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + + done := make(chan string, 1) + go func() { + b, _ := io.ReadAll(r) + done <- string(b) + }() + + fn() + + _ = w.Close() + os.Stdout = orig + out := <-done + _ = r.Close() + return out +} + +// --------------------------------------------------------------------------- +// schema fixtures +// --------------------------------------------------------------------------- + +// TestEveryFixtureDecodesAsObjectSchema is the committed-fixture health check: +// gen_schemas.py output must be valid JSON, must describe an object, and must +// carry the title pydantic gives it (which is the class name, which is the +// fixture basename — the three-way identity harnessx resolves against). +func TestEveryFixtureDecodesAsObjectSchema(t *testing.T) { + names := FixtureNames() + if len(names) == 0 { + t.Fatal("no schema fixtures embedded — did go:embed lose testdata/schemas?") + } + for _, name := range names { + m, err := LoadFixture(name) + if err != nil { + t.Errorf("%s: %v", name, err) + continue + } + if got := m["type"]; got != "object" { + t.Errorf("%s: type = %v, want \"object\"", name, got) + } + if got, ok := m["title"].(string); !ok || got != name { + t.Errorf("%s: title = %v, want the pydantic class name %q "+ + "(fixture basename must equal the class name and the Go struct name)", name, m["title"], name) + } + if _, ok := m["properties"].(map[string]any); !ok { + t.Errorf("%s: no properties object", name) + } + } +} + +// TestFixtureSetMatchesGeneratorList pins the enumerated model list so a +// fixture added or removed by hand (rather than by gen_schemas.py) is caught. +// Keep this list in sync with go/scripts/gen_schemas.py's MODELS. +func TestFixtureSetMatchesGeneratorList(t *testing.T) { + want := []string{ + "ArchitectureMapRaw", + "CWEExpansion", + "ChainCorrelationResult", + "ComplianceGate", + "ConfigReportRaw", + "CrossServiceFinding", + "DastVerificationResult", + "DataFlowMapRaw", + "DataFlowTrace", + "DependencyReportRaw", + "DuplicateCheck", + "EnrichedFinding", + "ExploitHypothesis", + "PolicyEvalResult", + "ReachabilityGate", + "ReachabilityProof", + "RemediationSuggestion", + "SanitizationResult", + "ScanLocationsResult", + "SecurityContextRaw", + "SeverityClassification", + "StrategySelection", + "VerdictDecision", + } + got := FixtureNames() + if !reflect.DeepEqual(got, want) { + t.Errorf("fixture set =\n %v\nwant\n %v", got, want) + } +} + +// CWEExpansion stands in for the real schemas.CWEExpansion (owned by the +// schemas package) so this package can prove the by-type-name resolution works +// end to end without importing it. +type CWEExpansion struct { + AdditionalCWEs []string `json:"additional_cwes"` + Rationale string `json:"rationale"` +} + +// TestSchemaForResolvesFixtureByGoTypeName: no RegisterSchema call anywhere — +// the Go type's NAME is the lookup key. +func TestSchemaForResolvesFixtureByGoTypeName(t *testing.T) { + got := SchemaFor[CWEExpansion]() + if got["title"] != "CWEExpansion" { + t.Fatalf("SchemaFor[CWEExpansion] title = %v, want the pydantic fixture", got["title"]) + } + // Pydantic marks BOTH fields required and does NOT set additionalProperties; + // an invopop reflection of the Go struct would look different, which is how + // we know the fixture won. + req, _ := got["required"].([]any) + if len(req) != 2 { + t.Errorf("required = %v, want the pydantic 2-entry list", got["required"]) + } + if _, present := got["additionalProperties"]; present { + t.Errorf("fixture unexpectedly carries additionalProperties: %v", got["additionalProperties"]) + } +} + +// unfixturedResult has no committed fixture, so SchemaFor must fall back to +// invopop reflection. +type unfixturedResult struct { + Name string `json:"name"` + Items []string `json:"items"` +} + +func TestSchemaForFallsBackToReflection(t *testing.T) { + got := SchemaFor[unfixturedResult]() + props, ok := got["properties"].(map[string]any) + if !ok { + t.Fatalf("reflected schema has no properties: %#v", got) + } + for _, key := range []string{"name", "items"} { + if _, present := props[key]; !present { + t.Errorf("reflected schema missing property %q: %#v", key, props) + } + } + // ExpandedStruct puts the root properties inline (the SDK's + // DiagnoseOutputFailure reads map["properties"]). + if got["$ref"] != nil { + t.Errorf("reflected schema is a $ref, want ExpandedStruct inline: %#v", got) + } +} + +func TestSchemaForIsCached(t *testing.T) { + a := SchemaFor[CWEExpansion]() + b := SchemaFor[CWEExpansion]() + if reflect.ValueOf(a).Pointer() != reflect.ValueOf(b).Pointer() { + t.Error("SchemaFor re-resolved the schema instead of using the cache") + } +} + +// --------------------------------------------------------------------------- +// Run +// --------------------------------------------------------------------------- + +func TestRunPassesPromptSchemaAndOptions(t *testing.T) { + fh := &fakeHarness{res: &harness.Result{}} + opts := harness.Options{Cwd: "/tmp/secaf-x", ProjectDir: "/repo"} + + dest, res, err := Run[CWEExpansion](context.Background(), fh, "find CWEs", opts) + if err != nil { + t.Fatalf("Run: %v", err) + } + if dest == nil || res == nil { + t.Fatal("Run returned a nil dest/result on success") + } + if fh.gotPrompt != "find CWEs" { + t.Errorf("prompt = %q", fh.gotPrompt) + } + if fh.gotOpts.Cwd != "/tmp/secaf-x" || fh.gotOpts.ProjectDir != "/repo" { + t.Errorf("opts = %#v, want cwd/project_dir passed through unchanged", fh.gotOpts) + } + if fh.gotSchema["title"] != "CWEExpansion" { + t.Errorf("schema = %v, want the committed pydantic fixture", fh.gotSchema["title"]) + } + if _, ok := fh.gotDest.(*CWEExpansion); !ok { + t.Errorf("dest = %T, want *CWEExpansion", fh.gotDest) + } +} + +func TestRunPropagatesTransportError(t *testing.T) { + want := errors.New("provider binary not found") + fh := &fakeHarness{err: want} + _, _, err := Run[CWEExpansion](context.Background(), fh, "p", harness.Options{}) + if !errors.Is(err, want) { + t.Errorf("Run error = %v, want the SDK error propagated", err) + } +} + +// TestRunDoesNotErrorOnHarnessFailure: a harness that RAN and failed comes back +// as (result with IsError, nil error) — mirroring the Python SDK, which returns +// a HarnessResult with is_error=True rather than raising. Extract is what turns +// that into an exception. +func TestRunDoesNotErrorOnHarnessFailure(t *testing.T) { + fh := &fakeHarness{res: &harness.Result{IsError: true, ErrorMessage: "boom"}} + _, res, err := Run[CWEExpansion](context.Background(), fh, "p", harness.Options{}) + if err != nil { + t.Fatalf("Run returned an error for a harness-level failure: %v", err) + } + if res == nil || !res.IsError { + t.Fatalf("Run lost the failed result: %#v", res) + } +} + +// --------------------------------------------------------------------------- +// Extract (agents/_utils.py extract_harness_result) +// --------------------------------------------------------------------------- + +func TestExtractReturnsParsedValue(t *testing.T) { + dest := &CWEExpansion{AdditionalCWEs: []string{"CWE-918"}, Rationale: "ssrf surface"} + res := &harness.Result{Parsed: dest} + + var got CWEExpansion + out := captureStdout(t, func() { + var err error + got, err = Extract(res, dest, "ArchitectureMapper") + if err != nil { + t.Errorf("Extract: %v", err) + } + }) + if out != "" { + t.Errorf("Extract printed diagnostics on the success path: %q", out) + } + if !reflect.DeepEqual(got, *dest) { + t.Errorf("Extract = %#v, want %#v", got, *dest) + } +} + +// TestExtractHarnessErrorRaises pins the RuntimeError text AND the stdout +// diagnostic block, both byte-for-byte against the Python helper: +// +// print(f"[{agent_name}] HARNESS ERROR: {error_message}\n" +// f" turns={num_turns}, duration_ms={duration_ms}\n" +// f" result_text={str(result_text)[:500] if result_text else None}", flush=True) +// raise RuntimeError(f"{agent_name} harness error: {error_message}") +func TestExtractHarnessErrorRaises(t *testing.T) { + res := &harness.Result{ + IsError: true, + ErrorMessage: "provider exited 1", + Result: "partial text", + NumTurns: 7, + DurationMS: 1234, + } + var err error + out := captureStdout(t, func() { + _, err = Extract(res, &CWEExpansion{}, "DependencyAuditor") + }) + + wantErr := "DependencyAuditor harness error: provider exited 1" + if err == nil || err.Error() != wantErr { + t.Errorf("Extract error = %v, want %q", err, wantErr) + } + wantOut := "[DependencyAuditor] HARNESS ERROR: provider exited 1\n" + + " turns=7, duration_ms=1234\n" + + " result_text=partial text\n" + if out != wantOut { + t.Errorf("diagnostics =\n%q\nwant\n%q", out, wantOut) + } +} + +// TestExtractHarnessErrorEmptyResultTextPrintsNone: Python's +// `str(result_text)[:500] if result_text else None` treats "" as falsy. +func TestExtractHarnessErrorEmptyResultTextPrintsNone(t *testing.T) { + res := &harness.Result{IsError: true, ErrorMessage: "no output", Result: "", NumTurns: 0, DurationMS: 0} + out := captureStdout(t, func() { _, _ = Extract(res, &CWEExpansion{}, "ConfigScanner") }) + want := "[ConfigScanner] HARNESS ERROR: no output\n turns=0, duration_ms=0\n result_text=None\n" + if out != want { + t.Errorf("diagnostics =\n%q\nwant\n%q", out, want) + } +} + +// TestExtractHarnessErrorTruncatesResultTextByRunes: Python slices str by code +// points, not bytes. +func TestExtractHarnessErrorTruncatesResultTextByRunes(t *testing.T) { + long := strings.Repeat("é", 600) // 600 runes, 1200 bytes + res := &harness.Result{IsError: true, ErrorMessage: "e", Result: long} + out := captureStdout(t, func() { _, _ = Extract(res, &CWEExpansion{}, "A") }) + + prefix := "[A] HARNESS ERROR: e\n turns=0, duration_ms=0\n result_text=" + body := strings.TrimSuffix(strings.TrimPrefix(out, prefix), "\n") + if n := len([]rune(body)); n != 500 { + t.Errorf("result_text truncated to %d runes, want 500 (python str[:500])", n) + } +} + +// TestExtractParsedNilRaisesTypeError pins the TypeError-equivalent message and +// the debug line. +func TestExtractParsedNilRaisesTypeError(t *testing.T) { + res := &harness.Result{Parsed: nil} + var err error + out := captureStdout(t, func() { + _, err = Extract(res, &CWEExpansion{}, "SecurityContextProfiler") + }) + wantErr := "SecurityContextProfiler did not return a valid CWEExpansion" + if err == nil || err.Error() != wantErr { + t.Errorf("Extract error = %v, want %q", err, wantErr) + } + wantOut := "[SecurityContextProfiler] harness result type=Result, is_error=False, parsed type=NoneType\n" + if out != wantOut { + t.Errorf("debug line = %q, want %q", out, wantOut) + } +} + +// TestExtractNilResult: Python's getattr(None, ...) defaults land in the same +// TypeError branch. +func TestExtractNilResult(t *testing.T) { + var err error + out := captureStdout(t, func() { + _, err = Extract[CWEExpansion](nil, nil, "Verifier") + }) + if err == nil || err.Error() != "Verifier did not return a valid CWEExpansion" { + t.Errorf("Extract error = %v", err) + } + if want := "[Verifier] harness result type=NoneType, is_error=False, parsed type=NoneType\n"; out != want { + t.Errorf("debug line = %q, want %q", out, want) + } +} + +// --------------------------------------------------------------------------- +// RunExtract +// --------------------------------------------------------------------------- + +func TestRunExtractSuccess(t *testing.T) { + fh := &fakeHarness{ + res: &harness.Result{}, + fill: func(dest any) { + _ = json.Unmarshal([]byte(`{"additional_cwes":["CWE-611"],"rationale":"xxe"}`), dest) + }, + } + var got CWEExpansion + out := captureStdout(t, func() { + var err error + got, err = RunExtract[CWEExpansion](context.Background(), fh, "p", harness.Options{}, "CWEExpander") + if err != nil { + t.Errorf("RunExtract: %v", err) + } + }) + if out != "" { + t.Errorf("RunExtract printed diagnostics on success: %q", out) + } + want := CWEExpansion{AdditionalCWEs: []string{"CWE-611"}, Rationale: "xxe"} + if !reflect.DeepEqual(got, want) { + t.Errorf("RunExtract = %#v, want %#v", got, want) + } +} + +func TestRunExtractPropagatesTransportError(t *testing.T) { + want := errors.New("spawn failed") + fh := &fakeHarness{err: want} + _, err := RunExtract[CWEExpansion](context.Background(), fh, "p", harness.Options{}, "A") + if !errors.Is(err, want) { + t.Errorf("RunExtract error = %v, want the transport error", err) + } +} + +func TestRunExtractMapsHarnessFailureToError(t *testing.T) { + fh := &fakeHarness{res: &harness.Result{IsError: true, ErrorMessage: "schema validation failed"}} + var err error + _ = captureStdout(t, func() { + _, err = RunExtract[CWEExpansion](context.Background(), fh, "p", harness.Options{}, "Tracer") + }) + if err == nil || err.Error() != "Tracer harness error: schema validation failed" { + t.Errorf("RunExtract error = %v", err) + } +} diff --git a/go/internal/harnessx/run.go b/go/internal/harnessx/run.go new file mode 100644 index 0000000..a2a23f3 --- /dev/null +++ b/go/internal/harnessx/run.go @@ -0,0 +1,146 @@ +package harnessx + +import ( + "context" + "fmt" + "reflect" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/pyfmt" +) + +// Run is the Go form of +// +// result = await app.harness(prompt=prompt, schema=Model, cwd=..., project_dir=...) +// +// It resolves Model's schema by Go type name (SchemaFor), allocates the +// destination, and calls the SDK. +// +// The returned error is NON-NIL ONLY for a transport-level failure — the SDK +// could not run the provider at all. A harness that ran and failed (bad +// provider exit, unparseable output, schema validation exhausted) comes back as +// a nil error with Result.IsError set, exactly as the Python SDK returns a +// HarnessResult with is_error=True rather than raising. Turning that into an +// error is Extract's job, so the diagnostic block prints in one place. +// +// *T is always non-nil so callers can hand it straight to Extract; it holds the +// SDK-populated value when Result.Parsed is set and a default-seeded value +// (T's UnmarshalJSON ran on whatever partial JSON there was) otherwise. +func Run[T any](ctx context.Context, app appx.Harnesser, prompt string, opts harness.Options) (*T, *harness.Result, error) { + schema := SchemaFor[T]() + dest := new(T) + res, err := app.Harness(ctx, prompt, schema, dest, opts) + if err != nil { + return dest, res, err + } + return dest, res, nil +} + +// Extract ports extract_harness_result from src/sec_af/agents/_utils.py exactly: +// +// def extract_harness_result(result, schema, agent_name): +// is_error = bool(getattr(result, "is_error", False)) +// if is_error: +// ... +// print(f"[{agent_name}] HARNESS ERROR: {error_message}\n" +// f" turns={num_turns}, duration_ms={duration_ms}\n" +// f" result_text={str(result_text)[:500] if result_text else None}", +// flush=True) +// raise RuntimeError(f"{agent_name} harness error: {error_message}") +// parsed = getattr(result, "parsed", None) +// if isinstance(parsed, schema): +// return parsed +// debug_message = (...) +// if isinstance(parsed, dict): +// try: return schema.model_validate(parsed) +// except Exception: print(debug_message, flush=True); raise +// print(debug_message, flush=True) +// raise TypeError(f"{agent_name} did not return a valid {schema.__name__}") +// +// dest is the pointer Run handed to the SDK. The SDK sets Result.Parsed to that +// same pointer on success (sdk/go/harness/runner.go:413,549), so a non-nil +// Parsed means "*dest is populated and schema-valid" — the exact condition +// `isinstance(parsed, schema)` tests in Python. +// +// Python parity notes: +// +// - The stdout diagnostics are reproduced verbatim, including the +// `result_text=None` spelling that Python's `... if result_text else None` +// produces for an EMPTY result string (falsy, not just missing), and the +// 500-character (not byte) truncation of Python's str slice. +// - `type(result).__name__` in the debug line is the Python SDK's +// `HarnessResult`; Go's concrete type is `harness.Result`, so the line +// prints "Result". The line is diagnostic only. +// - The `isinstance(parsed, dict)` branch is Python duck-typing for a SDK that +// may hand back a raw dict. The Go SDK's Parsed is always the `dest` pointer +// it was given, so that branch is unreachable here and is deliberately not +// ported. +func Extract[T any](res *harness.Result, dest *T, agentName string) (T, error) { + var zero T + typeName := reflect.TypeOf((*T)(nil)).Elem().Name() + + if res == nil { + // Python: getattr(None, "is_error", False) is False and + // getattr(None, "parsed", None) is None, so a missing result falls + // straight to the TypeError branch after the debug line. + fmt.Printf("[%s] harness result type=%s, is_error=%s, parsed type=%s\n", + agentName, "NoneType", "False", "NoneType") + return zero, fmt.Errorf("%s did not return a valid %s", agentName, typeName) + } + + if res.IsError { + // Python: `str(result_text)[:500] if result_text else None` — an empty + // result string is falsy, so it prints the literal "None". + resultText := "None" + if res.Result != "" { + resultText = runeSlice(res.Result, 500) + } + fmt.Printf("[%s] HARNESS ERROR: %s\n turns=%d, duration_ms=%d\n result_text=%s\n", + agentName, res.ErrorMessage, res.NumTurns, res.DurationMS, resultText) + return zero, fmt.Errorf("%s harness error: %s", agentName, res.ErrorMessage) + } + + if res.Parsed != nil && dest != nil { + return *dest, nil + } + + parsedType := "NoneType" + if res.Parsed != nil { + parsedType = reflect.TypeOf(res.Parsed).String() + } + fmt.Printf("[%s] harness result type=%s, is_error=%s, parsed type=%s\n", + agentName, "Result", pyfmt.Str(res.IsError), parsedType) + return zero, fmt.Errorf("%s did not return a valid %s", agentName, typeName) +} + +// RunExtract is the shape every SEC-AF agent module actually uses: +// +// result = await app.harness(prompt=prompt, schema=Model, cwd=harness_cwd, project_dir=repo_path) +// return extract_harness_result(result, Model, "AgentName") +// +// A transport error from app.Harness propagates unchanged (Python would let the +// SDK's exception propagate out of the `await` the same way); everything else +// goes through Extract. +func RunExtract[T any](ctx context.Context, app appx.Harnesser, prompt string, opts harness.Options, agentName string) (T, error) { + dest, res, err := Run[T](ctx, app, prompt, opts) + if err != nil { + var zero T + return zero, err + } + return Extract[T](res, dest, agentName) +} + +// runeSlice reproduces Python's s[:n], which counts Unicode code points, not +// bytes. +func runeSlice(s string, n int) string { + if n < 0 { + n = 0 + } + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) +} diff --git a/go/internal/harnessx/schema.go b/go/internal/harnessx/schema.go new file mode 100644 index 0000000..9a7ab56 --- /dev/null +++ b/go/internal/harnessx/schema.go @@ -0,0 +1,148 @@ +// Package harnessx is the single choke point every SEC-AF agent uses to call +// the AgentField harness for structured output. +// +// It replaces two things the Python source does implicitly: +// +// - `app.harness(prompt=..., schema=SomeModel, cwd=..., project_dir=...)` — +// the pydantic model is resolved here into the JSON-schema map the Go SDK +// consumes, by GO TYPE NAME (see SchemaFor); +// - `extract_harness_result(result, SomeModel, "AgentName")` from +// src/sec_af/agents/_utils.py — ported byte-for-byte as Extract, including +// the diagnostic block it prints to stdout. +// +// RunExtract combines both, because `result = await app.harness(...)` followed +// immediately by `extract_harness_result(result, Model, name)` is what every +// single agent module in src/sec_af/agents does. +package harnessx + +import ( + "embed" + "encoding/json" + "io/fs" + "reflect" + "sort" + "strings" + "sync" + + "github.com/invopop/jsonschema" +) + +// embeddedSchemas holds the committed pydantic-generated JSON schemas, one per +// model SEC-AF passes as `schema=`. They are produced by go/scripts/gen_schemas.py +// from the real pydantic classes, so the schema the Go SDK validates against — +// and pretty-prints into the harness prompt's OUTPUT REQUIREMENTS block — is the +// same one the Python node uses: defaulted fields optional, `X | None` nullable, +// extra keys allowed. +// +// Fixture basename == pydantic class name == Go struct name. That three-way +// identity is the cross-package contract the port is built on (see the design +// doc's "Go struct names == pydantic class names exactly" rule): there is NO +// registration call, so a new Run[T] destination type only needs its Go name to +// match the Python class for the right schema to be picked up. +// +//go:embed testdata/schemas/*.json +var embeddedSchemas embed.FS + +// schemaCache memoizes the resolved schema map per concrete type T so the +// embedded-fixture load (or the non-trivial invopop reflection) runs once per +// type. Keyed by reflect.Type; the stored map is treated as immutable by +// callers — the SDK only ever marshals and reads it — so sharing the cached +// value across goroutines is safe. +var schemaCache sync.Map // reflect.Type -> map[string]any + +// SchemaFor resolves the JSON-schema map the Go SDK harness consumes for T. +// +// Resolution order: +// +// 1. testdata/schemas/.json, embedded above. This is +// the pydantic model_json_schema() of the identically named Python class. +// 2. invopop reflection over the Go type, for types with no fixture (ad-hoc +// test structs, and any future destination whose Python counterpart is not +// in gen_schemas.py's MODELS list). +// +// Why the fixture must win: the SDK runs REAL JSON-Schema validation on parsed +// output (harness/runner.go -> runSchemaValidation, santhosh-tekuri/jsonschema/v5) +// and retries the whole harness call when it fails. An invopop reflection marks +// every field required, renders pointer fields non-nullable and sets +// additionalProperties:false, so Python-valid model output would be rejected and +// the node would burn retries and drop findings. +// +// Invopop reflector configuration (fallback path only): +// - ExpandedStruct: inline the root type's own properties at the top level so +// map["properties"] is populated for the SDK's DiagnoseOutputFailure. +// - DoNotReference=false (default): emit a $defs map for nested struct types. +// - Anonymous: suppress the auto-generated $id derived from the package path. +func SchemaFor[T any]() map[string]any { + t := reflect.TypeOf((*T)(nil)).Elem() + if cached, ok := schemaCache.Load(t); ok { + return cached.(map[string]any) + } + + var m map[string]any + if name := t.Name(); name != "" { + // A load failure is the normal "no fixture for this type" case; it also + // covers the impossible-in-practice corrupt-fixture case, which falls + // through to reflection rather than panicking inside an agent. + if loaded, err := LoadFixture(name); err == nil { + m = loaded + } + } + if m == nil { + m = reflectSchema(t) + } + + schemaCache.Store(t, m) + return m +} + +// LoadFixture decodes the committed pydantic schema fixture with the given +// basename (== the pydantic class name). Exported for tests and for tooling +// that needs a schema without a Go type in hand. +func LoadFixture(name string) (map[string]any, error) { + b, err := embeddedSchemas.ReadFile("testdata/schemas/" + name + ".json") + if err != nil { + return nil, err + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + return m, nil +} + +// FixtureNames lists every committed schema fixture basename, sorted. +func FixtureNames() []string { + entries, err := fs.ReadDir(embeddedSchemas, "testdata/schemas") + if err != nil { + return nil + } + out := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + out = append(out, strings.TrimSuffix(e.Name(), ".json")) + } + sort.Strings(out) + return out +} + +// reflectSchema is the invopop fallback for types with no committed fixture. +func reflectSchema(t reflect.Type) map[string]any { + r := &jsonschema.Reflector{ + ExpandedStruct: true, // root properties inline at top level + DoNotReference: false, // emit $defs for nested types + Anonymous: true, // no auto-generated $id from PkgPath + } + schema := r.ReflectFromType(t) + + b, err := json.Marshal(schema) + if err != nil { + return map[string]any{} + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + return map[string]any{} + } + return m +} diff --git a/go/internal/harnessx/schema_parity_test.go b/go/internal/harnessx/schema_parity_test.go new file mode 100644 index 0000000..5443ec1 --- /dev/null +++ b/go/internal/harnessx/schema_parity_test.go @@ -0,0 +1,165 @@ +package harnessx + +import ( + "encoding/json" + "reflect" + "sort" + "testing" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// --------------------------------------------------------------------------- +// Fixture <-> Go struct parity +// +// DESIGN.md's cross-agent contract: the pydantic schema fixture for a Go type +// is resolved BY GO TYPE NAME (testdata/schemas/.json), and that +// document is what SchemaFor[T] sends to the harness. Nothing in the compiler +// ties the two together — a renamed json tag, a dropped field or a stale +// regenerated fixture all compile fine and only show up as a model that +// answers in a shape Go cannot unmarshal. +// +// These tests are that tie. `internal/schemas` already gates its structs +// against a pydantic ground-truth dump (model_keys_test.go); this gates the +// OTHER document — the one that actually reaches the LLM — against the same +// structs, and gates the fixture SET against the types harnessx is asked for. +// --------------------------------------------------------------------------- + +// harnessModels is every type harnessx serves a fixture for, keyed by the +// fixture basename. Adding a fixture without adding it here fails +// TestEveryFixtureIsClaimedByAGoType. +func harnessModels() map[string]any { + return map[string]any{ + "ArchitectureMapRaw": schemas.ArchitectureMapRaw{}, + "CWEExpansion": schemas.CWEExpansion{}, + "ChainCorrelationResult": schemas.ChainCorrelationResult{}, + "ComplianceGate": schemas.ComplianceGate{}, + "ConfigReportRaw": schemas.ConfigReportRaw{}, + "CrossServiceFinding": schemas.CrossServiceFinding{}, + "DastVerificationResult": schemas.DastVerificationResult{}, + "DataFlowMapRaw": schemas.DataFlowMapRaw{}, + "DataFlowTrace": schemas.DataFlowTrace{}, + "DependencyReportRaw": schemas.DependencyReportRaw{}, + "DuplicateCheck": schemas.DuplicateCheck{}, + "EnrichedFinding": schemas.EnrichedFinding{}, + "ExploitHypothesis": schemas.ExploitHypothesis{}, + "PolicyEvalResult": schemas.PolicyEvalResult{}, + "ReachabilityGate": schemas.ReachabilityGate{}, + "ReachabilityProof": schemas.ReachabilityProof{}, + "RemediationSuggestion": schemas.RemediationSuggestion{}, + "SanitizationResult": schemas.SanitizationResult{}, + "ScanLocationsResult": schemas.ScanLocationsResult{}, + "SecurityContextRaw": schemas.SecurityContextRaw{}, + "SeverityClassification": schemas.SeverityClassification{}, + "StrategySelection": schemas.StrategySelection{}, + "VerdictDecision": schemas.VerdictDecision{}, + } +} + +// TestEveryFixtureIsClaimedByAGoType keeps the embedded fixture set and the Go +// types in step in both directions: a fixture nothing decodes into is dead +// weight, and a type with no fixture makes SchemaFor panic at runtime. +func TestEveryFixtureIsClaimedByAGoType(t *testing.T) { + models := harnessModels() + fixtures := FixtureNames() + + have := map[string]bool{} + for _, n := range fixtures { + have[n] = true + if _, ok := models[n]; !ok { + t.Errorf("fixture %q has no Go type in harnessModels — add it, or delete the fixture", n) + } + } + for n := range models { + if !have[n] { + t.Errorf("Go type %q has no embedded fixture testdata/schemas/%s.json", n, n) + } + } + if len(fixtures) != len(models) { + t.Errorf("%d fixtures, %d Go types", len(fixtures), len(models)) + } +} + +// TestSchemaFixturesMatchGoStructTags is the field-level gate the package doc +// asked for: the fixture's top-level "properties" key set must equal the Go +// struct's json tag set exactly. A property Go has no field for is silently +// dropped on decode; a field the schema does not declare is one the model is +// never told to produce. +func TestSchemaFixturesMatchGoStructTags(t *testing.T) { + for name, model := range harnessModels() { + name, model := name, model + t.Run(name, func(t *testing.T) { + schema, err := LoadFixture(name) + if err != nil { + t.Fatalf("load fixture: %v", err) + } + props, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatalf("fixture has no top-level properties object") + } + + want := sortedKeys(props) + got := jsonTagsOf(t, model) + if !reflect.DeepEqual(got, want) { + t.Errorf("json tags != schema properties\n Go: %v\n schema: %v", got, want) + } + }) + } +} + +// TestSchemaFixtureRequiredNamesExistInGo checks the other half: every name the +// schema marks required must be a field Go can actually receive. (The reverse +// is NOT asserted — pydantic omits defaulted fields from `required`, which is +// the documented reason these fixtures are used instead of a Go reflection.) +func TestSchemaFixtureRequiredNamesExistInGo(t *testing.T) { + for name, model := range harnessModels() { + name, model := name, model + t.Run(name, func(t *testing.T) { + schema, err := LoadFixture(name) + if err != nil { + t.Fatalf("load fixture: %v", err) + } + tags := map[string]bool{} + for _, tag := range jsonTagsOf(t, model) { + tags[tag] = true + } + req, _ := schema["required"].([]any) + for _, r := range req { + s, ok := r.(string) + if !ok { + t.Fatalf("required entry %v is not a string", r) + } + if !tags[s] { + t.Errorf("required field %q has no Go json tag", s) + } + } + }) + } +} + +// jsonTagsOf returns the sorted json tag names a zero value of model +// marshals to — i.e. exactly the keys the SDK will hand back to a caller. +// Marshaling (rather than reflecting over the struct type) is deliberate: it +// honours embedded structs, `-` tags and any custom MarshalJSON, so it reports +// the wire shape rather than the declaration. +func jsonTagsOf(t *testing.T, model any) []string { + t.Helper() + b, err := json.Marshal(model) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("decode marshaled model: %v", err) + } + return sortedKeys(m) +} + +func sortedKeys(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/go/internal/harnessx/testdata/schemas/ArchitectureMapRaw.json b/go/internal/harnessx/testdata/schemas/ArchitectureMapRaw.json new file mode 100644 index 0000000..6e76dc1 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/ArchitectureMapRaw.json @@ -0,0 +1,53 @@ +{ + "description": "Flat harness output for architecture mapper. All list[str], no nesting.", + "properties": { + "api_endpoints": { + "description": "One string per API endpoint. Format: 'method | path | handler | file_path:line | auth_required | rate_limited'. Example: 'GET | /api/users | get_users | src/api.py:15 | true | false'", + "items": { + "type": "string" + }, + "title": "Api Endpoints", + "type": "array" + }, + "app_type": { + "default": "unknown", + "description": "Application type: web_api, cli_tool, library, microservice, monolith", + "title": "App Type", + "type": "string" + }, + "entry_points": { + "description": "One string per entry point. Format: 'kind | route_or_id | file_path:line | auth_required'. Example: 'http | POST /api/login | src/routes.py:42 | false'", + "items": { + "type": "string" + }, + "title": "Entry Points", + "type": "array" + }, + "modules": { + "description": "One string per module. Format: 'name | path | language | description'. Example: 'auth | src/auth/ | python | Authentication and session management'", + "items": { + "type": "string" + }, + "title": "Modules", + "type": "array" + }, + "services": { + "description": "One string per external service. Format: 'name | type | endpoint | auth_mechanism'. Example: 'PostgreSQL | database | localhost:5432 | password'", + "items": { + "type": "string" + }, + "title": "Services", + "type": "array" + }, + "trust_boundaries": { + "description": "One string per boundary. Format: 'name | source_zone | target_zone | description'. Example: 'API Gateway | external | internal | Rate limiting and auth'", + "items": { + "type": "string" + }, + "title": "Trust Boundaries", + "type": "array" + } + }, + "title": "ArchitectureMapRaw", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/CWEExpansion.json b/go/internal/harnessx/testdata/schemas/CWEExpansion.json new file mode 100644 index 0000000..900051d --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/CWEExpansion.json @@ -0,0 +1,23 @@ +{ + "description": "AI-suggested CWE additions based on recon context.", + "properties": { + "additional_cwes": { + "description": "CWE IDs to add beyond baseline, e.g. ['CWE-918', 'CWE-611'].", + "items": { + "type": "string" + }, + "title": "Additional Cwes", + "type": "array" + }, + "rationale": { + "title": "Rationale", + "type": "string" + } + }, + "required": [ + "additional_cwes", + "rationale" + ], + "title": "CWEExpansion", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/ChainCorrelationResult.json b/go/internal/harnessx/testdata/schemas/ChainCorrelationResult.json new file mode 100644 index 0000000..8c2dd5a --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/ChainCorrelationResult.json @@ -0,0 +1,23 @@ +{ + "description": "Flat harness schema for chain correlation. LLM identifies chains only.", + "properties": { + "chains": { + "description": "Multi-step attack chains found. Format per entry: 'title | finding_id1,finding_id2,... | combined_impact | severity'. Example: 'SSRF to Internal API | f1,f2 | Access internal services | high'", + "items": { + "type": "string" + }, + "title": "Chains", + "type": "array" + }, + "duplicate_ids": { + "description": "Finding IDs that are duplicates missed by programmatic dedup (to drop)", + "items": { + "type": "string" + }, + "title": "Duplicate Ids", + "type": "array" + } + }, + "title": "ChainCorrelationResult", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/ComplianceGate.json b/go/internal/harnessx/testdata/schemas/ComplianceGate.json new file mode 100644 index 0000000..0bcf2d5 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/ComplianceGate.json @@ -0,0 +1,46 @@ +{ + "$defs": { + "ComplianceSuggestion": { + "properties": { + "control_id": { + "title": "Control Id", + "type": "string" + }, + "control_name": { + "title": "Control Name", + "type": "string" + }, + "framework": { + "title": "Framework", + "type": "string" + } + }, + "required": [ + "framework", + "control_id", + "control_name" + ], + "title": "ComplianceSuggestion", + "type": "object" + } + }, + "properties": { + "confidence": { + "title": "Confidence", + "type": "string" + }, + "mappings": { + "items": { + "$ref": "#/$defs/ComplianceSuggestion" + }, + "title": "Mappings", + "type": "array" + } + }, + "required": [ + "mappings", + "confidence" + ], + "title": "ComplianceGate", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/ConfigReportRaw.json b/go/internal/harnessx/testdata/schemas/ConfigReportRaw.json new file mode 100644 index 0000000..d650de4 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/ConfigReportRaw.json @@ -0,0 +1,23 @@ +{ + "description": "Flat harness output for config scanner. All list[str], no nesting.", + "properties": { + "misconfigs": { + "description": "One string per misconfiguration. Format: 'category | file_path:line | key | risk | remediation'. Example: 'debug_mode | config.py:15 | DEBUG=True | Exposes stack traces | Set DEBUG=False'", + "items": { + "type": "string" + }, + "title": "Misconfigs", + "type": "array" + }, + "secrets": { + "description": "One string per secret finding. Format: 'type | file_path:line | match_preview | confidence | is_test(true/false)'. Example: 'aws_access_key | .env:3 | AKIA... | high | false'", + "items": { + "type": "string" + }, + "title": "Secrets", + "type": "array" + } + }, + "title": "ConfigReportRaw", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/CrossServiceFinding.json b/go/internal/harnessx/testdata/schemas/CrossServiceFinding.json new file mode 100644 index 0000000..cce480f --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/CrossServiceFinding.json @@ -0,0 +1,36 @@ +{ + "description": "Flat schema for cross-service attack chain analysis. 4 fields.", + "properties": { + "chain_description": { + "description": "Description of the cross-service attack path", + "title": "Chain Description", + "type": "string" + }, + "entry_point": { + "description": "Public-facing entry point where attack begins", + "title": "Entry Point", + "type": "string" + }, + "impact": { + "description": "Impact if the cross-service chain is exploited", + "title": "Impact", + "type": "string" + }, + "services_involved": { + "description": "Service names in the attack chain", + "items": { + "type": "string" + }, + "title": "Services Involved", + "type": "array" + } + }, + "required": [ + "chain_description", + "services_involved", + "entry_point", + "impact" + ], + "title": "CrossServiceFinding", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/DastVerificationResult.json b/go/internal/harnessx/testdata/schemas/DastVerificationResult.json new file mode 100644 index 0000000..ad67d81 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/DastVerificationResult.json @@ -0,0 +1,33 @@ +{ + "description": "Flat schema for DAST-like runtime verification. 4 fields.", + "properties": { + "exploit_confirmed": { + "description": "Whether the exploit was confirmed at runtime", + "title": "Exploit Confirmed", + "type": "boolean" + }, + "payload_sent": { + "description": "The exploit payload or request that was sent", + "title": "Payload Sent", + "type": "string" + }, + "response_summary": { + "description": "Summary of the application response", + "title": "Response Summary", + "type": "string" + }, + "safety_notes": { + "description": "Safety measures taken during verification (sandbox, timeout, etc.)", + "title": "Safety Notes", + "type": "string" + } + }, + "required": [ + "payload_sent", + "response_summary", + "exploit_confirmed", + "safety_notes" + ], + "title": "DastVerificationResult", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/DataFlowMapRaw.json b/go/internal/harnessx/testdata/schemas/DataFlowMapRaw.json new file mode 100644 index 0000000..8b9f7ad --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/DataFlowMapRaw.json @@ -0,0 +1,31 @@ +{ + "description": "Flat harness output for data flow mapper. All list[str], no nesting.", + "properties": { + "flows": { + "description": "One string per data flow. Format: 'source | sink | sanitized(true/false) | file1, file2, ...'. Example: 'request.body | sql.execute | false | src/db.py, src/routes.py'", + "items": { + "type": "string" + }, + "title": "Flows", + "type": "array" + }, + "sanitization_points": { + "description": "One string per sanitization point. Format: 'file_path:line | function_name | type | protects_against'. Example: 'src/utils.py:42 | sanitize_html | html_encoding | CWE-79'", + "items": { + "type": "string" + }, + "title": "Sanitization Points", + "type": "array" + }, + "sinks": { + "description": "One string per security-critical sink. Format: 'sink_type | file_path:line | function_name | notes'. Example: 'sql_execute | src/db.py:55 | run_query | Direct string concatenation'", + "items": { + "type": "string" + }, + "title": "Sinks", + "type": "array" + } + }, + "title": "DataFlowMapRaw", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/DataFlowTrace.json b/go/internal/harnessx/testdata/schemas/DataFlowTrace.json new file mode 100644 index 0000000..6f51397 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/DataFlowTrace.json @@ -0,0 +1,36 @@ +{ + "description": "Flat schema for data flow tracing sub-agent. 4 fields.", + "properties": { + "sink": { + "description": "Security-sensitive operation reached (e.g. 'sql.execute(query)')", + "title": "Sink", + "type": "string" + }, + "sink_reached": { + "description": "Whether tainted data actually reaches the sink", + "title": "Sink Reached", + "type": "boolean" + }, + "source": { + "description": "Where tainted input enters (e.g. 'request.params.id')", + "title": "Source", + "type": "string" + }, + "steps": { + "description": "Ordered list of file:line descriptions showing flow path", + "items": { + "type": "string" + }, + "title": "Steps", + "type": "array" + } + }, + "required": [ + "source", + "sink", + "steps", + "sink_reached" + ], + "title": "DataFlowTrace", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/DependencyReportRaw.json b/go/internal/harnessx/testdata/schemas/DependencyReportRaw.json new file mode 100644 index 0000000..05bcb29 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/DependencyReportRaw.json @@ -0,0 +1,31 @@ +{ + "description": "Flat harness output for dependency auditor. All list[str], no nesting.", + "properties": { + "known_cves": { + "description": "One string per CVE. Format: 'cve_id | package | installed_version | fixed_version | cvss_score | direct | reachable'. Example: 'CVE-2023-1234 | lodash | 4.17.15 | 4.17.21 | 7.5 | true | unknown'", + "items": { + "type": "string" + }, + "title": "Known Cves", + "type": "array" + }, + "outdated": { + "description": "One string per outdated dep. Format: 'package | current_version | latest_version | direct(true/false)'. Example: 'express | 4.17.0 | 4.18.2 | true'", + "items": { + "type": "string" + }, + "title": "Outdated", + "type": "array" + }, + "sbom": { + "description": "One string per dependency. Format: 'name | version | ecosystem | direct(true/false) | license'. Example: 'express | 4.18.2 | npm | true | MIT'", + "items": { + "type": "string" + }, + "title": "Sbom", + "type": "array" + } + }, + "title": "DependencyReportRaw", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/DuplicateCheck.json b/go/internal/harnessx/testdata/schemas/DuplicateCheck.json new file mode 100644 index 0000000..0e11f47 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/DuplicateCheck.json @@ -0,0 +1,31 @@ +{ + "description": "DESIGN.md \u00a75.5: quick duplicate check gate for dedup decisions.", + "properties": { + "duplicate_of": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Duplicate Of" + }, + "is_duplicate": { + "title": "Is Duplicate", + "type": "boolean" + }, + "reason": { + "title": "Reason", + "type": "string" + } + }, + "required": [ + "is_duplicate", + "reason" + ], + "title": "DuplicateCheck", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/EnrichedFinding.json b/go/internal/harnessx/testdata/schemas/EnrichedFinding.json new file mode 100644 index 0000000..90c7329 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/EnrichedFinding.json @@ -0,0 +1,45 @@ +{ + "description": "Flat schema for Step 2: finding enrichment. 6 fields.", + "properties": { + "confidence": { + "description": "One of: \"high\", \"medium\", \"low\"", + "title": "Confidence", + "type": "string" + }, + "cwe_id": { + "description": "CWE identifier (e.g. 'CWE-89')", + "title": "Cwe Id", + "type": "string" + }, + "data_flow_summary": { + "description": "Natural language summary of the data flow (string, not nested)", + "title": "Data Flow Summary", + "type": "string" + }, + "description": { + "description": "Detailed description of the vulnerability", + "title": "Description", + "type": "string" + }, + "severity": { + "description": "One of: \"critical\", \"high\", \"medium\", \"low\", \"info\"", + "title": "Severity", + "type": "string" + }, + "title": { + "description": "Human-readable title for the finding", + "title": "Title", + "type": "string" + } + }, + "required": [ + "title", + "description", + "cwe_id", + "severity", + "confidence", + "data_flow_summary" + ], + "title": "EnrichedFinding", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/ExploitHypothesis.json b/go/internal/harnessx/testdata/schemas/ExploitHypothesis.json new file mode 100644 index 0000000..a7a7bd2 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/ExploitHypothesis.json @@ -0,0 +1,34 @@ +{ + "description": "Flat schema for exploit construction sub-agent. 3 fields.", + "properties": { + "expected_outcome": { + "description": "What would happen if exploit succeeds", + "title": "Expected Outcome", + "type": "string" + }, + "hypothesis": { + "description": "Natural language description of exploit scenario", + "title": "Hypothesis", + "type": "string" + }, + "payload": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Concrete exploit payload or input", + "title": "Payload" + } + }, + "required": [ + "hypothesis", + "expected_outcome" + ], + "title": "ExploitHypothesis", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/PolicyEvalResult.json b/go/internal/harnessx/testdata/schemas/PolicyEvalResult.json new file mode 100644 index 0000000..62e465d --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/PolicyEvalResult.json @@ -0,0 +1,33 @@ +{ + "description": "Flat schema for AI policy evaluation. 4 fields.", + "properties": { + "description": { + "description": "How the policy is violated, or 'No violation' if compliant", + "title": "Description", + "type": "string" + }, + "file_path": { + "description": "Primary file where violation occurs, or 'N/A'", + "title": "File Path", + "type": "string" + }, + "severity": { + "description": "Severity: \"high\", \"medium\", or \"low\"", + "title": "Severity", + "type": "string" + }, + "violated": { + "description": "Whether the policy is violated", + "title": "Violated", + "type": "boolean" + } + }, + "required": [ + "violated", + "description", + "file_path", + "severity" + ], + "title": "PolicyEvalResult", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/ReachabilityGate.json b/go/internal/harnessx/testdata/schemas/ReachabilityGate.json new file mode 100644 index 0000000..75b991a --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/ReachabilityGate.json @@ -0,0 +1,26 @@ +{ + "description": "Reachability assessment for findings without explicit reachability tags.", + "properties": { + "confidence": { + "description": "One of: \"high\", \"medium\", \"low\".", + "title": "Confidence", + "type": "string" + }, + "rationale": { + "title": "Rationale", + "type": "string" + }, + "reachability": { + "description": "One of: \"externally_reachable\", \"requires_auth\", \"internal_only\", \"unreachable\".", + "title": "Reachability", + "type": "string" + } + }, + "required": [ + "reachability", + "rationale", + "confidence" + ], + "title": "ReachabilityGate", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/ReachabilityProof.json b/go/internal/harnessx/testdata/schemas/ReachabilityProof.json new file mode 100644 index 0000000..bf4f5e9 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/ReachabilityProof.json @@ -0,0 +1,36 @@ +{ + "description": "Flat schema for dependency reachability analysis. 4 fields.", + "properties": { + "call_chain": { + "description": "Import/call chain from app code to vulnerable function", + "items": { + "type": "string" + }, + "title": "Call Chain", + "type": "array" + }, + "direct": { + "description": "Whether the dependency is direct or transitive", + "title": "Direct", + "type": "boolean" + }, + "reachable": { + "description": "Whether the vulnerable function is actually called", + "title": "Reachable", + "type": "boolean" + }, + "vulnerable_function": { + "description": "The vulnerable function/method in the dependency", + "title": "Vulnerable Function", + "type": "string" + } + }, + "required": [ + "vulnerable_function", + "call_chain", + "reachable", + "direct" + ], + "title": "ReachabilityProof", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/RemediationSuggestion.json b/go/internal/harnessx/testdata/schemas/RemediationSuggestion.json new file mode 100644 index 0000000..5722347 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/RemediationSuggestion.json @@ -0,0 +1,27 @@ +{ + "description": "Flat schema for AI-generated remediation suggestion. 3 fields.", + "properties": { + "confidence": { + "description": "Confidence in the fix: \"high\", \"medium\", or \"low\"", + "title": "Confidence", + "type": "string" + }, + "fix_description": { + "description": "Natural language description of the recommended fix", + "title": "Fix Description", + "type": "string" + }, + "patch_diff": { + "description": "Unified diff format patch showing the code changes needed", + "title": "Patch Diff", + "type": "string" + } + }, + "required": [ + "fix_description", + "patch_diff", + "confidence" + ], + "title": "RemediationSuggestion", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/SanitizationResult.json b/go/internal/harnessx/testdata/schemas/SanitizationResult.json new file mode 100644 index 0000000..07ef486 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/SanitizationResult.json @@ -0,0 +1,54 @@ +{ + "description": "Flat schema for sanitization analysis sub-agent. 4 fields.", + "properties": { + "bypass_method": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "How sanitization could be bypassed, if applicable", + "title": "Bypass Method" + }, + "found": { + "description": "Whether any sanitization/validation was found on the path", + "title": "Found", + "type": "boolean" + }, + "sufficient": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Whether sanitization is sufficient to prevent exploit", + "title": "Sufficient" + }, + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Type of sanitization (e.g. 'parameterized query', 'html encoding')", + "title": "Type" + } + }, + "required": [ + "found" + ], + "title": "SanitizationResult", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/ScanLocationsResult.json b/go/internal/harnessx/testdata/schemas/ScanLocationsResult.json new file mode 100644 index 0000000..77d54fd --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/ScanLocationsResult.json @@ -0,0 +1,49 @@ +{ + "$defs": { + "VulnLocation": { + "description": "Flat schema for Step 1: location scanning. 4 fields.", + "properties": { + "code_snippet": { + "description": "Relevant code snippet around the vulnerability", + "title": "Code Snippet", + "type": "string" + }, + "file_path": { + "description": "Path to the file containing the potential vulnerability", + "title": "File Path", + "type": "string" + }, + "pattern_type": { + "description": "Type of vulnerability pattern detected (e.g. 'sql_injection', 'command_injection')", + "title": "Pattern Type", + "type": "string" + }, + "start_line": { + "description": "Starting line number of the vulnerable code", + "title": "Start Line", + "type": "integer" + } + }, + "required": [ + "file_path", + "start_line", + "code_snippet", + "pattern_type" + ], + "title": "VulnLocation", + "type": "object" + } + }, + "description": "Container for Step 1 results.", + "properties": { + "locations": { + "items": { + "$ref": "#/$defs/VulnLocation" + }, + "title": "Locations", + "type": "array" + } + }, + "title": "ScanLocationsResult", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/SecurityContextRaw.json b/go/internal/harnessx/testdata/schemas/SecurityContextRaw.json new file mode 100644 index 0000000..43eae9f --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/SecurityContextRaw.json @@ -0,0 +1,37 @@ +{ + "description": "Flat harness output for security context profiler. All flat, no nesting.", + "properties": { + "auth_details": { + "default": "", + "description": "Brief description of auth implementation details", + "title": "Auth Details", + "type": "string" + }, + "auth_model": { + "description": "Authentication model: jwt, session_cookie, oauth2, api_key, none, or other", + "title": "Auth Model", + "type": "string" + }, + "crypto_usage": { + "description": "One string per crypto usage. Format: 'algorithm | key_size | mode | usage_context | is_weak(true/false)'. Example: 'AES | 256 | GCM | data encryption | false'", + "items": { + "type": "string" + }, + "title": "Crypto Usage", + "type": "array" + }, + "security_signals": { + "description": "Framework security features, security headers, and deployment signals. One signal per entry. Examples: 'CSRF protection enabled', 'HSTS header present', 'Runs in Docker'", + "items": { + "type": "string" + }, + "title": "Security Signals", + "type": "array" + } + }, + "required": [ + "auth_model" + ], + "title": "SecurityContextRaw", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/SeverityClassification.json b/go/internal/harnessx/testdata/schemas/SeverityClassification.json new file mode 100644 index 0000000..fcc533f --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/SeverityClassification.json @@ -0,0 +1,25 @@ +{ + "description": "DESIGN.md \u00a72.4: quick severity classification gate used in scoring.", + "properties": { + "confidence": { + "title": "Confidence", + "type": "number" + }, + "rationale": { + "title": "Rationale", + "type": "string" + }, + "severity": { + "description": "One of: \"critical\", \"high\", \"medium\", \"low\".", + "title": "Severity", + "type": "string" + } + }, + "required": [ + "severity", + "confidence", + "rationale" + ], + "title": "SeverityClassification", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/StrategySelection.json b/go/internal/harnessx/testdata/schemas/StrategySelection.json new file mode 100644 index 0000000..ab6c485 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/StrategySelection.json @@ -0,0 +1,22 @@ +{ + "description": "DESIGN.md \u00a75.3: strategy selection gate for HUNT routing.", + "properties": { + "rationale": { + "title": "Rationale", + "type": "string" + }, + "strategies": { + "items": { + "type": "string" + }, + "title": "Strategies", + "type": "array" + } + }, + "required": [ + "strategies", + "rationale" + ], + "title": "StrategySelection", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/VerdictDecision.json b/go/internal/harnessx/testdata/schemas/VerdictDecision.json new file mode 100644 index 0000000..7b93379 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/VerdictDecision.json @@ -0,0 +1,32 @@ +{ + "description": "Flat schema for verdict sub-agent. Uses .ai() not .harness(). 4 fields.", + "properties": { + "confidence": { + "description": "One of: \"high\", \"medium\", \"low\"", + "title": "Confidence", + "type": "string" + }, + "evidence_level": { + "description": "1-6 scale: 1=STATIC_MATCH to 6=FULL_EXPLOIT", + "title": "Evidence Level", + "type": "integer" + }, + "rationale": { + "title": "Rationale", + "type": "string" + }, + "verdict": { + "description": "One of: \"confirmed\", \"likely\", \"inconclusive\", \"not_exploitable\"", + "title": "Verdict", + "type": "string" + } + }, + "required": [ + "verdict", + "evidence_level", + "rationale", + "confidence" + ], + "title": "VerdictDecision", + "type": "object" +} diff --git a/go/internal/monitoring/monitoring.go b/go/internal/monitoring/monitoring.go new file mode 100644 index 0000000..804e817 --- /dev/null +++ b/go/internal/monitoring/monitoring.go @@ -0,0 +1,278 @@ +// Package monitoring ports src/sec_af/monitoring.py — continuous monitoring: +// baseline storage and regression detection. +// +// It compares a current SecurityAuditResult against a stored baseline to +// identify new vulnerabilities (regressions) and fixed issues, keyed on the +// finding FINGERPRINT (the stable content hash), never on the finding id. +// +// Python parity — scope: monitoring.py is NOT wired into app.py. AuditInput +// declares `monitoring_mode` and `baseline_path`, but nothing reads them, so no +// reasoner calls save_baseline/compare_with_baseline today. This package is +// ported for 1:1 completeness (and because the baseline file format is a +// user-visible artifact); it must NOT be wired into the Go node either, or the +// two implementations would diverge in behavior rather than only in language. +package monitoring + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// BaselineFinding is one finding as stored in a baseline file. +// +// Ports monitoring.py's BaselineFinding TypedDict. The Go struct's FIELD ORDER +// is the dict-literal order in save_baseline, which is what json.dumps writes +// (Python dicts are insertion-ordered) — see baselineFindingObject. +type BaselineFinding struct { + ID string `json:"id"` + Fingerprint string `json:"fingerprint"` + Title string `json:"title"` + Severity string `json:"severity"` + CweID string `json:"cwe_id"` + Verdict string `json:"verdict"` + FilePath string `json:"file_path"` + StartLine int `json:"start_line"` +} + +// BaselineData is the whole baseline document. +// +// Ports monitoring.py's BaselineData TypedDict. Because Python reads the two +// top-level keys DIFFERENTLY — `baseline.get("commit_sha", "unknown")` tolerates +// an absent key, `baseline["findings"]` raises KeyError on one — a decoded +// BaselineData also remembers which keys the file actually carried. Use +// CommitShaOr and FindingsPresent rather than reading the fields directly when +// the file may not have come from SaveBaseline. +type BaselineData struct { + CommitSha string `json:"commit_sha"` + Timestamp string `json:"timestamp"` + Findings []BaselineFinding `json:"findings"` + + commitShaPresent bool + findingsPresent bool +} + +// UnmarshalJSON decodes the document and records top-level key presence. +// +// Python parity: json.loads on a non-object (a list, a bare number) succeeds +// and the TypedDict cast is a no-op, so the failure surfaces later as a +// TypeError from `baseline["findings"]`. Go cannot decode a non-object into a +// struct at all, so the failure moves up to LoadBaseline — same outcome (an +// error, no result), earlier and with a clearer message. +func (d *BaselineData) UnmarshalJSON(b []byte) error { + var probe map[string]json.RawMessage + if err := json.Unmarshal(b, &probe); err != nil { + return err + } + type alias BaselineData + var a alias + if err := json.Unmarshal(b, &a); err != nil { + return err + } + *d = BaselineData(a) + _, d.commitShaPresent = probe["commit_sha"] + _, d.findingsPresent = probe["findings"] + return nil +} + +// CommitShaOr ports `baseline.get("commit_sha", fallback)`: an ABSENT key +// yields fallback, a present one yields its value even when that value is the +// empty string. +func (d BaselineData) CommitShaOr(fallback string) string { + if !d.commitShaPresent { + return fallback + } + return d.CommitSha +} + +// FindingsPresent reports whether the decoded document carried a "findings" +// key at all — the condition Python's `baseline["findings"]` subscript turns +// into a KeyError. +func (d BaselineData) FindingsPresent() bool { return d.findingsPresent } + +// SaveBaseline saves scan results as a baseline for future comparison. +// +// Ports monitoring.py save_baseline. The bytes are `json.dumps(baseline_data, +// indent=2)` — see pyjson.go for why encoding/json cannot produce them — with +// NO trailing newline, matching `Path(path).write_text(...)`. +// +// Python parity: write_text neither creates parent directories nor tolerates a +// missing one; os.WriteFile behaves the same. The 0o644 mode is Go's explicit +// spelling of what CPython's open() defaults to (0o666 before umask). +func SaveBaseline(result schemas.SecurityAuditResult, path string) error { + doc := BaselineDataFor(result) + if err := os.WriteFile(path, []byte(pyfmt.Dumps(baselineObject(doc), 2)), 0o644); err != nil { + return fmt.Errorf("monitoring: save baseline %q: %w", path, err) + } + return nil +} + +// BaselineDataFor projects a SecurityAuditResult into the baseline document +// save_baseline writes. Exported so callers can inspect (or diff) a baseline +// without touching the filesystem; SaveBaseline is this plus the write. +// +// Python parity: `result.timestamp.isoformat()` — schemas.Timestamp.String() is +// that exact spelling — and `.value` on the two enums, which for a `str, Enum` +// is the plain string. +func BaselineDataFor(result schemas.SecurityAuditResult) BaselineData { + findings := make([]BaselineFinding, 0, len(result.Findings)) + for _, f := range result.Findings { + findings = append(findings, BaselineFinding{ + ID: f.ID, + Fingerprint: f.Fingerprint, + Title: f.Title, + Severity: string(f.Severity), + CweID: f.CweID, + Verdict: string(f.Verdict), + FilePath: f.Location.FilePath, + StartLine: f.Location.StartLine, + }) + } + return BaselineData{ + CommitSha: result.CommitSha, + Timestamp: result.Timestamp.String(), + Findings: findings, + commitShaPresent: true, + findingsPresent: true, + } +} + +// baselineObject renders a BaselineData in the exact key order of +// save_baseline's dict literals. +// +// pyfmt.Ordered, not map[string]any: a Go map has no insertion order and +// pyfmt.Dumps sorts map keys, which would reorder the file. The order of the +// literals in monitoring.py save_baseline is part of the artifact users diff. +func baselineObject(d BaselineData) pyfmt.Ordered { + findings := make([]any, 0, len(d.Findings)) + for _, f := range d.Findings { + findings = append(findings, baselineFindingObject(f)) + } + return pyfmt.O( + "commit_sha", d.CommitSha, + "timestamp", d.Timestamp, + "findings", findings, + ) +} + +// baselineFindingObject renders one finding in save_baseline's dict-literal +// key order. +func baselineFindingObject(f BaselineFinding) pyfmt.Ordered { + return pyfmt.O( + "id", f.ID, + "fingerprint", f.Fingerprint, + "title", f.Title, + "severity", f.Severity, + "cwe_id", f.CweID, + "verdict", f.Verdict, + "file_path", f.FilePath, + "start_line", f.StartLine, + ) +} + +// LoadBaseline loads baseline scan data from a file. +// +// Ports monitoring.py load_baseline: `json.loads(Path(path).read_text( +// encoding="utf-8"))`. A missing file or malformed JSON is an error, exactly as +// Python raises OSError / json.JSONDecodeError. +func LoadBaseline(path string) (BaselineData, error) { + var out BaselineData + b, err := os.ReadFile(path) + if err != nil { + return out, fmt.Errorf("monitoring: load baseline %q: %w", path, err) + } + if err := json.Unmarshal(b, &out); err != nil { + return out, fmt.Errorf("monitoring: parse baseline %q: %w", path, err) + } + return out, nil +} + +// CompareWithBaseline compares current scan results against a stored baseline. +// +// Ports monitoring.py compare_with_baseline. The returned MonitoringResult is +// built from schemas.NewMonitoringResult so the two list fields are `[]` rather +// than null when nothing changed, matching pydantic's default_factory=list. +// +// Python parity, four behaviors that are easy to "clean up" by accident: +// +// - new_findings follows the ORDER of current.findings, and a fingerprint that +// appears twice in the current scan is reported twice (the loop appends per +// finding, it does not deduplicate). +// - fixed_findings follows the FIRST-SEEN order of fingerprints in the +// baseline, but carries the LAST record for a repeated fingerprint — that is +// what the dict comprehension `{f["fingerprint"]: f for f in ...}` produces. +// - unchanged_count counts DISTINCT shared fingerprints (a set intersection), +// so it need not equal len(current.findings) - len(new_findings). +// - regression_detected is `len(new_findings) > 0` only; fixed findings never +// set it. +func CompareWithBaseline(current schemas.SecurityAuditResult, baselinePath string) (schemas.MonitoringResult, error) { + out := schemas.NewMonitoringResult() + + baseline, err := LoadBaseline(baselinePath) + if err != nil { + return out, err + } + if !baseline.FindingsPresent() { + // Python parity: `baseline["findings"]` raises KeyError here. + return out, fmt.Errorf("monitoring: baseline %q has no %q key", baselinePath, "findings") + } + + baselineFingerprints := make(map[string]struct{}, len(baseline.Findings)) + baselineByFP := make(map[string]BaselineFinding, len(baseline.Findings)) + baselineFPOrder := make([]string, 0, len(baseline.Findings)) + for _, f := range baseline.Findings { + if _, seen := baselineByFP[f.Fingerprint]; !seen { + baselineFPOrder = append(baselineFPOrder, f.Fingerprint) + } + baselineFingerprints[f.Fingerprint] = struct{}{} + baselineByFP[f.Fingerprint] = f + } + + currentFingerprints := make(map[string]struct{}, len(current.Findings)) + for _, f := range current.Findings { + currentFingerprints[f.Fingerprint] = struct{}{} + } + + for _, finding := range current.Findings { + if _, inBaseline := baselineFingerprints[finding.Fingerprint]; inBaseline { + continue + } + out.NewFindings = append(out.NewFindings, schemas.RegressionFinding{ + FindingTitle: finding.Title, + FindingID: finding.ID, + Severity: string(finding.Severity), + CweID: finding.CweID, + Status: "new", + }) + } + + for _, fp := range baselineFPOrder { + if _, stillPresent := currentFingerprints[fp]; stillPresent { + continue + } + bf := baselineByFP[fp] + out.FixedFindings = append(out.FixedFindings, schemas.RegressionFinding{ + FindingTitle: bf.Title, + FindingID: bf.ID, + Severity: bf.Severity, + CweID: bf.CweID, + Status: "fixed", + }) + } + + unchanged := 0 + for fp := range baselineFingerprints { + if _, shared := currentFingerprints[fp]; shared { + unchanged++ + } + } + + out.BaselineCommit = baseline.CommitShaOr("unknown") + out.CurrentCommit = current.CommitSha + out.UnchangedCount = unchanged + out.RegressionDetected = len(out.NewFindings) > 0 + return out, nil +} diff --git a/go/internal/monitoring/monitoring_test.go b/go/internal/monitoring/monitoring_test.go new file mode 100644 index 0000000..e4bc1a3 --- /dev/null +++ b/go/internal/monitoring/monitoring_test.go @@ -0,0 +1,597 @@ +package monitoring + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// makeFinding ports tests/test_monitoring.py::_make_finding. The optional +// arguments Python defaults are spelled out at every call site, since Go has no +// keyword defaults. +func makeFinding(fingerprint, title string, severity schemas.Severity, cweID string) schemas.VerifiedFinding { + f := schemas.NewVerifiedFinding() + f.Fingerprint = fingerprint + f.Title = title + f.Description = "Test" + f.FindingType = schemas.FindingTypeSast + f.CweID = cweID + f.CweName = "SQL Injection" + f.Verdict = schemas.VerdictConfirmed + f.EvidenceLevel = schemas.EvidenceLevelReachabilityConfirmed + f.Rationale = "Test" + f.Severity = severity + f.ExploitabilityScore = 7.5 + f.Location = schemas.Location{FilePath: "app.py", StartLine: 10, EndLine: 15} + f.SarifRuleID = "sec-af/sast/cwe-89" + f.SarifSecuritySeverity = 7.5 + return f +} + +// defaultFinding is _make_finding() with every Python default applied. +func defaultFinding(fingerprint string) schemas.VerifiedFinding { + return makeFinding(fingerprint, "Test Finding", schemas.SeverityHigh, "CWE-89") +} + +// makeResult ports tests/test_monitoring.py::_make_result. +func makeResult(findings []schemas.VerifiedFinding, commit string) schemas.SecurityAuditResult { + r := schemas.NewSecurityAuditResult() + r.Repository = "https://github.com/test/repo" + r.CommitSha = commit + branch := "main" + r.Branch = &branch + r.Timestamp = schemas.NewTimestamp(time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC)) + r.DepthProfile = "standard" + r.StrategiesUsed = []string{"injection"} + r.Provider = "harness" + r.Findings = findings + r.AttackChains = []schemas.AttackChain{} + r.TotalRawFindings = len(findings) + r.Confirmed = len(findings) + r.NoiseReductionPct = 0.0 + r.BySeverity = map[string]int{} + r.DurationSeconds = 10.0 + r.AgentInvocations = 5 + r.CostUsd = 0.05 + r.CostBreakdown = map[string]float64{} + r.Sarif = "" + return r +} + +func tempBaselinePath(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "baseline.json") +} + +// --------------------------------------------------------------------------- +// Ported Python tests +// --------------------------------------------------------------------------- + +// TestSaveAndLoadBaseline ports +// tests/test_monitoring.py::test_save_and_load_baseline. +func TestSaveAndLoadBaseline(t *testing.T) { + result := makeResult([]schemas.VerifiedFinding{defaultFinding("fp-1")}, "abc123") + path := tempBaselinePath(t) + + if err := SaveBaseline(result, path); err != nil { + t.Fatalf("SaveBaseline: %v", err) + } + loaded, err := LoadBaseline(path) + if err != nil { + t.Fatalf("LoadBaseline: %v", err) + } + if loaded.CommitSha != "abc123" { + t.Errorf("CommitSha = %q, want %q", loaded.CommitSha, "abc123") + } + if len(loaded.Findings) != 1 { + t.Fatalf("len(Findings) = %d, want 1", len(loaded.Findings)) + } +} + +// TestCompareDetectsNewFinding ports +// tests/test_monitoring.py::test_compare_detects_new_finding. +func TestCompareDetectsNewFinding(t *testing.T) { + path := tempBaselinePath(t) + baseline := makeResult([]schemas.VerifiedFinding{defaultFinding("fp-1")}, "old") + if err := SaveBaseline(baseline, path); err != nil { + t.Fatalf("SaveBaseline: %v", err) + } + + current := makeResult([]schemas.VerifiedFinding{ + defaultFinding("fp-1"), + makeFinding("fp-2", "New Bug", schemas.SeverityHigh, "CWE-89"), + }, "new") + + result, err := CompareWithBaseline(current, path) + if err != nil { + t.Fatalf("CompareWithBaseline: %v", err) + } + if !result.RegressionDetected { + t.Error("RegressionDetected = false, want true") + } + if len(result.NewFindings) != 1 { + t.Fatalf("len(NewFindings) = %d, want 1", len(result.NewFindings)) + } + if result.NewFindings[0].FindingTitle != "New Bug" { + t.Errorf("NewFindings[0].FindingTitle = %q, want %q", result.NewFindings[0].FindingTitle, "New Bug") + } + if result.UnchangedCount != 1 { + t.Errorf("UnchangedCount = %d, want 1", result.UnchangedCount) + } + // Not asserted by the Python test but implied by the model: the new record + // carries the current finding's identity and status. + if result.NewFindings[0].Status != "new" { + t.Errorf("NewFindings[0].Status = %q, want %q", result.NewFindings[0].Status, "new") + } + if result.NewFindings[0].Severity != "high" || result.NewFindings[0].CweID != "CWE-89" { + t.Errorf("NewFindings[0] severity/cwe = %q/%q, want high/CWE-89", + result.NewFindings[0].Severity, result.NewFindings[0].CweID) + } + if result.BaselineCommit != "old" || result.CurrentCommit != "new" { + t.Errorf("commits = %q/%q, want old/new", result.BaselineCommit, result.CurrentCommit) + } +} + +// TestCompareDetectsFixedFinding ports +// tests/test_monitoring.py::test_compare_detects_fixed_finding. +func TestCompareDetectsFixedFinding(t *testing.T) { + path := tempBaselinePath(t) + baseline := makeResult([]schemas.VerifiedFinding{ + defaultFinding("fp-1"), + defaultFinding("fp-2"), + }, "old") + if err := SaveBaseline(baseline, path); err != nil { + t.Fatalf("SaveBaseline: %v", err) + } + + current := makeResult([]schemas.VerifiedFinding{defaultFinding("fp-1")}, "new") + + result, err := CompareWithBaseline(current, path) + if err != nil { + t.Fatalf("CompareWithBaseline: %v", err) + } + if result.RegressionDetected { + t.Error("RegressionDetected = true, want false — a fixed finding is not a regression") + } + if len(result.FixedFindings) != 1 { + t.Fatalf("len(FixedFindings) = %d, want 1", len(result.FixedFindings)) + } + if result.FixedFindings[0].Status != "fixed" { + t.Errorf("FixedFindings[0].Status = %q, want %q", result.FixedFindings[0].Status, "fixed") + } +} + +// TestCompareNoRegression ports +// tests/test_monitoring.py::test_compare_no_regression. +func TestCompareNoRegression(t *testing.T) { + path := tempBaselinePath(t) + if err := SaveBaseline(makeResult([]schemas.VerifiedFinding{defaultFinding("fp-1")}, "old"), path); err != nil { + t.Fatalf("SaveBaseline: %v", err) + } + + result, err := CompareWithBaseline(makeResult([]schemas.VerifiedFinding{defaultFinding("fp-1")}, "new"), path) + if err != nil { + t.Fatalf("CompareWithBaseline: %v", err) + } + if result.RegressionDetected { + t.Error("RegressionDetected = true, want false") + } + if len(result.NewFindings) != 0 { + t.Errorf("len(NewFindings) = %d, want 0", len(result.NewFindings)) + } + if result.UnchangedCount != 1 { + t.Errorf("UnchangedCount = %d, want 1", result.UnchangedCount) + } +} + +// --------------------------------------------------------------------------- +// Byte-level file-format parity +// --------------------------------------------------------------------------- + +// goldenTitle is gen_golden.py's _BASELINE_TITLE: a quote, a backslash, `<`, +// `>`, `&`, a BMP non-ASCII rune, an em dash, a tab, a newline, DEL, and an +// astral rune. Everything Go's encoder would escape differently from Python's. +const goldenTitle = "SQL injection in \"users\" & café \u2014 \\path\ttab\nnewline\u007f \U0001f600" + +func readGolden(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "golden", name)) + if err != nil { + t.Fatalf("read golden %s: %v", name, err) + } + return string(b) +} + +// goldenResult rebuilds gen_golden.py's `_make_result([_make_finding("fp-1", +// _BASELINE_TITLE, "id-1")])`. +func goldenResult(findings []schemas.VerifiedFinding) schemas.SecurityAuditResult { + return makeResult(findings, "abc123") +} + +// TestSaveBaselineMatchesPythonBytes is the byte-for-byte golden test for +// save_baseline's on-disk format: Python's indent=2 layout AND its +// ensure_ascii=True / no-HTML-escaping string encoding. +// +// Golden produced by go/scripts/gen_golden.py from the real Python function. +// +// NOTE (integration): that script no longer carries a monitoring section — it +// was lost to a concurrent rewrite during the port, so a re-run does NOT +// refresh baseline.json / baseline_empty.json. The committed bytes are the ones +// it produced from the real save_baseline; re-derive by hand if the Python +// changes. See the COVERAGE GAP comment in gen_golden.py. +func TestSaveBaselineMatchesPythonBytes(t *testing.T) { + f := makeFinding("fp-1", goldenTitle, schemas.SeverityHigh, "CWE-89") + f.ID = "id-1" + path := tempBaselinePath(t) + if err := SaveBaseline(goldenResult([]schemas.VerifiedFinding{f}), path); err != nil { + t.Fatalf("SaveBaseline: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read back: %v", err) + } + if want := readGolden(t, "baseline.json"); string(got) != want { + t.Errorf("baseline bytes differ from Python\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +// TestSaveBaselineEmptyFindingsMatchesPythonBytes pins the empty-list case, +// which json.dumps collapses to `[]` with no inner newline. +func TestSaveBaselineEmptyFindingsMatchesPythonBytes(t *testing.T) { + path := tempBaselinePath(t) + if err := SaveBaseline(goldenResult(nil), path); err != nil { + t.Fatalf("SaveBaseline: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read back: %v", err) + } + if want := readGolden(t, "baseline_empty.json"); string(got) != want { + t.Errorf("baseline bytes differ from Python\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +// TestSaveBaselineHasNoTrailingNewline pins `write_text(json.dumps(...))`: +// json.dumps does not append one and write_text does not either. +func TestSaveBaselineHasNoTrailingNewline(t *testing.T) { + path := tempBaselinePath(t) + if err := SaveBaseline(goldenResult(nil), path); err != nil { + t.Fatalf("SaveBaseline: %v", err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read back: %v", err) + } + if strings.HasSuffix(string(b), "\n") { + t.Errorf("baseline file ends with a newline: %q", string(b[len(b)-8:])) + } +} + +// TestSaveBaselineRoundTripsThroughLoad proves the written file is still valid +// JSON that decodes to the same values — the escaping is cosmetic, not lossy. +func TestSaveBaselineRoundTripsThroughLoad(t *testing.T) { + f := makeFinding("fp-1", goldenTitle, schemas.SeverityHigh, "CWE-89") + f.ID = "id-1" + path := tempBaselinePath(t) + if err := SaveBaseline(goldenResult([]schemas.VerifiedFinding{f}), path); err != nil { + t.Fatalf("SaveBaseline: %v", err) + } + loaded, err := LoadBaseline(path) + if err != nil { + t.Fatalf("LoadBaseline: %v", err) + } + want := BaselineFinding{ + ID: "id-1", + Fingerprint: "fp-1", + Title: goldenTitle, + Severity: "high", + CweID: "CWE-89", + Verdict: "confirmed", + FilePath: "app.py", + StartLine: 10, + } + if len(loaded.Findings) != 1 || !reflect.DeepEqual(loaded.Findings[0], want) { + t.Errorf("round-tripped finding = %+v, want %+v", loaded.Findings, want) + } + if loaded.Timestamp != "2025-01-15T00:00:00+00:00" { + t.Errorf("Timestamp = %q, want the datetime.isoformat() spelling", loaded.Timestamp) + } +} + +// TestBaselineStringEscaping is a table of `json.dumps(s)` ground truth. Every +// expected value is what the venv interpreter prints for json.dumps of the same +// input. +// +// The encoder under test is pyfmt.Dumps — this package used to carry its own +// copy of CPython's ensure_ascii escaping (pyjson.go) because pyfmt.Dumps did +// not exist yet. The copy is gone; the table stays here, unchanged, because a +// baseline file's titles are LLM-authored prose and this is where a regression +// in that escaping would actually be felt. +func TestBaselineStringEscaping(t *testing.T) { + cases := []struct{ in, want string }{ + {"", `""`}, + {"plain", `"plain"`}, + {`say "hi"`, `"say \"hi\""`}, + {`back\slash`, `"back\\slash"`}, + // NOT escaped by Python (Go's encoder escapes all three). + {" & ", `" & "`}, + {"tab\there", `"tab\there"`}, + {"nl\nhere", `"nl\nhere"`}, + {"cr\rhere", `"cr\rhere"`}, + {"bs\bhere", `"bs\bhere"`}, + {"ff\fhere", `"ff\fhere"`}, + {"\x00\x01\x1f", `"\u0000\u0001\u001f"`}, + {"\x7f", `"\u007f"`}, + {"café", `"caf\u00e9"`}, + {"\u2014", `"\u2014"`}, + {"\u2028\u2029", `"\u2028\u2029"`}, + {"\U0001f600", `"\ud83d\ude00"`}, + } + for _, tc := range cases { + if got := pyfmt.Dumps(tc.in, 2); got != tc.want { + t.Errorf("pyfmt.Dumps(%q) = %s, want %s", tc.in, got, tc.want) + } + } +} + +// TestBaselineIndentLayout pins the container layout rules independently of the +// baseline shape: nesting indentation, the ": " key separator, and empty +// containers collapsing. Same provenance as TestBaselineStringEscaping — the +// expectations came from the venv interpreter and now guard pyfmt.Dumps as this +// package uses it. +func TestBaselineIndentLayout(t *testing.T) { + doc := pyfmt.O( + "a", "x", + "empty_list", []any{}, + "empty_obj", pyfmt.Ordered{}, + "list", []any{pyfmt.O("i", 1, "j", 2), "s"}, + ) + want := `{ + "a": "x", + "empty_list": [], + "empty_obj": {}, + "list": [ + { + "i": 1, + "j": 2 + }, + "s" + ] +}` + if got := pyfmt.Dumps(doc, 2); got != want { + t.Errorf("pyfmt.Dumps layout mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +// --------------------------------------------------------------------------- +// compare_with_baseline behaviors the Python tests do not cover +// --------------------------------------------------------------------------- + +func writeBaselineJSON(t *testing.T, body string) string { + t.Helper() + path := tempBaselinePath(t) + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write baseline: %v", err) + } + return path +} + +// TestCompareMissingCommitShaFallsBackToUnknown ports +// `baseline.get("commit_sha", "unknown")`. +func TestCompareMissingCommitShaFallsBackToUnknown(t *testing.T) { + path := writeBaselineJSON(t, `{"timestamp": "2025-01-15T00:00:00+00:00", "findings": []}`) + + result, err := CompareWithBaseline(makeResult(nil, "cur"), path) + if err != nil { + t.Fatalf("CompareWithBaseline: %v", err) + } + if result.BaselineCommit != "unknown" { + t.Errorf("BaselineCommit = %q, want %q", result.BaselineCommit, "unknown") + } +} + +// TestCompareEmptyCommitShaIsNotUnknown proves .get() only falls back for an +// ABSENT key — a present empty string stays empty. +func TestCompareEmptyCommitShaIsNotUnknown(t *testing.T) { + path := writeBaselineJSON(t, `{"commit_sha": "", "timestamp": "t", "findings": []}`) + + result, err := CompareWithBaseline(makeResult(nil, "cur"), path) + if err != nil { + t.Fatalf("CompareWithBaseline: %v", err) + } + if result.BaselineCommit != "" { + t.Errorf("BaselineCommit = %q, want the empty string", result.BaselineCommit) + } +} + +// TestCompareMissingFindingsKeyIsAnError ports the KeyError Python raises for +// `baseline["findings"]` when the key is absent. +func TestCompareMissingFindingsKeyIsAnError(t *testing.T) { + path := writeBaselineJSON(t, `{"commit_sha": "old", "timestamp": "t"}`) + + if _, err := CompareWithBaseline(makeResult(nil, "cur"), path); err == nil { + t.Fatal("expected an error for a baseline with no findings key") + } +} + +// TestCompareMissingBaselineFileIsAnError mirrors the OSError load_baseline +// raises for a path that does not exist. +func TestCompareMissingBaselineFileIsAnError(t *testing.T) { + path := filepath.Join(t.TempDir(), "does-not-exist.json") + if _, err := CompareWithBaseline(makeResult(nil, "cur"), path); err == nil { + t.Fatal("expected an error for a missing baseline file") + } + if _, err := LoadBaseline(path); err == nil { + t.Fatal("expected an error from LoadBaseline for a missing file") + } +} + +// TestLoadBaselineMalformedJSONIsAnError mirrors json.JSONDecodeError. +func TestLoadBaselineMalformedJSONIsAnError(t *testing.T) { + if _, err := LoadBaseline(writeBaselineJSON(t, "{not json")); err == nil { + t.Fatal("expected an error for malformed JSON") + } + // A valid JSON document that is not an object: Python would fail later, at + // the `baseline["findings"]` subscript; Go fails here. Either way, an error. + if _, err := LoadBaseline(writeBaselineJSON(t, "[1, 2, 3]")); err == nil { + t.Fatal("expected an error for a non-object baseline document") + } +} + +// TestCompareNewFindingsFollowCurrentOrderAndKeepDuplicates pins the loop +// semantics: current order is preserved and a repeated new fingerprint is +// reported once per occurrence. +func TestCompareNewFindingsFollowCurrentOrderAndKeepDuplicates(t *testing.T) { + path := tempBaselinePath(t) + if err := SaveBaseline(makeResult(nil, "old"), path); err != nil { + t.Fatalf("SaveBaseline: %v", err) + } + + current := makeResult([]schemas.VerifiedFinding{ + makeFinding("fp-z", "Z", schemas.SeverityLow, "CWE-1"), + makeFinding("fp-a", "A", schemas.SeverityHigh, "CWE-2"), + makeFinding("fp-z", "Z again", schemas.SeverityLow, "CWE-1"), + }, "cur") + + result, err := CompareWithBaseline(current, path) + if err != nil { + t.Fatalf("CompareWithBaseline: %v", err) + } + got := []string{} + for _, f := range result.NewFindings { + got = append(got, f.FindingTitle) + } + if want := []string{"Z", "A", "Z again"}; !reflect.DeepEqual(got, want) { + t.Errorf("NewFindings titles = %v, want %v", got, want) + } + if result.UnchangedCount != 0 { + t.Errorf("UnchangedCount = %d, want 0", result.UnchangedCount) + } +} + +// TestCompareFixedFindingsFollowBaselineFirstSeenOrderWithLastRecord pins the +// `{f["fingerprint"]: f for f in ...}` semantics: FIRST-seen key order, LAST +// value. +func TestCompareFixedFindingsFollowBaselineFirstSeenOrderWithLastRecord(t *testing.T) { + path := writeBaselineJSON(t, `{ + "commit_sha": "old", + "timestamp": "t", + "findings": [ + {"id": "1", "fingerprint": "fp-b", "title": "B first", "severity": "low", "cwe_id": "CWE-1", "verdict": "confirmed", "file_path": "a.py", "start_line": 1}, + {"id": "2", "fingerprint": "fp-a", "title": "A", "severity": "high", "cwe_id": "CWE-2", "verdict": "likely", "file_path": "b.py", "start_line": 2}, + {"id": "3", "fingerprint": "fp-b", "title": "B last", "severity": "medium", "cwe_id": "CWE-3", "verdict": "confirmed", "file_path": "c.py", "start_line": 3} + ] +}`) + + result, err := CompareWithBaseline(makeResult(nil, "cur"), path) + if err != nil { + t.Fatalf("CompareWithBaseline: %v", err) + } + gotTitles := []string{} + gotIDs := []string{} + for _, f := range result.FixedFindings { + gotTitles = append(gotTitles, f.FindingTitle) + gotIDs = append(gotIDs, f.FindingID) + } + if want := []string{"B last", "A"}; !reflect.DeepEqual(gotTitles, want) { + t.Errorf("FixedFindings titles = %v, want %v (first-seen key order, last record)", gotTitles, want) + } + if want := []string{"3", "2"}; !reflect.DeepEqual(gotIDs, want) { + t.Errorf("FixedFindings ids = %v, want %v", gotIDs, want) + } + if result.FixedFindings[0].Severity != "medium" || result.FixedFindings[0].CweID != "CWE-3" { + t.Errorf("fixed record fields came from the wrong duplicate: %+v", result.FixedFindings[0]) + } +} + +// TestCompareUnchangedCountIsDistinctFingerprints proves unchanged_count is a +// SET intersection, so duplicates on either side count once. +func TestCompareUnchangedCountIsDistinctFingerprints(t *testing.T) { + path := tempBaselinePath(t) + baseline := makeResult([]schemas.VerifiedFinding{ + defaultFinding("fp-1"), defaultFinding("fp-1"), defaultFinding("fp-2"), + }, "old") + if err := SaveBaseline(baseline, path); err != nil { + t.Fatalf("SaveBaseline: %v", err) + } + + current := makeResult([]schemas.VerifiedFinding{ + defaultFinding("fp-1"), defaultFinding("fp-1"), defaultFinding("fp-2"), + }, "cur") + + result, err := CompareWithBaseline(current, path) + if err != nil { + t.Fatalf("CompareWithBaseline: %v", err) + } + if result.UnchangedCount != 2 { + t.Errorf("UnchangedCount = %d, want 2 (distinct fingerprints)", result.UnchangedCount) + } + if len(result.NewFindings) != 0 || len(result.FixedFindings) != 0 { + t.Errorf("expected no new/fixed findings, got %+v / %+v", result.NewFindings, result.FixedFindings) + } +} + +// TestCompareResultMarshalsEmptyListsNotNull proves the result uses pydantic's +// default_factory=list shape, so a clean comparison serializes `[]` rather than +// `null`. +func TestCompareResultMarshalsEmptyListsNotNull(t *testing.T) { + path := tempBaselinePath(t) + if err := SaveBaseline(makeResult(nil, "old"), path); err != nil { + t.Fatalf("SaveBaseline: %v", err) + } + result, err := CompareWithBaseline(makeResult(nil, "cur"), path) + if err != nil { + t.Fatalf("CompareWithBaseline: %v", err) + } + b, err := json.Marshal(result) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(b), "null") { + t.Errorf("MonitoringResult marshaled a null list: %s", b) + } + if !strings.Contains(string(b), `"new_findings":[]`) || !strings.Contains(string(b), `"fixed_findings":[]`) { + t.Errorf("expected empty JSON arrays, got %s", b) + } +} + +// TestBaselineDataForProjectsEveryField pins the projection save_baseline +// performs, including the two `.value` enum reads and the nested location. +func TestBaselineDataForProjectsEveryField(t *testing.T) { + f := makeFinding("fp-x", "Title", schemas.SeverityCritical, "CWE-79") + f.ID = "id-x" + f.Verdict = schemas.VerdictLikely + f.Location = schemas.Location{FilePath: "pkg/mod.go", StartLine: 42, EndLine: 44} + + got := BaselineDataFor(makeResult([]schemas.VerifiedFinding{f}, "sha")) + + if got.CommitSha != "sha" { + t.Errorf("CommitSha = %q, want %q", got.CommitSha, "sha") + } + if got.Timestamp != "2025-01-15T00:00:00+00:00" { + t.Errorf("Timestamp = %q", got.Timestamp) + } + want := BaselineFinding{ + ID: "id-x", Fingerprint: "fp-x", Title: "Title", Severity: "critical", + CweID: "CWE-79", Verdict: "likely", FilePath: "pkg/mod.go", StartLine: 42, + } + if len(got.Findings) != 1 || got.Findings[0] != want { + t.Errorf("Findings = %+v, want [%+v]", got.Findings, want) + } +} + +// TestSaveBaselineIntoMissingDirectoryIsAnError mirrors write_text, which does +// not create parent directories. +func TestSaveBaselineIntoMissingDirectoryIsAnError(t *testing.T) { + path := filepath.Join(t.TempDir(), "nope", "baseline.json") + if err := SaveBaseline(makeResult(nil, "x"), path); err == nil { + t.Fatal("expected an error writing into a missing directory") + } +} diff --git a/go/internal/monitoring/testdata/golden/baseline.json b/go/internal/monitoring/testdata/golden/baseline.json new file mode 100644 index 0000000..d9645c6 --- /dev/null +++ b/go/internal/monitoring/testdata/golden/baseline.json @@ -0,0 +1,16 @@ +{ + "commit_sha": "abc123", + "timestamp": "2025-01-15T00:00:00+00:00", + "findings": [ + { + "id": "id-1", + "fingerprint": "fp-1", + "title": "SQL injection in \"users\" & caf\u00e9 \u2014 \\path\ttab\nnewline\u007f \ud83d\ude00", + "severity": "high", + "cwe_id": "CWE-89", + "verdict": "confirmed", + "file_path": "app.py", + "start_line": 10 + } + ] +} \ No newline at end of file diff --git a/go/internal/monitoring/testdata/golden/baseline_empty.json b/go/internal/monitoring/testdata/golden/baseline_empty.json new file mode 100644 index 0000000..1626860 --- /dev/null +++ b/go/internal/monitoring/testdata/golden/baseline_empty.json @@ -0,0 +1,5 @@ +{ + "commit_sha": "abc123", + "timestamp": "2025-01-15T00:00:00+00:00", + "findings": [] +} \ No newline at end of file diff --git a/go/internal/node/audit.go b/go/internal/node/audit.go new file mode 100644 index 0000000..a03a0db --- /dev/null +++ b/go/internal/node/audit.go @@ -0,0 +1,631 @@ +package node + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "sort" + "strings" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + + "github.com/Agent-Field/sec-af/go/internal/afx" + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/orch" + "github.com/Agent-Field/sec-af/go/internal/phases" + "github.com/Agent-Field/sec-af/go/internal/reasoners" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// audit.go ports the `audit(...)` reasoner body (src/sec_af/app.py:123). + +// nowMonotonic is `time.monotonic()`. It is a variable so a test can pin the +// two duration fields the pipeline stamps; production never reassigns it. +// +// Go's time.Now carries a monotonic reading that Sub prefers over the wall +// clock, so a DIFFERENCE has exactly Python's time.monotonic() semantics. +var nowMonotonic = time.Now + +// ErrBadInput marks a ValueError-class failure raised INSIDE audit()'s +// try/except, i.e. one Python maps to +// +// except ValueError as exc: raise HTTPException(400, detail={"error": str(exc)}) +// +// Wrap an error with %w against this sentinel to have IsBadInput classify it. +var ErrBadInput = errors.New("bad input") + +// IsBadInput reports whether err is one of the ValueError-class failures the +// audit pipeline can raise from inside the mapped region. +// +// THREE families reach it, all of them ValueError subclasses in Python: +// +// 1. `ValueError(f"Unknown checkpoint phase: {phase}")` from +// orchestrator.run_from_checkpoint -> *orch.UnknownCheckpointPhaseError. +// 2. `Model.model_validate(payload)` / `Model(**payload)` anywhere inside the +// try — app.py:181 ReconResult, :191 HuntResult, :203 and :217 +// VerifiedFinding, plus orchestrator.py's `_read_checkpoint`. pydantic's +// ValidationError SUBCLASSES ValueError (VERIFIED on the pinned +// interpreter: `ValidationError.__mro__` is +// `(ValidationError, ValueError, Exception, BaseException, object)`), so +// EVERY schema failure inside the try takes the 400 branch, not the 500 one +// -> *phases.ValidationError. +// 3. `json.loads` on a corrupt checkpoint file: `json.JSONDecodeError` is also +// a ValueError -> encoding/json's *SyntaxError / *UnmarshalTypeError, which +// are what orch.ReadCheckpoint / ReadCheckpointList and afx.Bind return for +// the same input. +// +// TWO OTHER ValueErrors exist in the audit flow and are DELIBERATELY not routed +// here, because Python raises them OUTSIDE the try: +// +// orchestrator = AuditOrchestrator(app=app, input=audit_input) # AuditConfig.from_input -> ValueError on a bad depth +// repo_path = _resolve_repo(repo_url) # ValueError("git clone failed: ...") +// orchestrator.repo_path = ... +// try: +// ... # <- only failures in here reach the 400 mapping +// +// FastAPI turns an uncaught exception into a generic 500, so a bad `depth` and +// a failed clone are 500s in Python, not 400s. DESIGN.md §0.2 ("reproduce, do +// not improve") says to keep it that way; auditHandler therefore maps those two +// call sites to 500 explicitly and says so at each site. +// +// The distinction is observable twice over: the status code a client branches +// on, and the fact that the 400 branch emits NO `app.note` while the 500 branch +// emits `Note("Audit pipeline failed: ...", "audit", "error")`. +func IsBadInput(err error) bool { + var unknownPhase *orch.UnknownCheckpointPhaseError + if errors.As(err, &unknownPhase) { + return true + } + var validation *phases.ValidationError + if errors.As(err, &validation) { + return true + } + // json.JSONDecodeError and pydantic's coercion failures are both ValueError + // subclasses; encoding/json reports the same two conditions as these types. + var syntaxErr *json.SyntaxError + if errors.As(err, &syntaxErr) { + return true + } + var typeErr *json.UnmarshalTypeError + if errors.As(err, &typeErr) { + return true + } + return errors.Is(err, ErrBadInput) +} + +// AuditRequest transcribes the `audit(...)` signature (app.py:124-144) — all +// twenty parameters, with their exact names, types and defaults. +// +// Nil pointers stand for Python's `None`; nil slices stand for the four +// `list[str] | None = None` parameters, whose `or` fallbacks live in +// ToAuditInput. +type AuditRequest struct { + RepoURL string `json:"repo_url"` + Depth string `json:"depth"` + Branch string `json:"branch"` + CommitSha *string `json:"commit_sha"` + BaseCommitSha *string `json:"base_commit_sha"` + SeverityThreshold string `json:"severity_threshold"` + ScanTypes []string `json:"scan_types"` + OutputFormats []string `json:"output_formats"` + ComplianceFrameworks []string `json:"compliance_frameworks"` + MaxCostUsd *float64 `json:"max_cost_usd"` + MaxProvers *int `json:"max_provers"` + MaxDurationSeconds *int `json:"max_duration_seconds"` + IncludePaths []string `json:"include_paths"` + ExcludePaths []string `json:"exclude_paths"` + IsPr bool `json:"is_pr"` + PrID *string `json:"pr_id"` + PostPrComments bool `json:"post_pr_comments"` + FailOnFindings bool `json:"fail_on_findings"` + // EnableDast is accepted and then DISCARDED — see ToAuditInput. + EnableDast bool `json:"enable_dast"` + ResumeFromCheckpoint *string `json:"resume_from_checkpoint"` +} + +// NewAuditRequest returns the three non-empty keyword defaults of audit(): +// depth="standard", branch="main", severity_threshold="low". Every other +// parameter's default is None / [] / False, which is the Go zero value. +func NewAuditRequest() AuditRequest { + return AuditRequest{ + Depth: "standard", + Branch: "main", + SeverityThreshold: "low", + } +} + +// UnmarshalJSON seeds audit()'s keyword defaults before decoding. +func (a *AuditRequest) UnmarshalJSON(b []byte) error { + *a = NewAuditRequest() + type alias AuditRequest + return json.Unmarshal(b, (*alias)(a)) +} + +// ToAuditInput ports the `AuditInput(...)` construction at app.py:146. +// +// Python parity, three points: +// +// - the four list fallbacks are `x or [default]`, i.e. PYTHON TRUTHINESS, not +// `is None`. An EXPLICIT EMPTY LIST therefore also falls back to the +// default: `scan_types=[]` yields ["sast","sca","secrets","config"], and +// `exclude_paths=[]` yields the four-entry default. There is no way to ask +// for "no exclusions" through this reasoner. Reproduced. +// - `include_paths` has NO fallback: it is forwarded as-is, so None stays +// None (scan everything) and [] stays [] (an empty include filter). +// - `enable_dast=enable_dast` is passed to a model that has NO `enable_dast` +// FIELD — AuditInput declares `dast_enabled`. pydantic's default +// `extra="ignore"` DROPS it silently, so `dast_enabled` is False for every +// request no matter what the caller sends. VERIFIED on the repo's own +// interpreter (`AuditInput(repo_url="x", enable_dast=True).dast_enabled` is +// False, and the instance has no `enable_dast` attribute). This is a live +// Python bug — the reasoner's `enable_dast` parameter is inert — and it is +// reproduced rather than fixed: DastEnabled is left at the pydantic default. +// +// The five fields audit() does not pass at all (dast_enabled, repo_urls, +// monitoring_mode, baseline_path, custom_policies) keep their pydantic +// defaults, which is what starting from schemas.NewAuditInput() gives. +func (a AuditRequest) ToAuditInput() schemas.AuditInput { + in := schemas.NewAuditInput() + in.RepoURL = a.RepoURL + in.Depth = a.Depth + in.Branch = a.Branch + in.CommitSha = a.CommitSha + in.BaseCommitSha = a.BaseCommitSha + in.SeverityThreshold = a.SeverityThreshold + in.ScanTypes = orDefault(a.ScanTypes, []string{"sast", "sca", "secrets", "config"}) + in.OutputFormats = orDefault(a.OutputFormats, []string{"json"}) + in.ComplianceFrameworks = orDefault(a.ComplianceFrameworks, []string{}) + in.MaxCostUsd = a.MaxCostUsd + in.MaxProvers = a.MaxProvers + in.MaxDurationSeconds = a.MaxDurationSeconds + in.IncludePaths = a.IncludePaths + in.ExcludePaths = orDefault(a.ExcludePaths, []string{"tests/", "vendor/", "node_modules/", ".git/"}) + in.IsPr = a.IsPr + in.PrID = a.PrID + in.PostPrComments = a.PostPrComments + in.FailOnFindings = a.FailOnFindings + // in.DastEnabled deliberately untouched — see the doc comment. + return in +} + +// bindAuditRequest is the `audit` reasoner's input layer, in Python's order: +// +// validated = self._validate_handler_input(body, input_fields) # SDK +// audit_input = AuditInput(**validated) # pydantic +// +// `audit` is registered on the Agent rather than on the reasoner router +// (register.go), so it does not get the router's automatic wrapper — but it is +// the same layer and the same 34-reasoner surface, so the SDK-level validation +// runs here explicitly. It is what turns `{"is_pr": "yes"}` into true and +// `{"repo_url": 5}` into "5" instead of a bind error, and what rejects an +// explicit null on a required parameter. +func bindAuditRequest(input map[string]any) (AuditRequest, error) { + validated, err := reasoners.ValidateHandlerInput(reasoners.NameAudit, input) + if err != nil { + return AuditRequest{}, err + } + return afx.Bind[AuditRequest](validated) +} + +// orDefault is Python's `value or default` for a list: an empty (or nil) slice +// is falsy and yields the default. +func orDefault(value, def []string) []string { + if len(value) == 0 { + return def + } + return value +} + +// auditHandler ports the `audit(...)` reasoner (app.py:123-231). +// +// The Python shape, and the shape reproduced here: +// +// audit_input = AuditInput(...) # bind +// orchestrator = AuditOrchestrator(app, input) # OUTSIDE the try +// repo_path = _resolve_repo(repo_url) # OUTSIDE the try +// orchestrator.repo_path = Path(repo_path) +// orchestrator.checkpoint_dir = repo_path/".sec-af" +// try: +// | +// except ValueError as exc: 400 {"error": str(exc)} +// except Exception as exc: note("Audit pipeline failed: ..."); 500 {"error": "audit execution failed: ..."} +// return result.model_dump() +// +// Construction happens BEFORE resolution, which matters: the orchestrator's +// __init__ resolves SEC_AF_REPO_PATH (or the cwd) and — in PR mode with a base +// commit — runs the git diff analysis against THAT path, not against the +// repository the audit is about. SetRepoPath then overwrites repo_path and +// checkpoint_dir without recomputing self.config, so AuditConfig.repo_path also +// keeps the constructor's value. All three quirks are inherited from Python. +func (n *Node) auditHandler(ctx context.Context, input map[string]any) (any, error) { + req, err := bindAuditRequest(input) + if err != nil { + var handlerInput *reasoners.HandlerInputError + if errors.As(err, &handlerInput) { + // Python's endpoint rejects the body BEFORE the handler runs, with + // JSONResponse(422, ...). See reasoners.ValidateHandlerInput. + return nil, reasoners.HandlerInputExecuteError(err) + } + // What is left is an afx.Bind failure, i.e. the pydantic half: + // `AuditInput(**validated)` at app.py:146, which is raised OUTSIDE + // audit()'s try and therefore reaches FastAPI as a generic 500. The Go + // port answers 400 — the closest node-level bad-input signal the SDK + // exposes for a malformed body — rather than reproducing a status that + // tells the caller nothing. PRE-EXISTING and unrelated to the input + // layer above, which now answers Python's own 422. + return nil, &agent.ExecuteError{StatusCode: http.StatusBadRequest, Message: err.Error()} + } + + auditInput := req.ToAuditInput() + + orchestrator, err := n.newOrchestrator(ctx, n.auditApp, auditInput) + if err != nil { + // Python parity: AuditConfig.from_input raises ValueError("'x' is not a + // valid DepthProfile") here, OUTSIDE the try, so FastAPI answers with a + // generic 500 — not the 400 the ValueError branch would give. Reproduced + // as a 500; the message is surfaced (Python hides it behind FastAPI's + // generic body) because a silent 500 is undebuggable and the status code + // — the part a client branches on — is identical. See IsBadInput. + return nil, &agent.ExecuteError{StatusCode: http.StatusInternalServerError, Message: err.Error()} + } + + repoPath, err := n.resolveRepo(ctx, req.RepoURL) + if err != nil { + // Python parity: _resolve_repo's ValueError("git clone failed: ...") is + // also raised outside the try -> generic 500. Same reasoning as above. + return nil, &agent.ExecuteError{StatusCode: http.StatusInternalServerError, Message: err.Error()} + } + orchestrator.SetRepoPath(repoPath) + + result, err := n.runAudit(ctx, orchestrator, req, repoPath) + if err != nil { + if IsBadInput(err) { + // `except ValueError` -> 400 with the RAW message, no note. + return nil, &agent.ExecuteError{StatusCode: http.StatusBadRequest, Message: err.Error()} + } + // `except Exception` -> stdout diagnostic, note, then 500 with the + // prefix, in that order (app.py:224-230). + // + // Python parity: `print(f"AUDIT ERROR: {exc}\n{tb}", flush=True)`. The + // first line is byte-identical; the traceback that follows it has no Go + // equivalent (an error value carries no stack, and the panic-style stack + // available here would be the handler's, not the failure's), so the line + // is emitted alone. The same operator-facing convention harnessx.Extract + // follows for `[agent] HARNESS ERROR: ...`. + fmt.Printf("AUDIT ERROR: %s\n", err) + n.auditApp.Note(ctx, "Audit pipeline failed: "+err.Error(), "audit", "error") + return nil, &agent.ExecuteError{ + StatusCode: http.StatusInternalServerError, + Message: "audit execution failed: " + err.Error(), + } + } + + // Python returns `result.model_dump()`; SecurityAuditResult marshals to the + // identical snake_case key set, so returning the struct yields the same JSON. + return result, nil +} + +// runAudit is the body of audit()'s `try:` block — everything whose failure the +// 400/500 mapping applies to. +func (n *Node) runAudit( + ctx context.Context, + orchestrator *orch.AuditOrchestrator, + req AuditRequest, + repoPath string, +) (schemas.SecurityAuditResult, error) { + // `if isinstance(resume_from_checkpoint, str) and resume_from_checkpoint.strip():` + // — a whitespace-only value is falsy and takes the full-pipeline branch. + if req.ResumeFromCheckpoint != nil && strings.TrimSpace(*req.ResumeFromCheckpoint) != "" { + return orchestrator.RunFromCheckpoint(ctx, *req.ResumeFromCheckpoint) + } + + app := n.auditApp + nodeID := n.callNodeID + + app.Note(ctx, "Starting SEC-AF audit pipeline", "audit", "start") + started := nowMonotonic() + + // --- recon_phase -------------------------------------------------------- + reconDict, err := callMap(ctx, app, nodeID, reasoners.NameReconPhase, map[string]any{ + "repo_path": repoPath, + "depth": req.Depth, + }) + if err != nil { + return schemas.SecurityAuditResult{}, err + } + recon, err := phases.BindReconResult(reconDict) + if err != nil { + return schemas.SecurityAuditResult{}, err + } + recon.ReconDurationSeconds = nowMonotonic().Sub(started).Seconds() + if err := orchestrator.WriteCheckpoint(orch.PhaseRecon, recon); err != nil { + return schemas.SecurityAuditResult{}, err + } + + // --- hunt_phase --------------------------------------------------------- + // Python parity: the kwarg is `recon_context=recon_dict` — the RAW payload + // map returned by recon_phase, NOT recon.model_dump(). The two differ: + // recon_duration_seconds was just stamped on the MODEL and is still 0.0 in + // the dict that goes over the wire. + huntDict, err := callMap(ctx, app, nodeID, reasoners.NameHuntPhase, map[string]any{ + "repo_path": repoPath, + "recon_context": reconDict, + "depth": req.Depth, + }) + if err != nil { + return schemas.SecurityAuditResult{}, err + } + hunt, err := phases.BindHuntResult(huntDict) + if err != nil { + return schemas.SecurityAuditResult{}, err + } + // Python: `time.monotonic() - started - recon.recon_duration_seconds`, i.e. + // the hunt's own share of the elapsed time. + hunt.HuntDurationSeconds = nowMonotonic().Sub(started).Seconds() - recon.ReconDurationSeconds + if err := orchestrator.WriteCheckpoint(orch.PhaseHunt, hunt); err != nil { + return schemas.SecurityAuditResult{}, err + } + + // --- prove_phase -------------------------------------------------------- + // Python parity: here the kwarg IS `hunt.model_dump()` (the model, with the + // duration stamped), unlike recon_context above. + huntDump, err := afx.ToMap(hunt) + if err != nil { + return schemas.SecurityAuditResult{}, err + } + proveDict, err := callMap(ctx, app, nodeID, reasoners.NameProvePhase, map[string]any{ + "repo_path": repoPath, + "hunt_result": huntDump, + "depth": req.Depth, + "max_provers": req.MaxProvers, + }) + if err != nil { + return schemas.SecurityAuditResult{}, err + } + + // Python: `prove_dict["verified"]` — a SUBSCRIPT, so a payload without the + // key raises KeyError (an Exception, not a ValueError => 500). + verifiedRaw, ok := proveDict["verified"] + if !ok { + return schemas.SecurityAuditResult{}, &missingKeyError{Key: "verified", Source: reasoners.NameProvePhase} + } + verified, err := bindVerifiedList(verifiedRaw) + if err != nil { + return schemas.SecurityAuditResult{}, err + } + + orchestrator.FindingsNotVerified = mapInt(proveDict, "not_verified", 0) + // Python: `prove_dict.get("drop_summary", {"demoted_total": 0, "by_reason": {}, "findings": []})` + // (app.py:205-208) — a `.get`, so the default fires ONLY when the key is + // ABSENT. A key that is PRESENT with an odd value (a JSON null, a string, a + // list, a number) is threaded through verbatim and surfaces in the audit + // result's `metadata.prove_drop_summary`, which is `dict[str, object]` and + // accepts anything. Testing the VALUE's type here instead — as an earlier + // draft did — replaced a caller-visible `null` with the default object. + // + // afx.WireNumbers restores the int-vs-float split CPython's json.loads + // makes: the summary is stored UNTYPED and re-serialised into + // `metadata["prove_drop_summary"]`, where a float64 2 would print "2.0" + // against Python's "2". + if summary, present := proveDict["drop_summary"]; present { + orchestrator.ProveDropSummary = afx.WireNumbers(summary) + } else { + orchestrator.ProveDropSummary = orch.NewDropSummary() + } + + if err := orchestrator.WriteCheckpoint(orch.PhaseProve, verified); err != nil { + return schemas.SecurityAuditResult{}, err + } + + // --- remediation_phase -------------------------------------------------- + verifiedDumps := make([]any, 0, len(verified)) + for i := range verified { + dump, dumpErr := afx.ToMap(verified[i]) + if dumpErr != nil { + return schemas.SecurityAuditResult{}, dumpErr + } + verifiedDumps = append(verifiedDumps, dump) + } + remediationDict, err := callMap(ctx, app, nodeID, reasoners.NameRemediationPhase, map[string]any{ + "repo_path": repoPath, + "verified_findings": verifiedDumps, + }) + if err != nil { + return schemas.SecurityAuditResult{}, err + } + remediatedRaw, ok := remediationDict["verified"] + if !ok { + return schemas.SecurityAuditResult{}, &missingKeyError{Key: "verified", Source: reasoners.NameRemediationPhase} + } + verified, err = bindVerifiedList(remediatedRaw) + if err != nil { + return schemas.SecurityAuditResult{}, err + } + + // Python: total_selected + len(hunt.strategies_run) + 3. The "+3" counts the + // three phase reasoners that are not per-finding fan-outs (recon, hunt, + // prove); remediation is not counted, and neither is `audit` itself. + orchestrator.SetAgentInvocations(mapInt(proveDict, "total_selected", 0) + len(hunt.StrategiesRun) + 3) + + result, err := orchestrator.GenerateOutput(ctx, recon, hunt, verified) + if err != nil { + return schemas.SecurityAuditResult{}, err + } + + app.Note(ctx, "SEC-AF audit complete", "audit", "complete") + return result, nil +} + +// callMap is app.py's +// +// raw = await app.call(f"{NODE_ID}.{name}", **kwargs) +// payload = _as_dict(_unwrap(raw, name), name) +// +// app.py declares its own byte-identical copies of _unwrap/_as_dict alongside +// reasoners/phases.py's; afx owns the single Go implementation. +func callMap(ctx context.Context, app appx.Caller, nodeID, name string, input map[string]any) (map[string]any, error) { + raw, err := app.Call(ctx, nodeID+"."+name, input) + if err != nil { + return nil, err + } + payload, err := afx.Unwrap(raw, name) + if err != nil { + return nil, err + } + return afx.AsMap(payload, name) +} + +// bindVerifiedList is `[VerifiedFinding.model_validate(v) for v in payload]` +// (app.py:201 and :213). +// +// The comprehension has TWO distinct failure modes, and Python maps them to +// DIFFERENT audit responses, so the port keeps them apart: +// +// - the payload is not ITERABLE -> TypeError, an Exception but not a +// ValueError, so audit()'s `except Exception` answers 500. str(exc) is +// `'int' object is not iterable` (VERIFIED on the pinned interpreter for +// int/float/bool/NoneType), which notIterableError reproduces. +// - the payload iterates but an ELEMENT is not a mapping -> +// `VerifiedFinding.model_validate(5)` raises a pydantic ValidationError, +// which SUBCLASSES ValueError, so audit() answers 400 with +// `Input should be a valid dictionary or instance of VerifiedFinding` +// (VERIFIED: one `model_type` error, `isinstance(exc, ValueError)` True). +// A *phases.ValidationError is what IsBadInput routes to that branch. +// +// Iterability follows Python, not Go: a STRING iterates its characters and a +// DICT iterates its keys, so both reach the element branch (and an empty dict +// or empty string yields an empty list, not an error) — only the scalars and +// None are "not iterable". +func bindVerifiedList(payload any) ([]schemas.VerifiedFinding, error) { + items, ok := pyIterate(payload) + if !ok { + return nil, ¬IterableError{Got: afx.PyTypeName(payload)} + } + out := make([]schemas.VerifiedFinding, 0, len(items)) + for _, item := range items { + row, isMap := item.(map[string]any) + if !isMap { + return nil, &phases.ValidationError{ + Model: "VerifiedFinding", + Errors: []string{"Input should be a valid dictionary or instance of VerifiedFinding"}, + } + } + finding, err := phases.BindVerifiedFinding(row) + if err != nil { + return nil, err + } + out = append(out, finding) + } + return out, nil +} + +// pyIterate is `list(x)` for the JSON value kinds a `.call` payload can hold: +// a list yields its elements, a string its CHARACTERS, a dict its KEYS, and +// everything else (numbers, booleans, None) is not iterable. +func pyIterate(payload any) ([]any, bool) { + switch v := payload.(type) { + case []any: + return v, true + case string: + out := make([]any, 0, len(v)) + for _, r := range v { + out = append(out, string(r)) + } + return out, true + case map[string]any: + // Python iterates a dict in INSERTION order, which a decoded Go map + // does not carry; sorting keeps the walk deterministic. The order is + // unobservable here anyway — every key is a string, so whichever comes + // first fails the element branch identically. + keys := make([]string, 0, len(v)) + for key := range v { + keys = append(keys, key) + } + sort.Strings(keys) + out := make([]any, 0, len(keys)) + for _, key := range keys { + out = append(out, key) + } + return out, true + } + return nil, false +} + +// mapInt is `payload.get(key, def)` coerced to an int. +// +// A JSON number arrives as float64 through the control plane and as an int when +// a Go caller built the map in process, so both are accepted; anything else +// (including an absent key) yields def. Python would happily store a float in +// findings_not_verified / agent_invocations; the Go fields are typed int, and +// every producer of these keys emits an integer. +func mapInt(payload map[string]any, key string, def int) int { + switch v := payload[key].(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + case float32: + return int(v) + } + return def +} + +// missingKeyError stands in for Python's KeyError on `prove_dict["verified"]` +// (app.py:201) and `remediation_dict["verified"]` (app.py:213). +// +// The TEXT is `str(exc)`, not `repr(exc)`: app.py:229-230 interpolates the +// exception into `f"Audit pipeline failed: {exc}"` and +// `f"audit execution failed: {exc}"`, and `str(KeyError('verified'))` is +// `'verified'` — the repr of the KEY, with quotes and with no class-name +// prefix (VERIFIED on the pinned interpreter). Source is kept as a field +// because it is useful in tests and logs, but it must not appear in Error(). +// Same rule as prove.ErrChainTagsNotASet and the phases select_strategy gate. +type missingKeyError struct { + Key string + Source string +} + +func (e *missingKeyError) Error() string { + return "'" + e.Key + "'" +} + +// notIterableError stands in for the TypeError a non-iterable `verified` +// produces. `str(exc)` is `'int' object is not iterable` — again no class-name +// prefix (VERIFIED for int, float, bool and NoneType). +// +// Documented residual, inherited from afx.PyTypeName: Go's encoding/json +// decodes every JSON number to float64, so an integral payload says "float" +// where CPython's json.loads would have said "int". +type notIterableError struct{ Got string } + +func (e *notIterableError) Error() string { + return "'" + e.Got + "' object is not iterable" +} + +// The `audit` reasoner's declared input schema is NOT written out here. It +// comes from the same capture of the live Python node that the router +// reasoners' schemas come from — reasoners.InputSchema(reasoners.NameAudit), +// see internal/reasoners/input_schemas.go — and register.go attaches it. +// +// A hand-written transcription of the app.py signature used to live at this +// spot. It was dropped because it was materially RICHER than what Python +// actually publishes, and therefore wrong for a port whose contract is "same +// discovery payload": +// +// - it typed the nullable parameters by their base type +// (`commit_sha: {"type":"string"}`, `scan_types: {"type":"array",...}`), +// while Python reports `{"type":"object"}` for every `X | None` — its +// Union branch never fires for a PEP 604 union; +// - it carried `default` and `description` keywords, which +// `_types_to_json_schema` does not emit at all; +// - it set `additionalProperties: true` at the top level, which Python only +// emits for `dict[str, Any]`-typed PROPERTIES, never for the schema root. +// +// The Go-side defaults those keywords documented are still enforced — by +// AuditRequest.UnmarshalJSON and ToAuditInput, which are what actually bind the +// request — so nothing is lost but the divergent advertisement. diff --git a/go/internal/node/audit_test.go b/go/internal/node/audit_test.go new file mode 100644 index 0000000..377264e --- /dev/null +++ b/go/internal/node/audit_test.go @@ -0,0 +1,1087 @@ +package node + +// Tests for the `audit` reasoner. +// +// Validation contract (behaviour, derived from src/sec_af/app.py:123): +// +// - the 20 parameters bind with their exact defaults, and the four +// `x or [default]` list fallbacks fire for an EXPLICIT EMPTY LIST as well as +// for an absent key; +// - `enable_dast` never reaches AuditInput (pydantic drops the unknown key), +// so dast_enabled stays False; +// - the pipeline makes exactly four `.call`s, in order, with exactly the +// documented kwargs: recon_phase{repo_path,depth}, +// hunt_phase{repo_path,recon_context,depth}, +// prove_phase{repo_path,hunt_result,depth,max_provers}, +// remediation_phase{repo_path,verified_findings}; +// - hunt_phase receives the RAW recon payload (not the model dump), while +// prove_phase receives hunt.model_dump(); +// - the three checkpoints are written under /.sec-af; +// - agent_invocations = total_selected + len(strategies_run) + 3, and +// findings_not_verified / prove_drop_summary are copied from prove_phase; +// - a non-blank resume_from_checkpoint skips the pipeline entirely; +// - error mapping: every ValueError raised INSIDE audit()'s try -> 400 with +// the raw message and NO note. That is the unknown checkpoint phase AND +// every `model_validate` failure (pydantic's ValidationError subclasses +// ValueError) AND a corrupt checkpoint file (json.JSONDecodeError likewise). +// Any other pipeline failure -> note("Audit pipeline failed: ...", +// ["audit","error"]) then 500 with the "audit execution failed: " prefix, +// preceded by the stdout diagnostic `print(f"AUDIT ERROR: {exc}\n{tb}")`; +// - the two failures Python raises OUTSIDE its try (a bad depth in +// AuditConfig.from_input, a failed clone) surface as 500s with no note and +// no prefix. + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/orch" + "github.com/Agent-Field/sec-af/go/internal/phases" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// --------------------------------------------------------------------------- +// input binding +// --------------------------------------------------------------------------- + +func TestAuditRequestDefaults(t *testing.T) { + var req AuditRequest + if err := json.Unmarshal([]byte(`{"repo_url":"https://example.test/o/r"}`), &req); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if req.Depth != "standard" { + t.Errorf("depth = %q, want standard", req.Depth) + } + if req.Branch != "main" { + t.Errorf("branch = %q, want main", req.Branch) + } + if req.SeverityThreshold != "low" { + t.Errorf("severity_threshold = %q, want low", req.SeverityThreshold) + } + if req.IsPr || req.PostPrComments || req.FailOnFindings || req.EnableDast { + t.Error("the four booleans must default to false") + } + if req.CommitSha != nil || req.MaxProvers != nil || req.ResumeFromCheckpoint != nil { + t.Error("the optional parameters must default to nil (Python None)") + } +} + +func TestToAuditInputListFallbacks(t *testing.T) { + t.Run("absent lists take the audit() defaults", func(t *testing.T) { + in := NewAuditRequest().ToAuditInput() + assertStrings(t, "scan_types", in.ScanTypes, []string{"sast", "sca", "secrets", "config"}) + assertStrings(t, "output_formats", in.OutputFormats, []string{"json"}) + assertStrings(t, "compliance_frameworks", in.ComplianceFrameworks, []string{}) + assertStrings(t, "exclude_paths", in.ExcludePaths, []string{"tests/", "vendor/", "node_modules/", ".git/"}) + if in.IncludePaths != nil { + t.Errorf("include_paths = %v, want nil (no fallback in Python)", in.IncludePaths) + } + }) + + t.Run("an EXPLICIT empty list also falls back (Python truthiness)", func(t *testing.T) { + req := NewAuditRequest() + req.ScanTypes = []string{} + req.ExcludePaths = []string{} + in := req.ToAuditInput() + assertStrings(t, "scan_types", in.ScanTypes, []string{"sast", "sca", "secrets", "config"}) + assertStrings(t, "exclude_paths", in.ExcludePaths, []string{"tests/", "vendor/", "node_modules/", ".git/"}) + }) + + t.Run("a non-empty list is passed through", func(t *testing.T) { + req := NewAuditRequest() + req.ScanTypes = []string{"sast"} + req.IncludePaths = []string{"src/"} + in := req.ToAuditInput() + assertStrings(t, "scan_types", in.ScanTypes, []string{"sast"}) + assertStrings(t, "include_paths", in.IncludePaths, []string{"src/"}) + }) +} + +// TestToAuditInputDropsEnableDast pins the Python bug: audit() passes +// enable_dast= to a model whose field is dast_enabled, and pydantic's default +// extra="ignore" throws it away. +func TestToAuditInputDropsEnableDast(t *testing.T) { + req := NewAuditRequest() + req.EnableDast = true + if in := req.ToAuditInput(); in.DastEnabled { + t.Error("dast_enabled = true, want false: audit()'s enable_dast never reaches AuditInput") + } +} + +func TestToAuditInputKeepsUnpassedPydanticDefaults(t *testing.T) { + in := NewAuditRequest().ToAuditInput() + if in.RepoUrls == nil { + t.Error("repo_urls = nil, want [] (default_factory=list)") + } + if in.CustomPolicies == nil { + t.Error("custom_policies = nil, want [] (default_factory=list)") + } + if in.MonitoringMode { + t.Error("monitoring_mode must default to false") + } + if in.BaselinePath != nil { + t.Error("baseline_path must default to nil") + } +} + +// --------------------------------------------------------------------------- +// the pipeline +// --------------------------------------------------------------------------- + +// newAuditNode builds a Node with the audit seams stubbed: a recording appx.Fake +// for the `.call`s and notes, a real orchestrator rooted at repo, and a +// resolveRepo that returns repo without touching git. +func newAuditNode(t *testing.T, repo string, fake *appx.Fake) *Node { + t.Helper() + clearEnv(t) + t.Setenv("NODE_ID", "sec-af") + t.Setenv("SEC_AF_REPO_PATH", repo) + + return &Node{ + NodeID: "sec-af", + callNodeID: "sec-af", + auditApp: fake, + newOrchestrator: orch.NewWithContext, + resolveRepo: func(context.Context, string) (string, error) { + return repo, nil + }, + tags: map[string][]string{}, + } +} + +// phasePayloads answers the four phase calls with the minimum schema-valid +// payloads. reconExtra/proveExtra let a test add keys. +func phasePayloads(strategies []string, totalSelected, notVerified int) func(context.Context, string, map[string]any) (map[string]any, error) { + return func(_ context.Context, target string, _ map[string]any) (map[string]any, error) { + switch { + case strings.HasSuffix(target, ".recon_phase"): + return map[string]any{ + "architecture": map[string]any{}, + "data_flows": map[string]any{}, + "dependencies": map[string]any{}, + "config": map[string]any{}, + "security_context": map[string]any{"auth_model": "jwt", "auth_details": "bearer"}, + "languages": []any{"python"}, + "lines_of_code": float64(120), + }, nil + case strings.HasSuffix(target, ".hunt_phase"): + run := make([]any, 0, len(strategies)) + for _, s := range strategies { + run = append(run, s) + } + return map[string]any{"strategies_run": run, "total_raw": float64(0)}, nil + case strings.HasSuffix(target, ".prove_phase"): + return map[string]any{ + "verified": []any{}, + "total_selected": float64(totalSelected), + "total_findings": float64(0), + "not_verified": float64(notVerified), + "drop_summary": map[string]any{ + "demoted_total": float64(2), + "by_reason": map[string]any{"verifier_error": float64(2)}, + "findings": []any{}, + }, + }, nil + case strings.HasSuffix(target, ".remediation_phase"): + return map[string]any{"verified": []any{}}, nil + } + return nil, errors.New("unexpected call target " + target) + } +} + +func TestAuditPipelineCallSequenceAndKwargs(t *testing.T) { + repo := t.TempDir() + fake := &appx.Fake{CallFn: phasePayloads([]string{"injection", "auth"}, 7, 4)} + n := newAuditNode(t, repo, fake) + + got, err := n.auditHandler(context.Background(), map[string]any{ + "repo_url": repo, + "depth": "thorough", + "max_provers": float64(9), + }) + if err != nil { + t.Fatalf("auditHandler: %v", err) + } + + // --- exact targets, in order --- + wantTargets := []string{ + "sec-af.recon_phase", + "sec-af.hunt_phase", + "sec-af.prove_phase", + "sec-af.remediation_phase", + } + if !reflect.DeepEqual(fake.CallTargets(), wantTargets) { + t.Fatalf("call targets = %v, want %v", fake.CallTargets(), wantTargets) + } + + // --- exact kwargs per call --- + assertKeys(t, "recon_phase", fake.Calls[0].Input, "repo_path", "depth") + assertKeys(t, "hunt_phase", fake.Calls[1].Input, "repo_path", "recon_context", "depth") + assertKeys(t, "prove_phase", fake.Calls[2].Input, "repo_path", "hunt_result", "depth", "max_provers") + assertKeys(t, "remediation_phase", fake.Calls[3].Input, "repo_path", "verified_findings") + + for i, call := range fake.Calls { + if call.Input["repo_path"] != repo { + t.Errorf("call %d repo_path = %v, want %q", i, call.Input["repo_path"], repo) + } + } + for _, i := range []int{0, 1, 2} { + if fake.Calls[i].Input["depth"] != "thorough" { + t.Errorf("call %d depth = %v, want thorough", i, fake.Calls[i].Input["depth"]) + } + } + if mp, ok := fake.Calls[2].Input["max_provers"].(*int); !ok || mp == nil || *mp != 9 { + t.Errorf("prove_phase max_provers = %#v, want a pointer to 9", fake.Calls[2].Input["max_provers"]) + } + + // --- hunt_phase gets the RAW recon payload, prove_phase the hunt MODEL --- + reconContext, ok := fake.Calls[1].Input["recon_context"].(map[string]any) + if !ok { + t.Fatalf("recon_context = %#v, want the raw payload map", fake.Calls[1].Input["recon_context"]) + } + if _, present := reconContext["recon_duration_seconds"]; present { + t.Error("recon_context carries recon_duration_seconds: the MODEL dump was forwarded, not the raw payload") + } + huntResult, ok := fake.Calls[2].Input["hunt_result"].(map[string]any) + if !ok { + t.Fatalf("hunt_result = %#v, want hunt.model_dump()", fake.Calls[2].Input["hunt_result"]) + } + for _, key := range []string{"findings", "chains", "total_raw", "strategies_run", "hunt_duration_seconds"} { + if _, present := huntResult[key]; !present { + t.Errorf("hunt_result is missing %q — it is not a full model_dump()", key) + } + } + + // --- checkpoints --- + for _, phase := range []string{"recon", "hunt", "prove"} { + path := filepath.Join(repo, ".sec-af", "checkpoint-"+phase+".json") + if _, statErr := os.Stat(path); statErr != nil { + t.Errorf("checkpoint %s not written: %v", phase, statErr) + } + } + + // --- result bookkeeping --- + result, ok := got.(schemas.SecurityAuditResult) + if !ok { + t.Fatalf("handler returned %T, want schemas.SecurityAuditResult", got) + } + // total_selected(7) + len(strategies_run)(2) + 3 + if result.AgentInvocations != 12 { + t.Errorf("agent_invocations = %d, want 12 (7 + 2 + 3)", result.AgentInvocations) + } + if result.Metadata["findings_not_verified"] != 4 { + t.Errorf("findings_not_verified = %v, want 4", result.Metadata["findings_not_verified"]) + } + summary, ok := result.Metadata["prove_drop_summary"].(map[string]any) + if !ok { + t.Fatalf("prove_drop_summary = %#v, want the payload's own map", result.Metadata["prove_drop_summary"]) + } + // json.Number, not float64: afx.WireNumbers restores the int-vs-float + // split CPython's json.loads makes, so the summary re-serialises into + // metadata as "2" (Python's int) rather than "2.0". + if summary["demoted_total"] != json.Number("2") { + t.Errorf("prove_drop_summary.demoted_total = %#v, want json.Number(\"2\")", summary["demoted_total"]) + } + + // --- the bracketing notes --- + msgs := fake.NoteMessages() + if len(msgs) < 2 || msgs[0] != "Starting SEC-AF audit pipeline" || msgs[len(msgs)-1] != "SEC-AF audit complete" { + t.Errorf("notes = %v, want the start/complete bracket", msgs) + } + if !reflect.DeepEqual(fake.Notes[0].Tags, []string{"audit", "start"}) { + t.Errorf("start note tags = %v, want [audit start]", fake.Notes[0].Tags) + } + last := fake.Notes[len(fake.Notes)-1] + if !reflect.DeepEqual(last.Tags, []string{"audit", "complete"}) { + t.Errorf("complete note tags = %v, want [audit complete]", last.Tags) + } +} + +// TestAuditDefaultsReachThePhases drives the handler with only repo_url and +// checks the defaults the phases observe. +func TestAuditDefaultsReachThePhases(t *testing.T) { + repo := t.TempDir() + fake := &appx.Fake{CallFn: phasePayloads(nil, 0, 0)} + n := newAuditNode(t, repo, fake) + + if _, err := n.auditHandler(context.Background(), map[string]any{"repo_url": repo}); err != nil { + t.Fatalf("auditHandler: %v", err) + } + if fake.Calls[0].Input["depth"] != "standard" { + t.Errorf("recon_phase depth = %v, want the audit() default standard", fake.Calls[0].Input["depth"]) + } + if mp, ok := fake.Calls[2].Input["max_provers"].(*int); !ok || mp != nil { + t.Errorf("prove_phase max_provers = %#v, want a nil *int (Python None)", fake.Calls[2].Input["max_provers"]) + } +} + +// TestAuditResumeSkipsThePipeline pins the resume branch: no `.call`, no +// "Starting SEC-AF audit pipeline" note. +func TestAuditResumeSkipsThePipeline(t *testing.T) { + repo := t.TempDir() + fake := &appx.Fake{CallFn: phasePayloads(nil, 0, 0)} + n := newAuditNode(t, repo, fake) + + _, err := n.auditHandler(context.Background(), map[string]any{ + "repo_url": repo, + "resume_from_checkpoint": "hunt", + }) + // The checkpoint files do not exist, so RunFromCheckpoint fails — which is + // the point: the pipeline was never entered. + if err == nil { + t.Fatal("want a failure reading the missing checkpoint") + } + if len(fake.Calls) != 0 { + t.Errorf("resume made %v calls, want none", fake.CallTargets()) + } + for _, msg := range fake.NoteMessages() { + if msg == "Starting SEC-AF audit pipeline" { + t.Error("resume must not emit the pipeline-start note") + } + } +} + +// TestAuditCheckpointsResumeFromTheirOwnOutput is the round trip the resume +// branch depends on: `_read_checkpoint(phase, schema)` is `schema(**data)`, so +// every checkpoint the pipeline writes must validate when it is read back. A +// finding shape the writer produces but the reader rejects would make +// `audit(resume_from_checkpoint="prove")` a 400 on a healthy run. +func TestAuditCheckpointsResumeFromTheirOwnOutput(t *testing.T) { + repo := t.TempDir() + verified := map[string]any{ + "id": "v1", "fingerprint": "fp1", "title": "SQLi", "description": "d", + "finding_type": "sast", "cwe_id": "CWE-89", "cwe_name": "SQL Injection", + "verdict": "confirmed", "evidence_level": float64(4), "rationale": "r", + "severity": "high", "exploitability_score": 0.9, + "location": map[string]any{"file_path": "a.go", "start_line": float64(1), "end_line": float64(2)}, + "sarif_rule_id": "sast/sql-injection", "sarif_security_severity": 0.9, + "proof": map[string]any{ + "exploit_hypothesis": "h", "verification_method": "static", "evidence_level": float64(4), + }, + "reproduction_steps": []any{map[string]any{"step": float64(1), "description": "d"}}, + "compliance": []any{map[string]any{ + "framework": "OWASP", "control_id": "A03", "control_name": "Injection", + }}, + } + fake := &appx.Fake{CallFn: func(_ context.Context, target string, in map[string]any) (map[string]any, error) { + switch { + case strings.HasSuffix(target, ".prove_phase"): + return map[string]any{ + "verified": []any{verified}, "total_selected": float64(1), + "total_findings": float64(1), "not_verified": float64(0), + }, nil + case strings.HasSuffix(target, ".remediation_phase"): + return map[string]any{"verified": []any{verified}}, nil + } + return phasePayloads([]string{"injection"}, 1, 0)(context.Background(), target, in) + }} + n := newAuditNode(t, repo, fake) + + if _, err := n.auditHandler(context.Background(), map[string]any{"repo_url": repo}); err != nil { + t.Fatalf("auditHandler: %v", err) + } + + for _, phase := range []string{"recon", "hunt", "prove"} { + resumeFake := &appx.Fake{CallFn: phasePayloads([]string{"injection"}, 1, 0)} + resumeNode := newAuditNode(t, repo, resumeFake) + if _, err := resumeNode.auditHandler(context.Background(), map[string]any{ + "repo_url": repo, + "resume_from_checkpoint": phase, + }); err != nil { + t.Errorf("resume from %q failed on checkpoints this pipeline wrote: %v", phase, err) + } + } +} + +// TestAuditBlankResumeRunsThePipeline: `resume_from_checkpoint.strip()` is +// falsy for a whitespace-only value, so the full pipeline runs. +func TestAuditBlankResumeRunsThePipeline(t *testing.T) { + repo := t.TempDir() + fake := &appx.Fake{CallFn: phasePayloads(nil, 0, 0)} + n := newAuditNode(t, repo, fake) + + if _, err := n.auditHandler(context.Background(), map[string]any{ + "repo_url": repo, + "resume_from_checkpoint": " ", + }); err != nil { + t.Fatalf("auditHandler: %v", err) + } + if len(fake.Calls) != 4 { + t.Errorf("calls = %v, want the four phases", fake.CallTargets()) + } +} + +// --------------------------------------------------------------------------- +// error mapping +// --------------------------------------------------------------------------- + +func TestAuditErrorMapping(t *testing.T) { + t.Run("unknown checkpoint phase -> 400, raw message, no note", func(t *testing.T) { + repo := t.TempDir() + fake := &appx.Fake{} + n := newAuditNode(t, repo, fake) + + _, err := n.auditHandler(context.Background(), map[string]any{ + "repo_url": repo, + "resume_from_checkpoint": "nonsense", + }) + exec := asExecuteError(t, err) + if exec.StatusCode != 400 { + t.Errorf("status = %d, want 400", exec.StatusCode) + } + if exec.Message != "Unknown checkpoint phase: nonsense" { + t.Errorf("message = %q, want the raw ValueError text", exec.Message) + } + for _, msg := range fake.NoteMessages() { + if strings.HasPrefix(msg, "Audit pipeline failed") { + t.Error("the 400 branch must not emit the failure note") + } + } + }) + + t.Run("pipeline failure -> stdout diagnostic + note + 500 with the prefix", func(t *testing.T) { + repo := t.TempDir() + fake := &appx.Fake{CallFn: func(context.Context, string, map[string]any) (map[string]any, error) { + return nil, errors.New("recon_phase exploded") + }} + n := newAuditNode(t, repo, fake) + + var err error + stdout := captureStdout(t, func() { + _, err = n.auditHandler(context.Background(), map[string]any{"repo_url": repo}) + }) + // Python parity: app.py:228 prints `AUDIT ERROR: {exc}` before the note. + // The traceback that follows it in Python has no Go equivalent, so only + // the first line is asserted — and it must be there. + if !strings.HasPrefix(stdout, "AUDIT ERROR: ") { + t.Errorf("stdout = %q, want the AUDIT ERROR diagnostic", stdout) + } + if !strings.Contains(stdout, "recon_phase exploded") { + t.Errorf("stdout = %q, want the failure message", stdout) + } + exec := asExecuteError(t, err) + if exec.StatusCode != 500 { + t.Errorf("status = %d, want 500", exec.StatusCode) + } + if !strings.HasPrefix(exec.Message, "audit execution failed: ") { + t.Errorf("message = %q, want the Python prefix", exec.Message) + } + + var failure *appx.NoteCall + for i := range fake.Notes { + if strings.HasPrefix(fake.Notes[i].Message, "Audit pipeline failed: ") { + failure = &fake.Notes[i] + } + } + if failure == nil { + t.Fatalf("no \"Audit pipeline failed\" note; got %v", fake.NoteMessages()) + } + if !reflect.DeepEqual(failure.Tags, []string{"audit", "error"}) { + t.Errorf("failure note tags = %v, want [audit error]", failure.Tags) + } + }) + + // pydantic's ValidationError SUBCLASSES ValueError, so every + // `Model.model_validate(payload)` inside audit()'s try takes the 400 + // branch — raw message, NO note. VERIFIED on the pinned interpreter: + // `ValidationError.__mro__` = (ValidationError, ValueError, Exception, + // BaseException, object). + t.Run("a schema-invalid recon_phase payload -> 400, raw message, no note", func(t *testing.T) { + repo := t.TempDir() + fake := &appx.Fake{CallFn: func(_ context.Context, target string, _ map[string]any) (map[string]any, error) { + if strings.HasSuffix(target, ".recon_phase") { + // app.py:181 `ReconResult.model_validate(recon_dict)` raises. + return map[string]any{"languages": []any{"go"}}, nil + } + return map[string]any{}, nil + }} + n := newAuditNode(t, repo, fake) + + _, err := n.auditHandler(context.Background(), map[string]any{"repo_url": repo}) + exec := asExecuteError(t, err) + if exec.StatusCode != 400 { + t.Errorf("status = %d, want 400", exec.StatusCode) + } + if strings.HasPrefix(exec.Message, "audit execution failed: ") { + t.Errorf("message = %q, want the RAW ValueError text (the 500 prefix is the except-Exception branch)", exec.Message) + } + if !strings.Contains(exec.Message, "ReconResult") { + t.Errorf("message = %q, want the model name", exec.Message) + } + for _, msg := range fake.NoteMessages() { + if strings.HasPrefix(msg, "Audit pipeline failed") { + t.Error("the 400 branch must not emit the failure note") + } + } + }) + + t.Run("a schema-invalid hunt_phase payload -> 400, no note", func(t *testing.T) { + repo := t.TempDir() + fake := &appx.Fake{CallFn: func(_ context.Context, target string, in map[string]any) (map[string]any, error) { + if strings.HasSuffix(target, ".hunt_phase") { + // app.py:191 `HuntResult.model_validate(...)` raises: the + // element is not a RawFinding. + return map[string]any{"findings": []any{map[string]any{"title": "malformed"}}}, nil + } + return phasePayloads(nil, 0, 0)(context.Background(), target, in) + }} + n := newAuditNode(t, repo, fake) + + _, err := n.auditHandler(context.Background(), map[string]any{"repo_url": repo}) + exec := asExecuteError(t, err) + if exec.StatusCode != 400 { + t.Errorf("status = %d, want 400", exec.StatusCode) + } + for _, msg := range fake.NoteMessages() { + if strings.HasPrefix(msg, "Audit pipeline failed") { + t.Error("the 400 branch must not emit the failure note") + } + } + }) + + // orchestrator.py's `_read_checkpoint` does `json.loads(...)`, whose + // JSONDecodeError is also a ValueError -> 400. + t.Run("a corrupt checkpoint file -> 400, no note", func(t *testing.T) { + repo := t.TempDir() + if err := os.MkdirAll(filepath.Join(repo, ".sec-af"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".sec-af", "checkpoint-recon.json"), []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + fake := &appx.Fake{} + n := newAuditNode(t, repo, fake) + + _, err := n.auditHandler(context.Background(), map[string]any{ + "repo_url": repo, + "resume_from_checkpoint": "recon", + }) + exec := asExecuteError(t, err) + if exec.StatusCode != 400 { + t.Errorf("status = %d, want 400", exec.StatusCode) + } + for _, msg := range fake.NoteMessages() { + if strings.HasPrefix(msg, "Audit pipeline failed") { + t.Error("the 400 branch must not emit the failure note") + } + } + }) + + t.Run("a bad depth fails the constructor -> 500, no note", func(t *testing.T) { + // Python parity: AuditConfig.from_input's ValueError is raised BEFORE + // audit()'s try, so FastAPI answers 500, not 400. + repo := t.TempDir() + fake := &appx.Fake{} + n := newAuditNode(t, repo, fake) + + _, err := n.auditHandler(context.Background(), map[string]any{ + "repo_url": repo, + "depth": "sideways", + }) + exec := asExecuteError(t, err) + if exec.StatusCode != 500 { + t.Errorf("status = %d, want 500", exec.StatusCode) + } + if strings.HasPrefix(exec.Message, "audit execution failed: ") { + t.Errorf("message = %q, want the raw error (this path is not inside the try)", exec.Message) + } + if len(fake.Notes) != 0 { + t.Errorf("notes = %v, want none", fake.NoteMessages()) + } + }) + + t.Run("a clone failure -> 500, no note", func(t *testing.T) { + repo := t.TempDir() + fake := &appx.Fake{} + n := newAuditNode(t, repo, fake) + n.resolveRepo = func(context.Context, string) (string, error) { + return "", &CloneFailedError{Stderr: "repository not found"} + } + + _, err := n.auditHandler(context.Background(), map[string]any{"repo_url": "https://example.test/o/r"}) + exec := asExecuteError(t, err) + if exec.StatusCode != 500 { + t.Errorf("status = %d, want 500", exec.StatusCode) + } + if exec.Message != "git clone failed: repository not found" { + t.Errorf("message = %q, want the raw ValueError text", exec.Message) + } + if len(fake.Notes) != 0 { + t.Errorf("notes = %v, want none", fake.NoteMessages()) + } + }) +} + +func TestIsBadInput(t *testing.T) { + if !IsBadInput(&orch.UnknownCheckpointPhaseError{Phase: "x"}) { + t.Error("an unknown checkpoint phase must classify as bad input") + } + if !IsBadInput(errors.Join(ErrBadInput, errors.New("wrapped"))) { + t.Error("an error wrapping ErrBadInput must classify as bad input") + } + if IsBadInput(errors.New("boom")) { + t.Error("a plain error must not classify as bad input") + } + // Python parity: these two ARE ValueErrors, but they are raised outside + // audit()'s try, so they are not routed to the 400 branch. + if IsBadInput(&CloneFailedError{Stderr: "x"}) { + t.Error("a clone failure must not classify as bad input (raised outside the try)") + } + + // pydantic's ValidationError subclasses ValueError, so every schema + // failure inside the try is a 400. + if _, err := phases.BindReconResult(map[string]any{}); !IsBadInput(err) { + t.Errorf("a *phases.ValidationError must classify as bad input, got %v", err) + } + if _, err := phases.BindVerifiedFinding(map[string]any{"title": "malformed"}); !IsBadInput(err) { + t.Errorf("a VerifiedFinding validation failure must classify as bad input, got %v", err) + } + if _, err := bindVerifiedList([]any{map[string]any{"title": "malformed"}}); !IsBadInput(err) { + t.Errorf("bindVerifiedList must classify as bad input, got %v", err) + } + // json.JSONDecodeError is a ValueError too. + if err := json.Unmarshal([]byte("{not json"), &map[string]any{}); !IsBadInput(err) { + t.Errorf("a JSON syntax error must classify as bad input, got %v", err) + } + if err := json.Unmarshal([]byte(`{"total_raw":"x"}`), &schemas.HuntResult{}); !IsBadInput(err) { + t.Errorf("a JSON type error must classify as bad input, got %v", err) + } +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +func asExecuteError(t *testing.T, err error) *agent.ExecuteError { + t.Helper() + if err == nil { + t.Fatal("expected an error, got nil") + } + var exec *agent.ExecuteError + if !errors.As(err, &exec) { + t.Fatalf("error is not *agent.ExecuteError: %T (%v)", err, err) + } + return exec +} + +func assertKeys(t *testing.T, name string, got map[string]any, want ...string) { + t.Helper() + if len(got) != len(want) { + keys := make([]string, 0, len(got)) + for k := range got { + keys = append(keys, k) + } + t.Errorf("%s kwargs = %v, want exactly %v", name, keys, want) + return + } + for _, key := range want { + if _, ok := got[key]; !ok { + t.Errorf("%s kwargs missing %q", name, key) + } + } +} + +func assertStrings(t *testing.T, name string, got, want []string) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Errorf("%s = %v, want %v", name, got, want) + } +} + +// captureStdout runs fn with os.Stdout replaced by a pipe and returns what was +// written. The audit handler's diagnostic goes through fmt.Printf, which reads +// os.Stdout at call time, so swapping the variable is enough. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + + done := make(chan string, 1) + go func() { + b, _ := io.ReadAll(r) + done <- string(b) + }() + + fn() + + _ = w.Close() + os.Stdout = orig + out := <-done + _ = r.Close() + return out +} + +// phasePayloadsWith answers the four phase calls like phasePayloads, then +// applies overrides to the payload of the named phase (an override value of nil +// DELETES the key). +func phasePayloadsWith(phase string, overrides map[string]any) func(context.Context, string, map[string]any) (map[string]any, error) { + base := phasePayloads(nil, 0, 0) + return func(ctx context.Context, target string, input map[string]any) (map[string]any, error) { + out, err := base(ctx, target, input) + if err != nil || !strings.HasSuffix(target, "."+phase) { + return out, err + } + for key, value := range overrides { + if value == nil { + delete(out, key) + continue + } + out[key] = value + } + return out, nil + } +} + +// TestAuditVerifiedKeyErrorsAreStrExc pins the TEXT of the two `KeyError`s +// audit() can raise, which app.py:229-230 interpolates into BOTH the +// "Audit pipeline failed: {exc}" note and the "audit execution failed: {exc}" +// 500 body. +// +// Validation contract (behaviour, from src/sec_af/app.py:201 and :213 — +// `prove_dict["verified"]` and `remediation_dict["verified"]` are bare +// subscripts inside the try): +// +// - a payload with no "verified" key fails the audit; +// - the message is `str(KeyError('verified'))`, which the pinned interpreter +// renders as `'verified'` — the quoted key, with NO `KeyError: ` prefix, +// because app.py interpolates str(exc) and not repr(exc); +// - the failure is an Exception but not a ValueError, so it takes the 500 +// branch (note + prefix), not the 400 one. +func TestAuditVerifiedKeyErrorsAreStrExc(t *testing.T) { + for _, phase := range []string{"prove_phase", "remediation_phase"} { + t.Run(phase, func(t *testing.T) { + repo := t.TempDir() + fake := &appx.Fake{CallFn: phasePayloadsWith(phase, map[string]any{"verified": nil})} + n := newAuditNode(t, repo, fake) + + var err error + captureStdout(t, func() { + _, err = n.auditHandler(context.Background(), map[string]any{"repo_url": repo}) + }) + + exec := asExecuteError(t, err) + if exec.StatusCode != 500 { + t.Errorf("status = %d, want 500 (KeyError is not a ValueError)", exec.StatusCode) + } + if want := "audit execution failed: 'verified'"; exec.Message != want { + t.Errorf("500 body = %q, want %q", exec.Message, want) + } + var note string + for _, msg := range fake.NoteMessages() { + if strings.HasPrefix(msg, "Audit pipeline failed: ") { + note = msg + } + } + if want := "Audit pipeline failed: 'verified'"; note != want { + t.Errorf("note = %q, want %q", note, want) + } + }) + } +} + +// TestAuditVerifiedNotIterableIsStrExc pins the other half: `for v in 5` raises +// TypeError, whose str(exc) the pinned interpreter renders as +// `'int' object is not iterable` (and `'NoneType' object is not iterable`, +// `'float' object is not iterable`, `'bool' object is not iterable`) — again +// with no class-name prefix. +// +// Documented residual: Go's encoding/json decodes every JSON number to float64, +// so a numeric payload reports "float" where CPython's json.loads would have +// produced an int and reported "int". That divergence is afx.PyTypeName's, and +// is documented there. +func TestAuditVerifiedNotIterableIsStrExc(t *testing.T) { + for _, tc := range []struct { + name string + payload any + want string + }{ + {"null", nil, "'NoneType' object is not iterable"}, + {"number", float64(5), "'float' object is not iterable"}, + {"bool", true, "'bool' object is not iterable"}, + } { + t.Run(tc.name, func(t *testing.T) { + repo := t.TempDir() + // nil would DELETE the key, which is the KeyError case; wrap it so + // the key stays present with a null value. + answers := phasePayloads(nil, 0, 0) + fake := &appx.Fake{CallFn: func(ctx context.Context, target string, in map[string]any) (map[string]any, error) { + out, err := answers(ctx, target, in) + if err == nil && strings.HasSuffix(target, ".prove_phase") { + out["verified"] = tc.payload + } + return out, err + }} + n := newAuditNode(t, repo, fake) + + var err error + captureStdout(t, func() { + _, err = n.auditHandler(context.Background(), map[string]any{"repo_url": repo}) + }) + + exec := asExecuteError(t, err) + if exec.StatusCode != 500 { + t.Errorf("status = %d, want 500 (TypeError is not a ValueError)", exec.StatusCode) + } + if want := "audit execution failed: " + tc.want; exec.Message != want { + t.Errorf("500 body = %q, want %q", exec.Message, want) + } + }) + } +} + +// TestAuditVerifiedElementIsAPydanticValidationError is the third shape: +// the payload ITERATES but an element is not a mapping, so Python reaches +// `VerifiedFinding.model_validate(5)` — a pydantic ValidationError, which +// SUBCLASSES ValueError and therefore takes audit()'s 400 branch (raw message, +// NO note), not the 500 one. VERIFIED on the pinned interpreter: one +// `model_type` error, msg "Input should be a valid dictionary or instance of +// VerifiedFinding", isinstance(exc, ValueError) is True. +// +// A STRING and a DICT are both iterable in Python, so they land here too rather +// than on the TypeError branch — and an EMPTY string or dict iterates zero +// times, yielding an empty verified list and a successful audit. +func TestAuditVerifiedElementIsAPydanticValidationError(t *testing.T) { + for _, tc := range []struct { + name string + payload any + wantErr bool + }{ + {"list of non-dicts", []any{float64(5)}, true}, + {"string iterates characters", "abc", true}, + {"dict iterates keys", map[string]any{"a": float64(1)}, true}, + {"empty string iterates nothing", "", false}, + {"empty dict iterates nothing", map[string]any{}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + repo := t.TempDir() + answers := phasePayloads(nil, 0, 0) + fake := &appx.Fake{CallFn: func(ctx context.Context, target string, in map[string]any) (map[string]any, error) { + out, err := answers(ctx, target, in) + if err == nil && strings.HasSuffix(target, ".prove_phase") { + out["verified"] = tc.payload + } + return out, err + }} + n := newAuditNode(t, repo, fake) + + var err error + captureStdout(t, func() { + _, err = n.auditHandler(context.Background(), map[string]any{"repo_url": repo}) + }) + + if !tc.wantErr { + if err != nil { + t.Fatalf("an empty iterable must yield an empty verified list, got %v", err) + } + return + } + exec := asExecuteError(t, err) + if exec.StatusCode != 400 { + t.Errorf("status = %d, want 400 (pydantic's ValidationError subclasses ValueError)", exec.StatusCode) + } + if want := "1 validation error for VerifiedFinding: Input should be a valid dictionary or instance of VerifiedFinding"; exec.Message != want { + t.Errorf("400 body = %q, want %q", exec.Message, want) + } + for _, msg := range fake.NoteMessages() { + if strings.HasPrefix(msg, "Audit pipeline failed") { + t.Error("the 400 branch must not emit the failure note") + } + } + }) + } +} + +// TestAuditDropSummaryIsADotGet pins `prove_dict.get("drop_summary", {...})` +// (app.py:205-208). +// +// Validation contract (behaviour, measured on the pinned interpreter): +// +// - key ABSENT -> the {"demoted_total":0,"by_reason":{},"findings":[]} default; +// - key PRESENT -> the value VERBATIM, whatever it is. `dict.get` never +// inspects the value, so an explicit null stays null, a string stays a +// string, and a list stays a list — all the way into the audit result's +// `metadata.prove_drop_summary`, which is `dict[str, object]` in pydantic +// and accepts any of them. +func TestAuditDropSummaryIsADotGet(t *testing.T) { + defaultSummary := map[string]any{ + "demoted_total": 0, + "by_reason": map[string]int{}, + "findings": []map[string]any{}, + } + + for _, tc := range []struct { + name string + payload any // nil DELETES the key + want any + }{ + {"absent", nil, defaultSummary}, + {"null", (any)(nil), (any)(nil)}, + {"string", "none", "none"}, + // Numbers come back as json.Number: the value is stored UNTYPED and + // re-serialised, so it has to carry the int-vs-float distinction + // CPython's json.loads makes (see afx.WireNumbers). + {"list", []any{float64(1)}, []any{json.Number("1")}}, + {"number", float64(7), json.Number("7")}, + {"object", map[string]any{"demoted_total": float64(3)}, + map[string]any{"demoted_total": json.Number("3")}}, + } { + t.Run(tc.name, func(t *testing.T) { + repo := t.TempDir() + answers := phasePayloads(nil, 0, 0) + fake := &appx.Fake{CallFn: func(ctx context.Context, target string, in map[string]any) (map[string]any, error) { + out, err := answers(ctx, target, in) + if err == nil && strings.HasSuffix(target, ".prove_phase") { + if tc.name == "absent" { + delete(out, "drop_summary") + } else { + out["drop_summary"] = tc.payload + } + } + return out, err + }} + n := newAuditNode(t, repo, fake) + + got, err := n.auditHandler(context.Background(), map[string]any{"repo_url": repo}) + if err != nil { + t.Fatalf("auditHandler: %v", err) + } + result, ok := got.(schemas.SecurityAuditResult) + if !ok { + t.Fatalf("result = %T, want schemas.SecurityAuditResult", got) + } + if !reflect.DeepEqual(result.Metadata["prove_drop_summary"], tc.want) { + t.Errorf("metadata.prove_drop_summary = %#v, want %#v", + result.Metadata["prove_drop_summary"], tc.want) + } + }) + } +} + +// TestPhasePayloadErrorTextsCarryNoClassName is the unit-level statement of the +// same rule, including the Source bookkeeping the message deliberately drops: +// str(exc) is the whole message, so nothing may name the exception class and +// nothing may name the phase. +func TestPhasePayloadErrorTextsCarryNoClassName(t *testing.T) { + missing := &missingKeyError{Key: "verified", Source: "prove_phase"} + if got, want := missing.Error(), "'verified'"; got != want { + t.Errorf("missingKeyError = %q, want %q (str(KeyError('verified')))", got, want) + } + if missing.Source != "prove_phase" { + t.Errorf("Source = %q, want it retained for diagnostics", missing.Source) + } + + for _, tc := range []struct{ got, want string }{ + {(¬IterableError{Got: "int"}).Error(), "'int' object is not iterable"}, + {(¬IterableError{Got: "NoneType"}).Error(), "'NoneType' object is not iterable"}, + {(¬IterableError{Got: "float"}).Error(), "'float' object is not iterable"}, + } { + if tc.got != tc.want { + t.Errorf("notIterableError = %q, want %q", tc.got, tc.want) + } + } + + for _, err := range []error{missing, ¬IterableError{Got: "int"}} { + for _, class := range []string{"KeyError", "TypeError", "AttributeError"} { + if strings.Contains(err.Error(), class) { + t.Errorf("%q names an exception class; app.py interpolates str(exc)", err.Error()) + } + } + } +} + +// TestAuditHandlerRunsTheSDKInputValidation pins that `audit` — registered on +// the Agent rather than on the reasoner router — gets the same +// `_validate_handler_input` layer the 33 router reasoners do. +// +// Validation contract (behaviour, measured on the pinned interpreter by calling +// the real `app._validate_handler_input(body, input_types["audit"])`): +// +// {"is_pr": "yes"} -> True (bool is a MEMBERSHIP test, not a parse) +// {"is_pr": "no"} -> False +// {"is_pr": 0} -> False +// {"is_pr": null} -> the default False, because is_pr HAS a default +// {"repo_url": 5} -> "5" (str() coercion) +// {"repo_url": null} -> 422 Field 'repo_url' cannot be None +// +// The four coercion cases were json.UnmarshalTypeErrors before, because +// auditHandler went straight to afx.Bind. +func TestAuditHandlerRunsTheSDKInputValidation(t *testing.T) { + t.Run("bool coercion", func(t *testing.T) { + for _, tc := range []struct { + value any + want bool + }{ + {"yes", true}, {"true", true}, {"TRUE", true}, {"1", true}, + {"no", false}, {"false", false}, {"", false}, + {float64(0), false}, {float64(2), true}, + {nil, false}, // null on a DEFAULTED parameter is the default + } { + got, err := bindAuditRequest(map[string]any{"repo_url": "u", "is_pr": tc.value}) + if err != nil { + t.Fatalf("is_pr=%#v: %v", tc.value, err) + } + if got.IsPr != tc.want { + t.Errorf("is_pr=%#v bound %v, want %v", tc.value, got.IsPr, tc.want) + } + } + }) + + t.Run("str coercion", func(t *testing.T) { + got, err := bindAuditRequest(map[string]any{"repo_url": float64(5)}) + if err != nil { + t.Fatalf("bindAuditRequest: %v", err) + } + if got.RepoURL != "5" { + t.Errorf("repo_url = %q, want %q", got.RepoURL, "5") + } + // The three non-empty keyword defaults still apply. + if got.Depth != "standard" || got.Branch != "main" || got.SeverityThreshold != "low" { + t.Errorf("defaults lost: %+v", got) + } + }) + + t.Run("null on a defaulted string keeps the default", func(t *testing.T) { + got, err := bindAuditRequest(map[string]any{"repo_url": "u", "branch": nil}) + if err != nil { + t.Fatalf("bindAuditRequest: %v", err) + } + if got.Branch != "main" { + t.Errorf("branch = %q, want main", got.Branch) + } + }) + + t.Run("a null required parameter is a 422", func(t *testing.T) { + repo := t.TempDir() + fake := &appx.Fake{} + n := newAuditNode(t, repo, fake) + + _, err := n.auditHandler(context.Background(), map[string]any{"repo_url": nil}) + exec := asExecuteError(t, err) + if exec.StatusCode != 422 { + t.Errorf("status = %d, want 422 (Python's endpoint rejects the body)", exec.StatusCode) + } + if want := "Field 'repo_url' cannot be None"; exec.Message != want { + t.Errorf("message = %q, want %q", exec.Message, want) + } + for _, msg := range fake.NoteMessages() { + if strings.HasPrefix(msg, "Audit pipeline failed") { + t.Error("input validation runs before the pipeline; no failure note") + } + } + }) +} diff --git a/go/internal/node/node.go b/go/internal/node/node.go new file mode 100644 index 0000000..cd78bdb --- /dev/null +++ b/go/internal/node/node.go @@ -0,0 +1,280 @@ +// Package node is the SEC-AF wiring layer: it builds the shared *agent.Agent +// from the environment exactly as src/sec_af/app.py does at import time, mounts +// the 33-reasoner router (internal/reasoners) plus the externally driven +// `audit` reasoner, and serves them. +// +// The split mirrors the Python module: +// +// node.go app.py's Agent(...) constructor + main() (BuildAgent, Serve) +// register.go app.include_router(reasoner_router) + @app.reasoner() audit +// audit.go the audit() body — the four `.call`s, checkpoints, error mapping +// resolve.go _resolve_repo +package node + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + "github.com/Agent-Field/agentfield/sdk/go/ai" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/config" + "github.com/Agent-Field/sec-af/go/internal/orch" + "github.com/Agent-Field/sec-af/go/internal/phases" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// Node bundles the constructed agent with the resolved identity and the seams +// the audit handler is tested through. +type Node struct { + // App is the SDK agent. It satisfies appx.App (Harness/AI/Note/Call) + // directly, so the router handlers and the orchestrator both take it as-is, + // and Serve mounts its handler. + App *agent.Agent + + // NodeID is the resolved node id (NODE_ID env, or the sec-af default). It + // names the node to the control plane and appears in log lines. + NodeID string + + // callNodeID is the prefix the audit handler puts on its four `.call` + // targets. app.py reads `NODE_ID = os.getenv("NODE_ID", "sec-af")` ONCE at + // module import and interpolates it into every f-string, so the value is + // captured here at build time rather than re-read per request. + // + // It is resolved with phases.NodeID() — the same reader internal/reasoners + // uses for the `*_phase` targets — so the audit handler and the phase + // reasoners can never disagree about which node they are calling. Both + // phases.NodeID and envOr (used for NodeID above) treat an explicitly + // empty NODE_ID as unset, so the registered identity and the `.call` + // prefix are the SAME string for every possible value of the variable — + // see phases.NodeID for why Go cannot reproduce Python's empty-string + // spelling. + callNodeID string + + // AgentFieldServer is the control-plane base URL the agent registers with. + AgentFieldServer string + + // ListenAddress is the ":port" the SDK server binds (":"+PORT). + ListenAddress string + + // auditApp is the agent-capability seam the audit handler makes its four + // `.call`s and its notes through. It defaults to App; the handler tests + // substitute an appx.Fake. + auditApp appx.App + + // newOrchestrator constructs the audit orchestrator. Production points it at + // orch.NewWithContext; the error-mapping tests inject a failing constructor + // to exercise the path Python takes when AuditConfig.from_input raises. + newOrchestrator func(ctx context.Context, app appx.App, in schemas.AuditInput) (*orch.AuditOrchestrator, error) + + // resolveRepo is the _resolve_repo seam. Production points it at + // ResolveRepo; tests substitute a stub so no git subprocess runs. + resolveRepo func(ctx context.Context, repoURL string) (string, error) + + // registered records every reasoner name passed through the single + // registration path, in order, and tags records the tags each was + // registered with (audit -> none; the 33 router reasoners -> + // ["security","audit","red-team"]). The SDK exposes neither, so this is the + // parity test's source of truth. + registered []string + tags map[string][]string +} + +// RegisteredNames returns a copy of the reasoner names registered on this node, +// in registration order. +func (n *Node) RegisteredNames() []string { + return append([]string(nil), n.registered...) +} + +// TagsFor returns a copy of the tags registered for name (nil when none). +func (n *Node) TagsFor(name string) []string { + return append([]string(nil), n.tags[name]...) +} + +// harnessConfig maps the resolved AI integration configuration onto the SDK +// harness configuration, porting app.py's +// +// harness_config=HarnessConfig( +// provider=_ai_config.provider, +// model=_ai_config.harness_model, +// max_turns=_ai_config.max_turns, +// env=_ai_config.provider_env(), +// opencode_bin=_ai_config.opencode_bin, +// aforge_bin=_ai_config.aforge_bin, +// permission_mode="auto", +// ) +// +// The Python SDK takes a per-provider binary path (opencode_bin AND +// aforge_bin); the Go SDK takes ONE BinPath that applies to whichever provider +// is selected. So the value is chosen by provider: the aforge binary for +// provider "aforge", the opencode binary for provider "opencode", and empty +// for anything else — an empty BinPath lets the SDK pick the provider's own +// default executable, which is what Python does for the providers it has no +// explicit path for (claude-code, codex, gemini). +func harnessConfig(c config.AIIntegrationConfig, env map[string]string) *agent.HarnessConfig { + return &agent.HarnessConfig{ + Provider: c.Provider, + Model: c.HarnessModel, + MaxTurns: c.MaxTurns, + PermissionMode: "auto", + Env: env, + BinPath: harnessBin(c), + } +} + +// harnessBin picks the single BinPath the Go SDK accepts. See harnessConfig. +func harnessBin(c config.AIIntegrationConfig) string { + switch c.Provider { + case "aforge": + return c.AforgeBin + case "opencode": + return c.OpencodeBin + default: + return "" + } +} + +// BuildAgent constructs the SEC-AF agent from the environment, porting +// src/sec_af/app.py:35-58. +// +// NODE_ID default "sec-af" +// AGENTFIELD_URL control-plane URL, FALLING BACK to AGENTFIELD_SERVER, +// then "http://localhost:8080". Note the precedence: this +// repo reads AGENTFIELD_URL FIRST +// (`os.getenv("AGENTFIELD_URL", os.getenv("AGENTFIELD_SERVER", ...))`), +// which is the opposite of the SDK's own convention and of +// pr-af. It is ported as written. +// AGENTFIELD_API_KEY -> Config.Token (control-plane bearer) +// PORT default "8013" -> ListenAddress ":8013" +// AGENT_CALLBACK_URL -> Config.PublicURL +// +// Two deliberate divergences from Python, both documented rather than "fixed": +// +// 1. CALLBACK URL. Python computes +// `callback_url=os.getenv("AGENT_CALLBACK_URL", f"http://127.0.0.1:{os.getenv('PORT', '8004')}")` +// while `main()` listens on `port=int(os.getenv("PORT", "8080"))`. The two +// defaults DISAGREE: with neither PORT nor AGENT_CALLBACK_URL set, the +// Python node listens on 8080 and tells the control plane to call it on +// 8004 — a latent bug, and one that cannot be "reproduced" usefully because +// it makes the node unreachable. The Go port sets PublicURL from +// AGENT_CALLBACK_URL when it is set and leaves it EMPTY otherwise, which +// makes the SDK derive http://localhost:. Every +// deployment that sets AGENT_CALLBACK_URL (docker-compose, the Go compose +// add-on) is byte-identical to Python; the unset case is merely correct +// instead of broken. +// +// 2. AI CONFIG. Python always passes +// `AIConfig(model=..., api_key=os.getenv("OPENROUTER_API_KEY", ""), api_base=...)`, +// accepting an empty key. The Go SDK's ai.Config rejects an empty API key at +// construction, so AIConfig is attached ONLY when OPENROUTER_API_KEY is set. +// Construction then succeeds without a key (matching Python) and the `.ai()` +// call fails at call time either way. +// +// A malformed numeric SEC_AF_* variable is an ERROR here, not a fallback: +// Python builds AIIntegrationConfig at module import, so the node fails to boot. +func BuildAgent(defaultNodeID, defaultPort, description string) (*Node, error) { + nodeID := envOr("NODE_ID", defaultNodeID) + server := envOr("AGENTFIELD_URL", envOr("AGENTFIELD_SERVER", "http://localhost:8080")) + token := os.Getenv("AGENTFIELD_API_KEY") + port := envOr("PORT", defaultPort) + + aiConf, err := config.AIConfigFromEnv() + if err != nil { + return nil, err + } + // provider_env() creates XDG_DATA_HOME eagerly; Python raises out of the + // Agent constructor when that fails, so the node must not boot either. + providerEnv, err := aiConf.ProviderEnv() + if err != nil { + return nil, err + } + + cfg := agent.Config{ + NodeID: nodeID, + Version: "0.1.0", + AgentFieldURL: server, + Token: token, + ListenAddress: ":" + port, + PublicURL: os.Getenv("AGENT_CALLBACK_URL"), + CLIConfig: &agent.CLIConfig{AppDescription: description}, + HarnessConfig: harnessConfig(aiConf, providerEnv), + } + if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" { + cfg.AIConfig = &ai.Config{ + Model: aiModelForAPI(aiConf.AIModel), + APIKey: apiKey, + BaseURL: "https://openrouter.ai/api/v1", + } + } + + app, err := agent.New(cfg) + if err != nil { + return nil, fmt.Errorf("create agent %q: %w", nodeID, err) + } + + return &Node{ + App: app, + NodeID: nodeID, + callNodeID: phases.NodeID(), + AgentFieldServer: server, + ListenAddress: ":" + port, + auditApp: app, + newOrchestrator: orch.NewWithContext, + resolveRepo: ResolveRepo, + tags: map[string][]string{}, + }, nil +} + +// Serve starts the SDK's own HTTP server, registers the node with the control +// plane and blocks until the context is cancelled or SIGINT/SIGTERM arrives — +// the Go equivalent of app.py's `app.run(port=..., host="0.0.0.0")`. +// +// Unlike pr-af (which needs a bespoke mux for /webhook/github), SEC-AF adds NO +// custom route, so agent.Serve is used directly: it binds the listener BEFORE +// registering, which is what makes the control plane's post-registration health +// check succeed. +// +// Python parity, /health: app.py adds its own route returning +// `{"status": "healthy", "version": "0.1.0"}`. The Go SDK already serves +// /health, returning `{"status": "ok"}` — same 200, same purpose, different +// body. Every consumer in this repo is a liveness probe that only looks at the +// status code (the Dockerfile HEALTHCHECK's `curl -f`, the compose healthcheck, +// the manifest's `healthcheck: /health`), so the SDK's route is used as-is +// rather than shadowed. The SDK additionally serves /status, which is the +// endpoint the control plane's own health monitor polls. +func (n *Node) Serve(ctx context.Context) error { + return n.App.Serve(ctx) +} + +// envOr returns the value of key, or def when the env var is unset OR empty. +// +// Python parity: app.py's reads are `os.getenv(key, default)`, which returns "" +// for a key explicitly set to the empty string. Treating "" as unset here is +// the deliberate difference: an empty NODE_ID / PORT / AGENTFIELD_URL cannot +// produce a working node (agent.New would take an empty node id, and +// ListenAddress would be ":"), and docker-compose files routinely pass through +// empty values for unset variables. config.AIConfigFromEnv keeps the strict +// os.getenv semantics for the SEC_AF_* variables, where "" is meaningful. +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// aiModelForAPI converts the configured AI model into the model ID the +// OpenRouter API expects. +// +// Python's `.ai()` path runs through LiteLLM, which CONSUMES a leading +// "openrouter/" as its routing prefix before calling the OpenRouter API. The Go +// SDK's ai client posts the model string verbatim to BaseURL, where +// "openrouter/minimax/minimax-m2.5" is an invalid model ID — so the routing +// prefix is stripped here to reach the same model Python does. The HARNESS +// model keeps its prefix (opencode's config wants the prefixed form, and +// docker-entrypoint.sh derives its provider model key by stripping it there). +func aiModelForAPI(model string) string { + return strings.TrimPrefix(model, "openrouter/") +} diff --git a/go/internal/node/node_test.go b/go/internal/node/node_test.go new file mode 100644 index 0000000..e5a7192 --- /dev/null +++ b/go/internal/node/node_test.go @@ -0,0 +1,486 @@ +package node + +// Tests for BuildAgent and the node's registration surface. +// +// Validation contract (behaviour, derived from src/sec_af/app.py): +// +// - NODE_ID / PORT / AGENTFIELD_API_KEY / AGENT_CALLBACK_URL are read from +// the environment with app.py's defaults; +// - the control-plane URL precedence in THIS repo is +// AGENTFIELD_URL > AGENTFIELD_SERVER > http://localhost:8080; +// - the harness configuration mirrors AIIntegrationConfig, with the ONE +// binary path chosen by provider (aforge -> aforge_bin, opencode -> +// opencode_bin, anything else -> empty so the SDK picks its own default); +// - a malformed SEC_AF_* numeric variable fails the boot, as it does in +// Python (the config is built at module import); +// - the AI model loses its "openrouter/" LiteLLM routing prefix, and AIConfig +// is attached only when OPENROUTER_API_KEY is set; +// - the node registers `audit` first (untagged) and then the 33 router +// reasoners, each tagged ["security","audit","red-team"]. + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/sec-af/go/internal/config" + "github.com/Agent-Field/sec-af/go/internal/phases" + "github.com/Agent-Field/sec-af/go/internal/reasoners" +) + +// unsetEnv removes key for the duration of the test and restores it after. +// +// t.Setenv(key, "") is NOT equivalent: config.AIConfigFromEnv deliberately uses +// os.LookupEnv, so an EMPTY SEC_AF_MAX_TURNS is a parse error (Python's +// `int(os.getenv("SEC_AF_MAX_TURNS", "50"))` raises on "" too) while an ABSENT +// one yields the default. The tests below need "absent". +func unsetEnv(t *testing.T, key string) { + t.Helper() + prev, had := os.LookupEnv(key) + if err := os.Unsetenv(key); err != nil { + t.Fatalf("unset %s: %v", key, err) + } + t.Cleanup(func() { + if had { + _ = os.Setenv(key, prev) + return + } + _ = os.Unsetenv(key) + }) +} + +// clearEnv removes every variable BuildAgent and config.AIConfigFromEnv read, +// so a test starts from the documented defaults regardless of the developer's +// shell. +func clearEnv(t *testing.T) { + t.Helper() + for _, key := range []string{ + "NODE_ID", "PORT", "AGENTFIELD_URL", "AGENTFIELD_SERVER", "AGENTFIELD_API_KEY", + "AGENT_CALLBACK_URL", "OPENROUTER_API_KEY", + "SEC_AF_PROVIDER", "HARNESS_PROVIDER", "SEC_AF_MODEL", "HARNESS_MODEL", + "SEC_AF_AI_MODEL", "AI_MODEL", "SEC_AF_MAX_TURNS", "SEC_AF_AI_MAX_RETRIES", + "SEC_AF_AI_INITIAL_BACKOFF_SECONDS", "SEC_AF_AI_MAX_BACKOFF_SECONDS", + "SEC_AF_OPENCODE_BIN", "SEC_AF_AFORGE_BIN", "AFORGE_BIN", + "SEC_AF_OPENCODE_SERVER", "OPENCODE_SERVER", + } { + unsetEnv(t, key) + } + // XDG_DATA_HOME is created by provider_env(); point it at a scratch dir so + // the test never writes into the developer's real data home or /tmp. + t.Setenv("XDG_DATA_HOME", t.TempDir()) +} + +func newTestNode(t *testing.T) *Node { + t.Helper() + n, err := BuildAgent("sec-af", "8013", "AI-Native Security Analysis and Red-Teaming Agent") + if err != nil { + t.Fatalf("BuildAgent: %v", err) + } + return n +} + +func TestBuildAgentDefaults(t *testing.T) { + clearEnv(t) + + n := newTestNode(t) + + if n.NodeID != "sec-af" { + t.Errorf("NodeID = %q, want sec-af", n.NodeID) + } + if n.ListenAddress != ":8013" { + t.Errorf("ListenAddress = %q, want :8013", n.ListenAddress) + } + if n.AgentFieldServer != "http://localhost:8080" { + t.Errorf("AgentFieldServer = %q, want http://localhost:8080", n.AgentFieldServer) + } + if n.App == nil { + t.Fatal("App is nil") + } +} + +func TestBuildAgentEnvOverrides(t *testing.T) { + clearEnv(t) + t.Setenv("NODE_ID", "sec-af-go") + t.Setenv("PORT", "9999") + t.Setenv("AGENTFIELD_SERVER", "http://cp:8080") + t.Setenv("AGENTFIELD_API_KEY", "tok") + + n := newTestNode(t) + + if n.NodeID != "sec-af-go" { + t.Errorf("NodeID = %q, want sec-af-go", n.NodeID) + } + if n.ListenAddress != ":9999" { + t.Errorf("ListenAddress = %q, want :9999", n.ListenAddress) + } + if n.AgentFieldServer != "http://cp:8080" { + t.Errorf("AgentFieldServer = %q, want http://cp:8080", n.AgentFieldServer) + } +} + +// TestBuildAgentURLPrecedence pins the quirk of THIS repo: app.py reads +// AGENTFIELD_URL first and only then AGENTFIELD_SERVER — the reverse of the +// SDK's own convention and of pr-af. +func TestBuildAgentURLPrecedence(t *testing.T) { + clearEnv(t) + t.Setenv("AGENTFIELD_URL", "http://from-url:8080") + t.Setenv("AGENTFIELD_SERVER", "http://from-server:8080") + + if got := newTestNode(t).AgentFieldServer; got != "http://from-url:8080" { + t.Errorf("AgentFieldServer = %q, want AGENTFIELD_URL to win", got) + } +} + +// TestBuildAgentMalformedNumberFailsBoot: Python builds AIIntegrationConfig at +// import, so `SEC_AF_MAX_TURNS=abc` crashes the process before it can serve. +func TestBuildAgentMalformedNumberFailsBoot(t *testing.T) { + clearEnv(t) + t.Setenv("SEC_AF_MAX_TURNS", "abc") + + if _, err := BuildAgent("sec-af", "8013", "d"); err == nil { + t.Fatal("want BuildAgent to fail on a malformed SEC_AF_MAX_TURNS") + } +} + +func TestHarnessBinByProvider(t *testing.T) { + clearEnv(t) + + cases := []struct { + provider string + aforge string + opencode string + want string + }{ + {"aforge", "/usr/local/bin/aforge", "opencode", "/usr/local/bin/aforge"}, + {"opencode", "aforge", "/home/secaf/.opencode/bin/opencode", "/home/secaf/.opencode/bin/opencode"}, + // Every other provider gets an empty BinPath so the SDK resolves its own + // default executable — the Python SDK is told nothing for these either. + {"claude-code", "aforge", "opencode", ""}, + {"codex", "aforge", "opencode", ""}, + {"gemini", "aforge", "opencode", ""}, + } + for _, tc := range cases { + t.Run(tc.provider, func(t *testing.T) { + t.Setenv("SEC_AF_PROVIDER", tc.provider) + t.Setenv("SEC_AF_AFORGE_BIN", tc.aforge) + t.Setenv("SEC_AF_OPENCODE_BIN", tc.opencode) + + // Boot the node so the environment is proven to be accepted, then + // read the same resolved config back to check the BinPath choice. + newTestNode(t) + + cfg, err := config.AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + hc := harnessConfig(cfg, nil) + if hc.BinPath != tc.want { + t.Errorf("BinPath = %q, want %q", hc.BinPath, tc.want) + } + if hc.Provider != tc.provider { + t.Errorf("Provider = %q, want %q", hc.Provider, tc.provider) + } + if hc.PermissionMode != "auto" { + t.Errorf("PermissionMode = %q, want auto", hc.PermissionMode) + } + if hc.MaxTurns != 50 { + t.Errorf("MaxTurns = %d, want the SEC_AF_MAX_TURNS default 50", hc.MaxTurns) + } + }) + } +} + +func TestAIModelForAPIStripsRoutingPrefix(t *testing.T) { + cases := map[string]string{ + "openrouter/minimax/minimax-m2.5": "minimax/minimax-m2.5", + "minimax/minimax-m2.5": "minimax/minimax-m2.5", + "": "", + // Only a LEADING prefix is stripped, and only one. + "openrouter/openrouter/x": "openrouter/x", + } + for in, want := range cases { + if got := aiModelForAPI(in); got != want { + t.Errorf("aiModelForAPI(%q) = %q, want %q", in, got, want) + } + } +} + +func TestEnvOrTreatsEmptyAsUnset(t *testing.T) { + t.Setenv("SEC_AF_TEST_ENV_OR", "") + if got := envOr("SEC_AF_TEST_ENV_OR", "fallback"); got != "fallback" { + t.Errorf("envOr with an empty value = %q, want the fallback", got) + } + t.Setenv("SEC_AF_TEST_ENV_OR", "value") + if got := envOr("SEC_AF_TEST_ENV_OR", "fallback"); got != "value" { + t.Errorf("envOr = %q, want value", got) + } +} + +// pythonSurface is the independent parity checklist for the FULL node surface: +// `audit` (registered by @app.reasoner() at the top of app.py) followed by the +// 33 router reasoners in DESIGN.md §3 order. +var pythonSurface = append([]string{reasoners.NameAudit}, reasoners.Names...) + +func TestNodeRegisterAllExactSurface(t *testing.T) { + clearEnv(t) + n := newTestNode(t) + n.RegisterAll() + + got := n.RegisteredNames() + if !reflect.DeepEqual(got, pythonSurface) { + t.Fatalf("registered surface mismatch:\n got = %v\n want = %v", got, pythonSurface) + } + if len(got) != 34 { + t.Errorf("surface size = %d, want 34 (audit + 33 router reasoners)", len(got)) + } + + seen := map[string]int{} + for _, name := range got { + seen[name]++ + } + for name, count := range seen { + if count > 1 { + t.Errorf("reasoner %q registered %d times (collision)", name, count) + } + } + + if tags := n.TagsFor(reasoners.NameAudit); len(tags) != 0 { + t.Errorf("audit tags = %v, want none (it is not on the tagged router)", tags) + } + for _, name := range reasoners.Names { + if tags := n.TagsFor(name); !reflect.DeepEqual(tags, []string{"security", "audit", "red-team"}) { + t.Errorf("%s tags = %v, want [security audit red-team]", name, tags) + } + } +} + +// TestBuildAgentCapturesCallNodeID pins that the `.call` prefix is read ONCE at +// build time (Python's module-level NODE_ID) and agrees with the id the node +// registers under. +func TestBuildAgentCapturesCallNodeID(t *testing.T) { + clearEnv(t) + t.Setenv("NODE_ID", "sec-af-go") + + n := newTestNode(t) + if n.callNodeID != "sec-af-go" { + t.Errorf("callNodeID = %q, want sec-af-go", n.callNodeID) + } + if n.callNodeID != n.NodeID { + t.Errorf("callNodeID %q and NodeID %q disagree", n.callNodeID, n.NodeID) + } + + // Changing the environment after the build must NOT move the targets. + t.Setenv("NODE_ID", "someone-else") + if n.callNodeID != "sec-af-go" { + t.Errorf("callNodeID changed after boot: %q", n.callNodeID) + } +} + +// TestBuildAgentEmptyNodeIDKeepsTheTwoReadersInSync is the case an explicitly +// empty variable (`NODE_ID=`, e.g. a docker-compose `NODE_ID=${NODE_ID}` +// passthrough) used to break: BuildAgent resolved the registered id with envOr +// ("" -> "sec-af") while the `.call` prefix came from os.LookupEnv ("" -> ""), +// so the node registered as "sec-af" and then emitted DAG edges targeting +// ".recon_phase". Python cannot get into that state — app.py:32 and +// reasoners/phases.py:31 read the SAME `os.getenv("NODE_ID", "sec-af")`, so its +// two values are always the identical string. +func TestBuildAgentEmptyNodeIDKeepsTheTwoReadersInSync(t *testing.T) { + clearEnv(t) + t.Setenv("NODE_ID", "") + + n := newTestNode(t) + if n.callNodeID != n.NodeID { + t.Errorf("callNodeID %q and NodeID %q disagree", n.callNodeID, n.NodeID) + } + if n.NodeID != "sec-af" { + t.Errorf("NodeID = %q, want the sec-af default", n.NodeID) + } + if got := phases.NodeID(); got != n.NodeID { + t.Errorf("phases.NodeID() = %q, want %q", got, n.NodeID) + } +} + +// TestNodeInputSchemasMatchThePythonNode covers the whole 34-reasoner surface +// at the place it actually exists — the built node — and reads the schemas back +// out of the SDK through /discover, the same payload the control plane is given +// at registration. +// +// Validation contract: no reasoner may be published on the Go SDK's +// `{"type":"object","additionalProperties":true}` placeholder, and every one of +// them must carry the schema the Python node publishes for that id (see +// internal/reasoners/input_schemas.go for the capture and how to regenerate it). +func TestNodeInputSchemasMatchThePythonNode(t *testing.T) { + clearEnv(t) + n := newTestNode(t) + n.RegisterAll() + + published := discoverInputSchemas(t, n) + + if len(published) != 34 { + t.Fatalf("/discover reports %d reasoners, want 34", len(published)) + } + + placeholder := decodeJSON(t, []byte(`{"type":"object","additionalProperties":true}`)) + for _, name := range n.RegisteredNames() { + got, ok := published[name] + if !ok { + t.Errorf("%s: not present in /discover", name) + continue + } + want := decodeJSON(t, reasoners.InputSchema(name)) + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: published schema mismatch\n got = %#v\n want = %#v", name, got, want) + } + if reflect.DeepEqual(got, placeholder) { + t.Errorf("%s: published the SDK placeholder schema", name) + } + } +} + +// TestAuditInputSchemaMatchesThePythonSignature transcribes the audit() +// signature (app.py:121-141) independently of the capture, so a bad +// regeneration fails here too. +// +// It is the schema with the most of Python's derivation quirks in one place: +// ELEVEN parameters are PEP 604 optionals (`str | None`, `list[str] | None`, +// `float | None`, `int | None`) and every one of them is published as a bare +// {"type":"object"} rather than as its base type or an array — the Union branch +// of Agent._type_to_json_schema only fires for typing.Union, and a `X | None` +// annotation is a types.UnionType with no __origin__. Only `repo_url` is +// required; the other nineteen parameters all have defaults. +func TestAuditInputSchemaMatchesThePythonSignature(t *testing.T) { + want := decodeJSON(t, []byte(`{"type":"object","properties":{ + "repo_url":{"type":"string"}, + "depth":{"type":"string"}, + "branch":{"type":"string"}, + "commit_sha":{"type":"object"}, + "base_commit_sha":{"type":"object"}, + "severity_threshold":{"type":"string"}, + "scan_types":{"type":"object"}, + "output_formats":{"type":"object"}, + "compliance_frameworks":{"type":"object"}, + "max_cost_usd":{"type":"object"}, + "max_provers":{"type":"object"}, + "max_duration_seconds":{"type":"object"}, + "include_paths":{"type":"object"}, + "exclude_paths":{"type":"object"}, + "is_pr":{"type":"boolean"}, + "pr_id":{"type":"object"}, + "post_pr_comments":{"type":"boolean"}, + "fail_on_findings":{"type":"boolean"}, + "enable_dast":{"type":"boolean"}, + "resume_from_checkpoint":{"type":"object"}}, + "required":["repo_url"]}`)) + + if got := decodeJSON(t, reasoners.InputSchema(reasoners.NameAudit)); !reflect.DeepEqual(got, want) { + t.Fatalf("audit schema mismatch\n got = %#v\n want = %#v", got, want) + } + + clearEnv(t) + n := newTestNode(t) + n.RegisterAll() + + if got := discoverInputSchemas(t, n)[reasoners.NameAudit]; !reflect.DeepEqual(got, want) { + t.Fatalf("published audit schema mismatch\n got = %#v\n want = %#v", got, want) + } +} + +// discoverInputSchemas asks the node's own agent which reasoners it holds and +// with which input schemas — the SDK keeps its reasoner table unexported, so +// /discover is the only read-back, and it is the registration payload itself. +func discoverInputSchemas(t *testing.T, n *Node) map[string]any { + t.Helper() + + rec := httptest.NewRecorder() + n.App.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/discover", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("/discover status = %d, want 200", rec.Code) + } + + var payload struct { + Reasoners []struct { + ID string `json:"id"` + InputSchema any `json:"input_schema"` + } `json:"reasoners"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode /discover: %v", err) + } + + out := make(map[string]any, len(payload.Reasoners)) + for _, r := range payload.Reasoners { + out[r.ID] = r.InputSchema + } + return out +} + +// decodeJSON renders raw JSON as untyped Go values so comparisons ignore object +// key order while still pinning array order (`required` follows the Python +// parameter order). +func decodeJSON(t *testing.T, raw []byte) any { + t.Helper() + var v any + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("decode %s: %v", raw, err) + } + return v +} + +// TestServeFailsWhenTheControlPlaneIsUnreachable pins the fourth deliberate +// divergence listed in go/README.md's "Parity notes", so the documentation +// cannot silently stop describing the binary. +// +// Validation contract (behaviour): +// +// - the Python node DEGRADES: agent_server.py installs a ConnectionManager +// whose start() is non-blocking; on failure connection_manager.py logs +// "AgentField server unavailable - running in degraded mode", keeps serving, +// and retries every 10s; +// - the Go SDK has no such retry — client.RegisterNode returns the error, +// Agent.Serve propagates it, and cmd/sec-af/main.go log.Fatalf's on it — so +// the process EXITS instead. +// +// This is SDK-level behaviour, not something the port chose, and it is why the +// compose add-on restart-loops until the Python stack's control plane answers. +// The test exists to pin the divergence as REAL: if a future SDK gains the +// Python retry semantics, Serve stops returning here and the README bullet has +// to be revisited rather than quietly rotting. +func TestServeFailsWhenTheControlPlaneIsUnreachable(t *testing.T) { + clearEnv(t) + + // A port nothing is listening on: bind, read the address, close. The + // connection is then REFUSED immediately rather than timing out. + probe, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("probe listen: %v", err) + } + dead := probe.Addr().String() + if err := probe.Close(); err != nil { + t.Fatalf("probe close: %v", err) + } + + t.Setenv("AGENTFIELD_SERVER", "http://"+dead) + t.Setenv("PORT", "0") // ephemeral listen port, so the test never collides + n := newTestNode(t) + // Register the surface first: an agent with no reasoners fails Initialize + // for a DIFFERENT reason ("no reasoners or skills registered"), which would + // make this test pass without ever reaching the control-plane call. + n.RegisterAll() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := n.Serve(ctx); err == nil { + t.Fatal("Serve returned nil with the control plane down; the Go node is " + + "documented to FAIL here where the Python node degrades and retries") + } else if !strings.Contains(err.Error(), "register node") { + t.Errorf("Serve error = %v, want the `register node` failure from client.RegisterNode", err) + } +} diff --git a/go/internal/node/register.go b/go/internal/node/register.go new file mode 100644 index 0000000..16303b2 --- /dev/null +++ b/go/internal/node/register.go @@ -0,0 +1,52 @@ +package node + +import ( + "github.com/Agent-Field/agentfield/sdk/go/agent" + + "github.com/Agent-Field/sec-af/go/internal/reasoners" +) + +// register.go ports the two registration statements of src/sec_af/app.py: +// +// @app.reasoner() +// async def audit(...): ... # the externally driven entry point +// ... +// app.include_router(reasoner_router) # the 33 router reasoners +// +// Python's decorator runs at import, BEFORE include_router at the bottom of the +// module, so `audit` is the first name on the node — the order is reproduced. + +// RegisterAll registers the complete SEC-AF surface: `audit` on the agent +// itself, then the 33 router reasoners through internal/reasoners.RegisterAll. +// +// `audit` carries the input schema the live Python node publishes for it — +// reasoners.InputSchema replays the committed capture, exactly as the router +// registrations do — and NO tags (Python decorates it with @app.reasoner(), not +// on the tagged AgentRouter). The 33 router reasoners are mounted with +// agent.RouterOptions{Tags: reasoners.RouterTags}, which is the Go spelling of +// AgentRouter(tags=["security","audit","red-team"]): the SDK merges those tags +// into every handler the router carries. No Prefix — Python's include_router +// call passes none, so the reasoners keep their bare names and callers reach +// them as `.`. +func (n *Node) RegisterAll() { + n.record(reasoners.NameAudit, nil) + n.App.RegisterReasoner(reasoners.NameAudit, n.auditHandler, + agent.WithInputSchema(reasoners.InputSchema(reasoners.NameAudit))) + + router := agent.NewRouter() + for _, name := range reasoners.RegisterAll(router, n.App) { + n.record(name, reasoners.RouterTags) + } + n.App.IncludeRouter(router, agent.RouterOptions{Tags: reasoners.RouterTags}) +} + +// record appends name (and its tags) to the node's registration bookkeeping — +// the source of truth for the parity test, since the SDK keeps its reasoner +// table unexported and reports an empty tag list on /discover. +func (n *Node) record(name string, tags []string) { + if n.tags == nil { + n.tags = map[string][]string{} + } + n.registered = append(n.registered, name) + n.tags[name] = append([]string(nil), tags...) +} diff --git a/go/internal/node/resolve.go b/go/internal/node/resolve.go new file mode 100644 index 0000000..50a669a --- /dev/null +++ b/go/internal/node/resolve.go @@ -0,0 +1,219 @@ +package node + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// resolve.go ports `_resolve_repo(repo_url)` (src/sec_af/app.py:75). + +// Workspace / git constants, transcribed from _resolve_repo. +const ( + // DefaultWorkspacesDir is `os.getenv("SEC_AF_WORKSPACES_DIR", "/workspaces")`'s + // fallback. + DefaultWorkspacesDir = "/workspaces" + // gitPullTimeout is `subprocess.run(..., timeout=60)` on `git pull --ff-only`. + gitPullTimeout = 60 * time.Second + // gitCloneTimeout is `subprocess.run(..., timeout=120)` on `git clone`. + gitCloneTimeout = 120 * time.Second + // workspacesDirPerm: `os.makedirs(path, exist_ok=True)` uses 0o777 masked by + // the umask, which under the usual 022 lands on 0755. + workspacesDirPerm os.FileMode = 0o755 +) + +// urlPrefixes is the `repo_url.startswith(("https://", "http://", "git@"))` +// tuple, in order. +var urlPrefixes = []string{"https://", "http://", "git@"} + +// CloneFailedError is `ValueError(f"git clone failed: {result.stderr.strip()}")`. +// +// It is the one error _resolve_repo raises, and it is a ValueError — which +// matters for the HTTP mapping. See auditHandler for where Python actually +// catches it (spoiler: it does not; _resolve_repo runs OUTSIDE audit()'s +// try/except). +type CloneFailedError struct{ Stderr string } + +func (e *CloneFailedError) Error() string { return "git clone failed: " + e.Stderr } + +// ResolveRepo ports `_resolve_repo(repo_url) -> str`: +// +// if os.path.isdir(repo_url): +// return str(Path(repo_url).resolve()) +// if repo_url.startswith(("https://", "http://", "git@")): +// repo_name = repo_url.rstrip("/").split("/")[-1].replace(".git", "") +// workspaces_root = os.getenv("SEC_AF_WORKSPACES_DIR", "/workspaces") +// try: os.makedirs(workspaces_root, exist_ok=True) +// except PermissionError: +// workspaces_root = str(Path.home() / ".sec-af" / "workspaces") +// os.makedirs(workspaces_root, exist_ok=True) +// target_dir = str(Path(workspaces_root) / repo_name) +// if os.path.isdir(target_dir): +// subprocess.run(["git", "pull", "--ff-only"], cwd=target_dir, env=..., timeout=60, capture_output=True) +// return target_dir +// result = subprocess.run(["git", "clone", "--depth", "1", repo_url, target_dir], env=..., timeout=120, capture_output=True, text=True) +// if result.returncode != 0: +// raise ValueError(f"git clone failed: {result.stderr.strip()}") +// return target_dir +// return str(Path(os.getenv("SEC_AF_REPO_PATH", os.getcwd())).resolve()) +// +// Python parity, in order of how easy each is to get wrong: +// +// - `.replace(".git", "")` is a GLOBAL substring replacement, not a suffix +// strip: "https://host/my.github.repo" yields the directory name +// "myhub.repo". Reproduced with strings.ReplaceAll. +// - `rstrip("/")` strips EVERY trailing slash, not just one. +// - the `git pull` CompletedProcess is DISCARDED — `check` defaults to False, +// so a failed pull (diverged history, no remote, network down) silently +// returns the stale checkout. Its 60s TIMEOUT is a different outcome: +// `subprocess.run(..., timeout=60)` RAISES TimeoutExpired (VERIFIED on the +// pinned interpreter: `subprocess.run(["sleep","5"], timeout=1)` raises +// while `subprocess.run(["false"], timeout=5)` returns returncode 1), and +// `_resolve_repo` is called OUTSIDE audit()'s try (app.py:165 vs :168), so +// that raise becomes a 500 and the audit never runs. A hung remote must +// therefore fail the request, not audit a stale checkout — the same +// distinction internal/diffanalysis.runGit draws. +// - only PermissionError falls back to ~/.sec-af/workspaces. An empty +// SEC_AF_WORKSPACES_DIR makes os.makedirs raise FileNotFoundError, which +// propagates; a path that exists as a FILE raises FileExistsError, which +// also propagates. Go's os.MkdirAll returns a *PathError for all three, so +// the fallback is gated on os.IsPermission. +// - the final fallback resolves SEC_AF_REPO_PATH (or the process cwd) — +// a non-URL, non-directory repo_url is silently replaced by the local +// checkout rather than being an error. +// +// The subprocess environment is `{**os.environ, GIT_TERMINAL_PROMPT: "0", +// GIT_ASKPASS: "echo"}`: never prompt for credentials, never block. +func ResolveRepo(ctx context.Context, repoURL string) (string, error) { + if isDir(repoURL) { + return resolvePath(repoURL), nil + } + + if hasURLPrefix(repoURL) { + repoName := repoNameFromURL(repoURL) + + workspacesRoot := DefaultWorkspacesDir + if v, ok := os.LookupEnv("SEC_AF_WORKSPACES_DIR"); ok { + workspacesRoot = v + } + if err := os.MkdirAll(workspacesRoot, workspacesDirPerm); err != nil { + if !os.IsPermission(err) { + return "", err + } + home, homeErr := os.UserHomeDir() + if homeErr != nil { + return "", homeErr + } + workspacesRoot = filepath.Join(home, ".sec-af", "workspaces") + if err := os.MkdirAll(workspacesRoot, workspacesDirPerm); err != nil { + return "", err + } + } + targetDir := filepath.Join(workspacesRoot, repoName) + + if isDir(targetDir) { + pullCtx, cancel := context.WithTimeout(ctx, gitPullTimeout) + defer cancel() + pull := exec.CommandContext(pullCtx, "git", "pull", "--ff-only") + pull.Dir = targetDir + pull.Env = gitEnv() + err := pull.Run() + if ctxErr := pullCtx.Err(); ctxErr != nil { + // The 60s deadline (or the caller's cancellation) killed git: + // subprocess.TimeoutExpired, which propagates. Note that Run() + // most often reports the kill as an *exec.ExitError + // ("signal: killed"), so the deadline has to be read off the + // context rather than off err. + return "", ctxErr + } + if err != nil { + if _, isExit := err.(*exec.ExitError); !isExit { + // git missing / not executable: Python's subprocess.run + // raises FileNotFoundError, which propagates too. + return "", err + } + // Python parity: `check` defaults to False, so a non-zero exit + // is a normal outcome and the CompletedProcess is discarded — + // the existing checkout comes back unchanged. + } + return targetDir, nil + } + + cloneCtx, cancel := context.WithTimeout(ctx, gitCloneTimeout) + defer cancel() + clone := exec.CommandContext(cloneCtx, "git", "clone", "--depth", "1", repoURL, targetDir) + clone.Env = gitEnv() + var stderr strings.Builder + clone.Stderr = &stderr + if err := clone.Run(); err != nil { + return "", &CloneFailedError{Stderr: strings.TrimSpace(stderr.String())} + } + return targetDir, nil + } + + fallback := os.Getenv("SEC_AF_REPO_PATH") + if fallback == "" { + cwd, err := os.Getwd() + if err != nil { + // os.getcwd() raises in Python too; "." is the only honest answer. + cwd = "." + } + fallback = cwd + } + return resolvePath(fallback), nil +} + +// repoNameFromURL is `repo_url.rstrip("/").split("/")[-1].replace(".git", "")`. +func repoNameFromURL(repoURL string) string { + trimmed := strings.TrimRight(repoURL, "/") + if idx := strings.LastIndex(trimmed, "/"); idx >= 0 { + trimmed = trimmed[idx+1:] + } + return strings.ReplaceAll(trimmed, ".git", "") +} + +// hasURLPrefix is `repo_url.startswith(("https://", "http://", "git@"))`. +func hasURLPrefix(repoURL string) bool { + for _, p := range urlPrefixes { + if strings.HasPrefix(repoURL, p) { + return true + } + } + return false +} + +// isDir is `os.path.isdir(p)` — it follows symlinks and is false for anything +// that is not a directory (including a path that does not exist). +func isDir(p string) bool { + info, err := os.Stat(p) + return err == nil && info.IsDir() +} + +// resolvePath is `str(Path(p).resolve())`: absolute, with symlinks followed. +// +// Python's resolve(strict=False) still returns a path when it does not exist; +// Go's filepath.EvalSymlinks fails there, so the absolute form is kept — the +// same answer for every path with no symlinked ancestor. (internal/orch's +// resolveRepoPath makes the identical trade for SEC_AF_REPO_PATH.) +func resolvePath(p string) string { + abs, err := filepath.Abs(p) + if err != nil { + return p + } + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + return resolved + } + return abs +} + +// gitEnv is `{**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "echo"}`. +// +// Later entries win in Go's exec.Cmd.Env, so appending the two overrides is +// equivalent to the dict merge even when the ambient environment already sets +// them. +func gitEnv() []string { + return append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=echo") +} diff --git a/go/internal/node/resolve_test.go b/go/internal/node/resolve_test.go new file mode 100644 index 0000000..9f14da1 --- /dev/null +++ b/go/internal/node/resolve_test.go @@ -0,0 +1,292 @@ +package node + +// Tests for _resolve_repo. +// +// Validation contract (behaviour, derived from src/sec_af/app.py:75): +// +// - an existing DIRECTORY is returned resolved (absolute, symlinks followed), +// with no git subprocess; +// - an http(s)/git@ URL is cloned shallow into +// $SEC_AF_WORKSPACES_DIR/, where is the last path +// segment with EVERY ".git" substring removed; +// - an existing clone is `git pull --ff-only`ed and returned; a FAILING pull +// (non-zero exit, `check=False`) is ignored and the stale checkout is still +// returned, but a pull that hits the 60s TIMEOUT raises +// subprocess.TimeoutExpired out of _resolve_repo — which app.py calls +// OUTSIDE audit()'s try, so the request fails instead of auditing stale +// code; +// - a failed clone raises ValueError("git clone failed: "); +// - anything else falls back to SEC_AF_REPO_PATH (or the cwd), resolved. +// +// The clone/pull paths run against a real LOCAL bare repository, so the tests +// need git but no network. + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestRepoNameFromURL(t *testing.T) { + cases := map[string]string{ + "https://github.com/owner/repo": "repo", + "https://github.com/owner/repo.git": "repo", + "https://github.com/owner/repo///": "repo", + "git@github.com:owner/repo.git": "repo", + "http://host/owner/repo.git/": "repo", + // Python parity: str.replace is GLOBAL, not a suffix strip. + "https://host/owner/my.github.repo": "myhub.repo", + "https://host/owner/.git": "", + // An scp-style URL with no slash keeps the whole string as the "last + // segment"; only the trailing ".git" is removed. + "git@github.com:repo.git": "git@github.com:repo", + } + for in, want := range cases { + if got := repoNameFromURL(in); got != want { + t.Errorf("repoNameFromURL(%q) = %q, want %q", in, got, want) + } + } +} + +func TestResolveRepoExistingDirectory(t *testing.T) { + dir := t.TempDir() + got, err := ResolveRepo(context.Background(), dir) + if err != nil { + t.Fatalf("ResolveRepo: %v", err) + } + want := resolvePath(dir) + if got != want { + t.Errorf("ResolveRepo(%q) = %q, want %q", dir, got, want) + } +} + +func TestResolveRepoFallsBackToRepoPathEnv(t *testing.T) { + fallback := t.TempDir() + t.Setenv("SEC_AF_REPO_PATH", fallback) + + got, err := ResolveRepo(context.Background(), "not-a-url-and-not-a-dir") + if err != nil { + t.Fatalf("ResolveRepo: %v", err) + } + if got != resolvePath(fallback) { + t.Errorf("ResolveRepo = %q, want the SEC_AF_REPO_PATH fallback %q", got, resolvePath(fallback)) + } +} + +func TestResolveRepoFallsBackToCwd(t *testing.T) { + unsetEnv(t, "SEC_AF_REPO_PATH") + + got, err := ResolveRepo(context.Background(), "./does-not-exist") + if err != nil { + t.Fatalf("ResolveRepo: %v", err) + } + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + if got != resolvePath(cwd) { + t.Errorf("ResolveRepo = %q, want the resolved cwd %q", got, resolvePath(cwd)) + } +} + +func TestResolveRepoClonesAndThenPulls(t *testing.T) { + requireGit(t) + + const url = "https://example.test/owner/repo.git" + origin := newBareOrigin(t) + rewriteURLToLocal(t, url, origin) + + workspaces := filepath.Join(t.TempDir(), "workspaces") + t.Setenv("SEC_AF_WORKSPACES_DIR", workspaces) + + first, err := ResolveRepo(context.Background(), url) + if err != nil { + t.Fatalf("first ResolveRepo (clone): %v", err) + } + wantDir := filepath.Join(workspaces, "repo") + if first != wantDir { + t.Fatalf("clone target = %q, want %q", first, wantDir) + } + if _, err := os.Stat(filepath.Join(first, "README.md")); err != nil { + t.Fatalf("clone did not produce the origin's content: %v", err) + } + + // Second call: the directory exists, so the pull branch runs and the same + // path comes back. + second, err := ResolveRepo(context.Background(), url) + if err != nil { + t.Fatalf("second ResolveRepo (pull): %v", err) + } + if second != first { + t.Errorf("pull path returned %q, want %q", second, first) + } +} + +// TestResolveRepoIgnoresAFailingPull pins the discarded CompletedProcess: a +// directory that is not a git repository at all still comes back unchanged. +func TestResolveRepoIgnoresAFailingPull(t *testing.T) { + requireGit(t) + + workspaces := t.TempDir() + t.Setenv("SEC_AF_WORKSPACES_DIR", workspaces) + + target := filepath.Join(workspaces, "repo") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + got, err := ResolveRepo(context.Background(), "https://example.invalid/owner/repo.git") + if err != nil { + t.Fatalf("ResolveRepo must ignore a failing pull: %v", err) + } + if got != target { + t.Errorf("ResolveRepo = %q, want the existing checkout %q", got, target) + } +} + +// TestResolveRepoPropagatesAPullDeadline is the other half of +// TestResolveRepoIgnoresAFailingPull. VERIFIED on the pinned interpreter: +// `subprocess.run(["sleep","5"], timeout=1)` raises TimeoutExpired while +// `subprocess.run(["false"], timeout=5)` returns returncode 1 — so the two +// outcomes must not collapse into "return the stale checkout". An expired +// context stands in for the 60s deadline: it is the same cancellation the +// deadline produces, without a five-second test. +func TestResolveRepoPropagatesAPullDeadline(t *testing.T) { + requireGit(t) + + workspaces := t.TempDir() + t.Setenv("SEC_AF_WORKSPACES_DIR", workspaces) + target := filepath.Join(workspaces, "repo") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + got, err := ResolveRepo(ctx, "https://example.invalid/owner/repo.git") + if err == nil { + t.Fatalf("ResolveRepo returned %q with no error; a killed pull must not "+ + "be reported as a usable checkout", got) + } + if got != "" { + t.Errorf("path = %q, want the empty string alongside the error", got) + } +} + +func TestResolveRepoCloneFailureIsAValueError(t *testing.T) { + requireGit(t) + + workspaces := t.TempDir() + t.Setenv("SEC_AF_WORKSPACES_DIR", workspaces) + + const url = "https://example.test/owner/missing.git" + rewriteURLToLocal(t, url, filepath.Join(t.TempDir(), "no-such-origin")) + + _, err := ResolveRepo(context.Background(), url) + if err == nil { + t.Fatal("want a clone failure") + } + var cloneErr *CloneFailedError + if !errors.As(err, &cloneErr) { + t.Fatalf("error is not *CloneFailedError: %T (%v)", err, err) + } + if !strings.HasPrefix(cloneErr.Error(), "git clone failed: ") { + t.Errorf("message = %q, want the Python prefix", cloneErr.Error()) + } +} + +// TestResolveRepoWorkspacesDirDefault pins that an unset SEC_AF_WORKSPACES_DIR +// means /workspaces. The directory is almost never creatable in a test +// environment, so the assertion is on the ERROR path: the failure must mention +// /workspaces, and a permission failure must fall back to ~/.sec-af/workspaces. +func TestResolveRepoWorkspacesDirDefault(t *testing.T) { + unsetEnv(t, "SEC_AF_WORKSPACES_DIR") + + if DefaultWorkspacesDir != "/workspaces" { + t.Fatalf("DefaultWorkspacesDir = %q, want /workspaces", DefaultWorkspacesDir) + } + + home := t.TempDir() + t.Setenv("HOME", home) + + const url = "https://example.test/owner/missing.git" + rewriteURLToLocal(t, url, filepath.Join(t.TempDir(), "no-such-origin")) + + _, err := ResolveRepo(context.Background(), url) + if err == nil { + t.Skip("this environment can create /workspaces; the fallback is untested here") + } + // Either the mkdir failed (not a permission error -> propagated) or the + // permission fallback kicked in and the clone then failed. Both are fine; + // what must NOT happen is a silent success. + if _, statErr := os.Stat(filepath.Join(home, ".sec-af", "workspaces")); statErr == nil { + // Permission fallback path taken: the clone error is the ValueError. + var cloneErr *CloneFailedError + if !errors.As(err, &cloneErr) { + t.Errorf("after the ~/.sec-af/workspaces fallback, want a clone error, got %T (%v)", err, err) + } + } +} + +// rewriteURLToLocal makes git resolve url to a local path, through the +// `url..insteadOf` config git reads from GIT_CONFIG_COUNT/KEY/VALUE. +// +// ResolveRepo builds its subprocess environment from os.Environ(), so a +// t.Setenv here reaches the clone and the pull. This is what lets the URL +// branch — which only fires for https/http/git@ prefixes — be exercised against +// a local bare repository, with no network and no ssh. +func rewriteURLToLocal(t *testing.T, url, localPath string) { + t.Helper() + t.Setenv("GIT_CONFIG_COUNT", "1") + t.Setenv("GIT_CONFIG_KEY_0", "url."+localPath+".insteadOf") + t.Setenv("GIT_CONFIG_VALUE_0", url) +} + +func requireGit(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not installed") + } +} + +// newBareOrigin builds a local bare repository with one commit and returns its +// path, so the clone/pull tests need no network. +func newBareOrigin(t *testing.T) string { + t.Helper() + + root := t.TempDir() + work := filepath.Join(root, "work") + bare := filepath.Join(root, "origin.git") + + run := func(dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_TERMINAL_PROMPT=0", + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + } + + if err := os.MkdirAll(work, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + run(work, "init", "-q", "-b", "main") + if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("origin\n"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + run(work, "add", "README.md") + run(work, "commit", "-q", "-m", "initial") + run(root, "clone", "-q", "--bare", work, bare) + + return bare +} diff --git a/go/internal/orch/budget.go b/go/internal/orch/budget.go new file mode 100644 index 0000000..e182da4 --- /dev/null +++ b/go/internal/orch/budget.go @@ -0,0 +1,270 @@ +package orch + +import ( + "context" + "errors" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/agentfield/sdk/go/harness" + + proveagent "github.com/Agent-Field/sec-af/go/internal/agents/prove" + "github.com/Agent-Field/sec-af/go/internal/appx" +) + +// BudgetExhaustedError ports `class BudgetExhausted(RuntimeError)` +// (orchestrator.py:35) — the sentinel the phase proxy raises when a cost or +// duration budget is used up. +type BudgetExhaustedError struct{ Message string } + +func (e *BudgetExhaustedError) Error() string { return e.Message } + +// IsBudgetExhausted reports whether err is (or wraps) a BudgetExhaustedError, +// which is Go's `except BudgetExhausted`. +func IsBudgetExhausted(err error) bool { + var target *BudgetExhaustedError + return errors.As(err, &target) +} + +// checkTimeBudget ports `_check_time_budget`: +// +// if self.max_duration_seconds is None: return +// elapsed = time.monotonic() - self.started_at +// if elapsed > self.max_duration_seconds: +// self.budget_exhausted = True +// raise BudgetExhausted("Duration budget exhausted") +// +// Python parity: the comparison is STRICTLY greater, so an elapsed time exactly +// equal to the limit does not trip it — the opposite of the cost checks, which +// use >=. +func (o *AuditOrchestrator) checkTimeBudget() error { + if o.MaxDurationSeconds == nil { + return nil + } + elapsed := o.elapsedSeconds() + if elapsed > float64(*o.MaxDurationSeconds) { + o.markBudgetExhausted() + return &BudgetExhaustedError{Message: "Duration budget exhausted"} + } + return nil +} + +// phaseBudgetLimit ports `_phase_budget_limit(phase)`: +// +// if self.max_cost_usd is None: return None +// weights = {"recon": recon_budget_pct, "hunt": hunt_budget_pct, "prove": prove_budget_pct} +// return self.max_cost_usd * weights[phase] +// +// The bool result is Python's "not None". Python parity: `weights[phase]` is a +// dict subscript, so an unknown phase name raises KeyError — a programming +// error, not a budget outcome. The Go port reports "no limit" for an unknown +// phase instead of panicking; every call site passes one of the three PhaseOrder +// constants, so the branch is unreachable. +func (o *AuditOrchestrator) phaseBudgetLimit(phase string) (float64, bool) { + if o.MaxCostUSD == nil { + return 0, false + } + var weight float64 + switch phase { + case PhaseRecon: + weight = o.BudgetConfig.ReconBudgetPct + case PhaseHunt: + weight = o.BudgetConfig.HuntBudgetPct + case PhaseProve: + weight = o.BudgetConfig.ProveBudgetPct + default: + return 0, false + } + return *o.MaxCostUSD * weight, true +} + +// checkCostBudget ports `_check_cost_budget(phase)`: +// +// if self.max_cost_usd is not None and self.total_cost_usd >= self.max_cost_usd: +// self.budget_exhausted = True; raise BudgetExhausted("Total budget exhausted") +// phase_limit = self._phase_budget_limit(phase) +// if phase_limit is not None and self.cost_breakdown[phase] >= phase_limit: +// self.budget_exhausted = True; raise BudgetExhausted(f"{phase} budget exhausted") +// +// Both comparisons are >=, so a phase that has spent EXACTLY its allowance is +// already exhausted. +func (o *AuditOrchestrator) checkCostBudget(phase string) error { + o.mu.Lock() + total := o.totalCostUSD + spent := o.costBreakdown[phase] + o.mu.Unlock() + + if o.MaxCostUSD != nil && total >= *o.MaxCostUSD { + o.markBudgetExhausted() + return &BudgetExhaustedError{Message: "Total budget exhausted"} + } + if limit, ok := o.phaseBudgetLimit(phase); ok && spent >= limit { + o.markBudgetExhausted() + return &BudgetExhaustedError{Message: phase + " budget exhausted"} + } + return nil +} + +// budgetOrTimeoutExhausted ports `_budget_or_timeout_exhausted(phase)`: +// +// try: +// self._check_time_budget(); self._check_cost_budget(phase); return False +// except BudgetExhausted: +// return True +// +// Note the side effect the boolean hides: both checks LATCH budget_exhausted +// before raising, so a caller that only reads the bool still sees the flag set. +func (o *AuditOrchestrator) budgetOrTimeoutExhausted(phase string) bool { + if err := o.checkTimeBudget(); err != nil { + return true + } + if err := o.checkCostBudget(phase); err != nil { + return true + } + return false +} + +func (o *AuditOrchestrator) markBudgetExhausted() { + o.mu.Lock() + o.budgetExhausted = true + o.mu.Unlock() +} + +// registerCost ports `_register_cost(phase, cost_usd)`: +// +// if cost_usd is None or cost_usd < 0: return +// self.total_cost_usd += cost_usd +// self.cost_breakdown[phase] += cost_usd +// +// Python parity: a cost of exactly 0.0 IS registered (the guard is `< 0`, not +// `<= 0`), which matters only for the invocation-shaped side effects — there +// are none — but is reproduced. `self.cost_breakdown[phase] += cost` raises +// KeyError for an unknown phase in Python; the Go map simply grows a fourth +// bucket, which GenerateOutput would then report. Unreachable for the same +// reason as phaseBudgetLimit's default branch. +func (o *AuditOrchestrator) registerCost(phase string, costUSD *float64) { + if costUSD == nil || *costUSD < 0 { + return + } + o.mu.Lock() + o.totalCostUSD += *costUSD + o.costBreakdown[phase] += *costUSD + o.mu.Unlock() +} + +// registerInvocation ports `self.agent_invocations += 1`. +func (o *AuditOrchestrator) registerInvocation() { + o.mu.Lock() + o.agentInvocations++ + o.mu.Unlock() +} + +// --------------------------------------------------------------------------- +// _PhaseHarnessProxy +// --------------------------------------------------------------------------- + +// phaseProxy ports `_PhaseHarnessProxy` (orchestrator.py:39) — the App the +// orchestrator hands to every in-process phase so that each harness call is +// budget-checked, counted and costed against that phase's bucket: +// +// class _PhaseHarnessProxy: +// def __init__(self, orchestrator, phase): ... +// async def harness(self, prompt, *, schema=None, cwd=None, **kwargs): +// if self._orchestrator._budget_or_timeout_exhausted(self._phase): +// raise BudgetExhausted(f"{self._phase} budget exhausted") +// result = await self._orchestrator.app.harness(prompt, schema=schema, cwd=cwd, **kwargs) +// self._orchestrator.agent_invocations += 1 +// self._orchestrator._register_cost(self._phase, getattr(result, "cost_usd", None)) +// return result +// +// CAPABILITY SURFACE IS LOAD-BEARING. The Python class defines `harness` and +// NOTHING ELSE — no `ai`, no `note`, no `call`, and no `__getattr__` (VERIFIED +// on the repo interpreter: hasattr(proxy, "ai") is False). Two live behaviours +// depend on that, both reachable from `audit(resume_from_checkpoint=...)` +// (app.py:169 -> run_from_checkpoint -> _run_hunt / _run_prove): +// +// 1. HUNT dedup. agents/dedup.py:150 gates its semantic duplicate pass on +// `has_ai = hasattr(app, "ai") and callable(...)`, which is FALSE for this +// proxy, so the pairwise `.ai(DuplicateCheck)` fan-out never runs and no +// finding is dropped by it. phaseProxy therefore does NOT implement +// appx.AIer, so internal/agents/dedup's `app.(appx.AIer)` probe fails the +// same way. +// 2. PROVE verdict. agents/prove/verdict.py:99 calls `await app.ai(...)` with +// no guard, raising `AttributeError: '_PhaseHarnessProxy' object has no +// attribute 'ai'`; agents/prove/__init__.py's `except BaseException` turns +// that into `verifier_fallback(..., drop_reason="verifier_error")` for +// EVERY finding. Go's run_prove signature needs an AIer, so proveProxy +// (below) supplies one that returns exactly that AttributeError. +// +// Python parity: `note` and `call` are absent for the same reason, and no phase +// the orchestrator drives in process reaches for either (verified by grep over +// src/sec_af/agents/**), so nothing needs a stand-in for them. +type phaseProxy struct { + orch *AuditOrchestrator + phase string +} + +var _ appx.Harnesser = (*phaseProxy)(nil) + +// AttributeError reproduces the CPython AttributeError a phase gets when it +// reaches for an attribute `_PhaseHarnessProxy` does not define. The message is +// byte-identical to CPython's, which matters: agents/prove's demotion classifier +// (`demoteOnError`) inspects `str(exc)` for "unverified"/"verdict"/ +// "validationerror", and this text matches none of them — so a verdict call +// through the proxy demotes with drop_reason "verifier_error", exactly as it +// does in Python. +type AttributeError struct{ Attr string } + +func (e *AttributeError) Error() string { + return "'_PhaseHarnessProxy' object has no attribute '" + e.Attr + "'" +} + +// PhaseProxy exposes the budget-checking wrapper for one phase name +// ("recon", "hunt" or "prove"). It is exported so a caller outside this package +// can drive a phase with the same accounting the orchestrator applies. +// +// The return type is appx.Harnesser, not appx.App: see the phaseProxy doc. +func (o *AuditOrchestrator) PhaseProxy(phase string) appx.Harnesser { + return &phaseProxy{orch: o, phase: phase} +} + +// proveProxy is `_PhaseHarnessProxy(self, "prove")` handed to run_prove, whose +// Go signature (proveagent.HarnessAIer) requires an `AI` method that Python's +// duck-typed call site does not. Every call returns the AttributeError Python +// raises, so the observable outcome — a demoted, INCONCLUSIVE finding with +// drop_reason "verifier_error" — is identical. +type proveProxy struct{ phaseProxy } + +var _ proveagent.HarnessAIer = (*proveProxy)(nil) + +// AI reproduces `_PhaseHarnessProxy.ai` NOT EXISTING. +func (p *proveProxy) AI(context.Context, string, ...ai.Option) (*ai.Response, error) { + return nil, &AttributeError{Attr: "ai"} +} + +// ProvePhaseProxy is PhaseProxy(PhaseProve) with the AI seam described above. +func (o *AuditOrchestrator) ProvePhaseProxy() proveagent.HarnessAIer { + return &proveProxy{phaseProxy{orch: o, phase: PhaseProve}} +} + +// Harness implements appx.Harnesser with the budget guard, the invocation +// counter and the cost registration, in Python's order. +func (p *phaseProxy) Harness(ctx context.Context, prompt string, schema map[string]any, dest any, opts harness.Options) (*harness.Result, error) { + if p.orch.budgetOrTimeoutExhausted(p.phase) { + // Python raises the SAME message whichever check tripped. + return nil, &BudgetExhaustedError{Message: p.phase + " budget exhausted"} + } + res, err := p.orch.App.Harness(ctx, prompt, schema, dest, opts) + if err != nil { + // Python: the `await` raised, so neither the counter nor the cost + // registration below runs. + return res, err + } + p.orch.registerInvocation() + if res != nil { + p.orch.registerCost(p.phase, res.CostUSD) + } else { + // Python: getattr(None, "cost_usd", None) is None -> no-op. + p.orch.registerCost(p.phase, nil) + } + return res, nil +} diff --git a/go/internal/orch/checkpoint.go b/go/internal/orch/checkpoint.go new file mode 100644 index 0000000..4bda7e4 --- /dev/null +++ b/go/internal/orch/checkpoint.go @@ -0,0 +1,184 @@ +package orch + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/Agent-Field/sec-af/go/internal/phases" + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// nowUTC is `datetime.now(UTC)`, as a variable so a test (and +// scripts/gen_golden.py's Python counterpart) can pin the checkpoint's +// created_at. Production never reassigns it. +var nowUTC = func() time.Time { return time.Now().UTC() } + +// checkpointDirPerm / checkpointFilePerm. +// +// Python parity: `Path.mkdir(parents=True, exist_ok=True)` uses mode 0o777 +// masked by the umask, and `Path.write_text` creates with 0o666 masked by the +// umask. Under the usual 022 umask those land on 0755 / 0644, which is what Go +// asks for directly — Go's os package does not apply an implicit mode. +const ( + checkpointDirPerm os.FileMode = 0o755 + checkpointFilePerm os.FileMode = 0o644 +) + +// CheckpointPath ports `_checkpoint_path(phase)`: +// +// return self.checkpoint_dir / f"checkpoint-{phase}.json" +func (o *AuditOrchestrator) CheckpointPath(phase string) string { + return filepath.Join(o.CheckpointDir, "checkpoint-"+phase+".json") +} + +// WriteCheckpoint ports `_write_checkpoint(phase, payload)`: +// +// self.checkpoint_dir.mkdir(parents=True, exist_ok=True) +// path = self._checkpoint_path(phase) +// data = [item.model_dump() for item in payload] if isinstance(payload, list) else payload.model_dump() +// body = {"phase": phase, "created_at": datetime.now(UTC).isoformat(), "data": data} +// path.write_text(json.dumps(body, indent=2), encoding="utf-8") +// +// The FILE BYTES are the contract — a Go node must be able to resume from a +// checkpoint a Python node wrote and vice versa — so the body is rendered with +// pyfmt.Dumps (json.dumps parity: `", "`/`": "` separators become `,`/`": "` +// under indent, floats keep Python's repr, non-ASCII is \uXXXX-escaped and +// `<>&` are NOT escaped) over an INSERTION-ORDERED object, because a Go map +// would sort "created_at" before "data" and "phase" while Python emits them in +// the literal's order. +// +// Python parity: +// +// - `created_at` is a plain STRING produced by `datetime.now(UTC).isoformat()` +// — `2026-01-02T03:04:05.123456+00:00`, with the fraction omitted when the +// microseconds are zero. schemas.Timestamp.String() is that exact spelling. +// - the isinstance(list) branch is only about calling model_dump per element; +// pyfmt.Dumps renders a slice of structs as an array of objects with the +// same field order, so one code path covers both. +// - Python raises on an I/O failure; Go returns the error, and every caller +// propagates it. +// - no trailing newline is written (`json.dumps` produces none, and +// `write_text` adds none). +func (o *AuditOrchestrator) WriteCheckpoint(phase string, payload any) error { + if err := os.MkdirAll(o.CheckpointDir, checkpointDirPerm); err != nil { + return fmt.Errorf("orch: create checkpoint dir: %w", err) + } + body := pyfmt.O( + "phase", phase, + "created_at", schemas.NewTimestamp(nowUTC()).String(), + "data", payload, + ) + path := o.CheckpointPath(phase) + if err := os.WriteFile(path, []byte(pyfmt.Dumps(body, 2)), checkpointFilePerm); err != nil { + return fmt.Errorf("orch: write checkpoint %s: %w", path, err) + } + return nil +} + +// readCheckpointBody decodes the `{"phase", "created_at", "data"}` envelope. +func (o *AuditOrchestrator) readCheckpointBody(phase string) (map[string]json.RawMessage, error) { + raw, err := os.ReadFile(o.CheckpointPath(phase)) + if err != nil { + return nil, err + } + var body map[string]json.RawMessage + if err := json.Unmarshal(raw, &body); err != nil { + return nil, fmt.Errorf("orch: decode checkpoint %s: %w", phase, err) + } + return body, nil +} + +// ReadCheckpoint ports `_read_checkpoint(phase, schema)`: +// +// payload = json.loads(path.read_text(encoding="utf-8")) +// return schema(**payload.get("data", {})) +// +// bind is the `schema(**data)` half: one of internal/phases' checked binders, +// so a hand-edited or stale checkpoint fails HERE — with a ValueError-class +// error the audit handler maps to HTTP 400 — instead of quietly binding to +// default-seeded values. Without it, a checkpoint whose findings carry an +// out-of-vocabulary severity resumed cleanly in Go and raised ValidationError +// in Python. +// +// Python parity: a checkpoint whose "data" key is MISSING falls back to `{}`, +// which for a model with required fields raises and for one with all-defaults +// yields the defaults. Passing `{}` to the binder reproduces both. +func ReadCheckpoint[T any](o *AuditOrchestrator, phase string, bind func(map[string]any) (T, error)) (T, error) { + var out T + body, err := o.readCheckpointBody(phase) + if err != nil { + return out, err + } + data, ok := body["data"] + if !ok || len(data) == 0 { + data = json.RawMessage("{}") + } + row := map[string]any{} + if err := json.Unmarshal(data, &row); err != nil { + return out, fmt.Errorf("orch: decode checkpoint %s data: %w", phase, err) + } + return bind(row) +} + +// ReadCheckpointList ports `_read_checkpoint_list(phase, schema)`: +// +// rows = payload.get("data", []) +// return [schema(**row) for row in rows] +// +// Same story as ReadCheckpoint: `schema(**row)` validates every element, so the +// binder runs per row and the first failure propagates. +func ReadCheckpointList[T any](o *AuditOrchestrator, phase string, bind func(map[string]any) (T, error)) ([]T, error) { + body, err := o.readCheckpointBody(phase) + if err != nil { + return nil, err + } + data, ok := body["data"] + if !ok || len(data) == 0 { + return []T{}, nil + } + var rows []map[string]any + if err := json.Unmarshal(data, &rows); err != nil { + return nil, fmt.Errorf("orch: decode checkpoint %s data: %w", phase, err) + } + out := make([]T, 0, len(rows)) + for _, row := range rows { + item, err := bind(row) + if err != nil { + return nil, err + } + out = append(out, item) + } + return out, nil +} + +// TryLoadCachedRecon ports `_try_load_cached_recon()`: +// +// try: +// return self._read_checkpoint("recon", ReconResult) +// except (FileNotFoundError, Exception): +// return None +// +// Python parity: the except tuple is redundant — `Exception` already covers +// FileNotFoundError — so EVERY failure (missing file, malformed JSON, failed +// validation) yields None. A nil pointer is that None. +func (o *AuditOrchestrator) TryLoadCachedRecon() *schemas.ReconResult { + recon, err := ReadCheckpoint(o, PhaseRecon, phases.BindReconResult) + if err != nil { + return nil + } + return &recon +} + +// mkdirCheckpointDir is `self.checkpoint_dir.mkdir(parents=True, exist_ok=True)`, +// which _generate_output performs before writing the per-framework compliance +// reports. +func mkdirCheckpointDir(dir string) error { + if err := os.MkdirAll(dir, checkpointDirPerm); err != nil { + return fmt.Errorf("orch: create checkpoint dir: %w", err) + } + return nil +} diff --git a/go/internal/orch/doc.go b/go/internal/orch/doc.go new file mode 100644 index 0000000..df9a2da --- /dev/null +++ b/go/internal/orch/doc.go @@ -0,0 +1,53 @@ +// Package orch ports src/sec_af/orchestrator.py — the AuditOrchestrator class, +// its budget/cost bookkeeping, its checkpoint format, and the output-generation +// step every entry point funnels through. +// +// # Two paths through this package +// +// The class has TWO ways of driving an audit, and only one of them is reached +// by the live REST API: +// +// - Run (`AuditOrchestrator.run`) is the STREAMING, in-process pipeline: +// fast recon, then deep recon / hunt / prove concurrently, with hunt +// publishing findings to prove through a queue. Every sub-agent is a Go +// function call, so the control plane sees ONE execution. app.py does not +// use it — it issues four `.call`s into internal/phases instead — but +// RunFromCheckpoint shares its `_run_hunt` / `_run_prove` halves, so the +// whole thing is ported. +// - GenerateOutput is the shared tail: severity filtering, the CWE severity +// floor, exploitability scoring, compliance mapping, the counters, the +// SARIF/JSON/Markdown artifacts. Both paths end here, and app.py calls it +// directly after its four `.call`s. +// +// # What the node wiring must know +// +// app.py constructs the orchestrator and then OVERWRITES two fields: +// +// orchestrator = AuditOrchestrator(app=app, input=audit_input) +// repo_path = _resolve_repo(repo_url) +// orchestrator.repo_path = Path(repo_path) +// orchestrator.checkpoint_dir = orchestrator.repo_path / ".sec-af" +// +// so New computes its own repo path from SEC_AF_REPO_PATH/cwd (Python parity — +// including running the PR-mode diff analysis against THAT path, before the +// override) and RepoPath/CheckpointDir are exported for the caller to replace. +// SetRepoPath does both assignments in one call. +// +// app.py likewise writes agent_invocations, findings_not_verified and +// prove_drop_summary from the phase results before calling GenerateOutput. +// FindingsNotVerified and ProveDropSummary are exported fields for the same +// reason; the invocation counter is mutex-guarded (see below), so it is written +// through SetAgentInvocations instead. +// +// # Concurrency +// +// Python's orchestrator is single-threaded asyncio, so its counters need no +// locking. The Go port fans phases out across goroutines and the phase proxy is +// shared by all of them, so the cost/invocation bookkeeping is mutex-guarded. +// All four guarded values are unexported and reachable only through +// TotalCostUSD / CostBreakdown / AgentInvocations / SetAgentInvocations / +// BudgetExhausted, so the lock contract cannot be bypassed by a later caller +// that reads a partial result while a phase is still running. +// That is the only structural addition; every value it computes is the value +// Python computes. +package orch diff --git a/go/internal/orch/golden_test.go b/go/internal/orch/golden_test.go new file mode 100644 index 0000000..575e550 --- /dev/null +++ b/go/internal/orch/golden_test.go @@ -0,0 +1,470 @@ +package orch + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strconv" + "testing" + "time" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// The goldens in testdata/golden are produced by +// +// PYTHONPATH=src ~/.agentfield/packages/sec-af/venv/bin/python go/scripts/gen_golden_phases.py +// +// by calling the REAL orchestrator.py functions with the fixtures in +// testdata/*.json, with the clock stubbed exactly where this package exposes a +// seam (nowUTC for the checkpoint timestamp, nowMonotonic for the elapsed +// times). Everything the two runtimes must agree on byte-for-byte is compared +// here. + +// --------------------------------------------------------------------------- +// fixtures +// --------------------------------------------------------------------------- + +func readFile(t *testing.T, rel string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", filepath.FromSlash(rel))) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + return string(b) +} + +func readJSON[T any](t *testing.T, rel string) T { + t.Helper() + var out T + if err := json.Unmarshal([]byte(readFile(t, rel)), &out); err != nil { + t.Fatalf("decode %s: %v", rel, err) + } + return out +} + +func reconFixture(t *testing.T, name string) schemas.ReconResult { + t.Helper() + fixtures := readJSON[map[string]json.RawMessage](t, "recon_fixture.json") + raw, ok := fixtures[name] + if !ok { + t.Fatalf("recon_fixture.json has no %q", name) + } + var recon schemas.ReconResult + if err := json.Unmarshal(raw, &recon); err != nil { + t.Fatalf("decode recon fixture %q: %v", name, err) + } + return recon +} + +func findingsFixture(t *testing.T) []schemas.RawFinding { + t.Helper() + return readJSON[[]schemas.RawFinding](t, "findings_fixture.json") +} + +// newTestOrchestrator builds an orchestrator whose repo/checkpoint paths point +// at a fresh temp dir. +// +// Python parity note reproduced by the caller, not by New: AuditConfig.from_input +// uses the STRICT DepthProfile constructor, so a non-canonical depth cannot +// survive construction. Tests that need one assign Input.Depth afterwards, +// exactly as scripts/gen_golden_phases.py does. +func newTestOrchestrator(t *testing.T, mutate ...func(*schemas.AuditInput)) (*AuditOrchestrator, *appx.Fake) { + t.Helper() + input := schemas.NewAuditInput() + input.RepoURL = "https://example.invalid/repo" + input.Depth = "standard" + for _, fn := range mutate { + fn(&input) + } + fake := &appx.Fake{} + o, err := New(fake, input) + if err != nil { + t.Fatalf("orch.New: %v", err) + } + o.SetRepoPath(t.TempDir()) + return o, fake +} + +func splitKey(t *testing.T, key string) (left, right string) { + t.Helper() + for i := 0; i < len(key); i++ { + if key[i] == '|' { + return key[:i], key[i+1:] + } + } + t.Fatalf("malformed golden key %q", key) + return "", "" +} + +func strategyNames(strategies []schemas.HuntStrategy) []string { + out := make([]string, 0, len(strategies)) + for _, s := range strategies { + out = append(out, string(s)) + } + return out +} + +func normalizeJSON(t *testing.T, v any) any { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out any + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return out +} + +// --------------------------------------------------------------------------- +// pure-function goldens +// --------------------------------------------------------------------------- + +// TestDefaultStrategies_Golden pins the ORCHESTRATOR's variant for every +// (fixture, depth) pair — including the thorough-only python_specific / +// javascript_specific additions the phases variant never makes. +func TestDefaultStrategies_Golden(t *testing.T) { + want := readJSON[map[string][]string](t, "golden/default_strategies.json") + if len(want) == 0 { + t.Fatal("default_strategies.json is empty") + } + for key, expected := range want { + reconName, depth := splitKey(t, key) + o, _ := newTestOrchestrator(t) + o.Input.Depth = depth + got := strategyNames(o.DefaultStrategies(reconFixture(t, reconName))) + if !reflect.DeepEqual(got, expected) { + t.Errorf("DefaultStrategies(%s) at depth %q\n got: %v\nwant: %v", reconName, depth, got, expected) + } + } +} + +// TestDefaultStrategies_DiffersFromPhases guards the two documented +// differences: no XSS ever, and the language-specific pair only at thorough. +func TestDefaultStrategies_DiffersFromPhases(t *testing.T) { + recon := reconFixture(t, "full") // languages: python, typescript + + for _, depth := range []string{"quick", "standard", "thorough"} { + o, _ := newTestOrchestrator(t) + o.Input.Depth = depth + names := strategyNames(o.DefaultStrategies(recon)) + for _, n := range names { + if n == string(schemas.HuntStrategyXSS) { + t.Errorf("depth %q: the orchestrator variant must never add xss (%v)", depth, names) + } + } + hasPython := contains(names, string(schemas.HuntStrategyPythonSpecific)) + hasJS := contains(names, string(schemas.HuntStrategyJavascriptSpecific)) + if depth == "thorough" { + if !hasPython || !hasJS { + t.Errorf("thorough must add both language strategies, got %v", names) + } + } else if hasPython || hasJS { + t.Errorf("depth %q must not add the language strategies, got %v", depth, names) + } + } +} + +func contains(list []string, want string) bool { + for _, v := range list { + if v == want { + return true + } + } + return false +} + +// TestProverCap_Golden pins `_prover_cap()`. +func TestProverCap_Golden(t *testing.T) { + want := readJSON[map[string]int](t, "golden/prover_cap.json") + if len(want) == 0 { + t.Fatal("prover_cap.json is empty") + } + for key, expected := range want { + depth, capSpec := splitKey(t, key) + o, _ := newTestOrchestrator(t, func(in *schemas.AuditInput) { + if capSpec != "null" { + v, err := strconv.Atoi(capSpec) + if err != nil { + t.Fatalf("golden key %q: %v", key, err) + } + in.MaxProvers = &v + } + }) + o.Input.Depth = depth + if got := o.ProverCap(); got != expected { + t.Errorf("ProverCap() at depth %q max_provers %s = %d, want %d", depth, capSpec, got, expected) + } + } +} + +// TestPrioritizeFindings_Golden pins the sort order and the stable tie-break. +func TestPrioritizeFindings_Golden(t *testing.T) { + want := readJSON[[]string](t, "golden/prioritize_findings.json") + o, _ := newTestOrchestrator(t) + findings := findingsFixture(t) + got := make([]string, 0, len(want)) + for _, f := range o.PrioritizeFindings(findings) { + got = append(got, f.ID) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("PrioritizeFindings order\n got: %v\nwant: %v", got, want) + } + if findings[0].ID != "low-high" { + t.Errorf("the input slice was reordered: findings[0] = %q", findings[0].ID) + } +} + +// TestVerifiedFindingFallback_Golden compares the whole model_dump against +// Python's — including the "sec-af//" rule id that +// differs from agents/prove.Fallback's. +func TestVerifiedFindingFallback_Golden(t *testing.T) { + finding := readJSON[schemas.RawFinding](t, "fallback_finding.json") + want := readJSON[any](t, "golden/verified_finding_fallback.json") + got := normalizeJSON(t, VerifiedFindingFallback(finding)) + if !reflect.DeepEqual(got, want) { + gotJSON, _ := json.MarshalIndent(got, "", " ") + wantJSON, _ := json.MarshalIndent(want, "", " ") + t.Errorf("VerifiedFindingFallback\n got: %s\nwant: %s", gotJSON, wantJSON) + } +} + +// TestMergeReconFindingsIntoHunt_Golden ports +// tests/test_recon_findings.py::test_merge_recon_findings_prepends_and_updates_counts +// and compares the whole merged HuntResult with Python's. +func TestMergeReconFindingsIntoHunt_Golden(t *testing.T) { + type goldenShape struct { + Merged json.RawMessage `json:"merged"` + EmptyReconIsIdentity bool `json:"empty_recon_is_identity"` + AlreadyPresentStrategy []string `json:"already_present"` + } + golden := readJSON[goldenShape](t, "golden/merge_recon_findings.json") + + findings := findingsFixture(t) + hunt := schemas.NewHuntResult() + hunt.Findings = []schemas.RawFinding{findings[0]} + hunt.TotalRaw = 1 + hunt.DeduplicatedCount = 1 + hunt.StrategiesRun = []string{"injection"} + reconFindings := []schemas.RawFinding{findings[2]} + + merged := MergeReconFindingsIntoHunt(hunt, reconFindings) + + // The Python test's own four assertions. + if len(merged.Findings) != 2 { + t.Fatalf("merged findings = %d, want 2", len(merged.Findings)) + } + if merged.Findings[0].HunterStrategy != findings[2].HunterStrategy { + t.Errorf("findings[0] must be the recon finding, got %q", merged.Findings[0].HunterStrategy) + } + if merged.TotalRaw != 2 { + t.Errorf("total_raw = %d, want 2", merged.TotalRaw) + } + if merged.DeduplicatedCount != 2 { + t.Errorf("deduplicated_count = %d, want 2", merged.DeduplicatedCount) + } + if merged.StrategiesRun[0] != "recon" { + t.Errorf("strategies_run[0] = %q, want recon", merged.StrategiesRun[0]) + } + + var wantMerged any + if err := json.Unmarshal(golden.Merged, &wantMerged); err != nil { + t.Fatalf("decode merged golden: %v", err) + } + if got := normalizeJSON(t, merged); !reflect.DeepEqual(got, wantMerged) { + gotJSON, _ := json.MarshalIndent(got, "", " ") + wantJSON, _ := json.MarshalIndent(wantMerged, "", " ") + t.Errorf("merged HuntResult\n got: %s\nwant: %s", gotJSON, wantJSON) + } + + // Empty recon findings: the hunt result comes back untouched. + if !golden.EmptyReconIsIdentity { + t.Fatal("golden says the empty-recon case is not the identity; the Python source says it is") + } + identity := MergeReconFindingsIntoHunt(hunt, nil) + if !reflect.DeepEqual(normalizeJSON(t, identity), normalizeJSON(t, hunt)) { + t.Error("MergeReconFindingsIntoHunt(hunt, nil) must return the hunt result unchanged") + } + + // "recon" already present: no second insertion. + pre := schemas.NewHuntResult() + pre.StrategiesRun = []string{"recon", "injection"} + if got := MergeReconFindingsIntoHunt(pre, reconFindings).StrategiesRun; !reflect.DeepEqual(got, golden.AlreadyPresentStrategy) { + t.Errorf("strategies_run = %v, want %v", got, golden.AlreadyPresentStrategy) + } +} + +// --------------------------------------------------------------------------- +// prompts and notes +// --------------------------------------------------------------------------- + +// TestReachabilitySummary_Golden pins the prompt body handed to +// AIGateWrapper.assess_reachability. +func TestReachabilitySummary_Golden(t *testing.T) { + finding := readJSON[schemas.VerifiedFinding](t, "verified_fixture.json") + want := readFile(t, "golden/reachability_summary.txt") + if got := ReachabilitySummary(finding); got != want { + t.Errorf("ReachabilitySummary\n got: %q\nwant: %q", got, want) + } +} + +// TestBuildProgress_And_EmitProgress_Golden pins BOTH the arithmetic +// (progress_fields.json) and the exact note text +// (progress_notes.json — pydantic's model_dump_json, no whitespace, floats with +// a decimal point). +func TestBuildProgress_And_EmitProgress_Golden(t *testing.T) { + type progressCase struct { + phase string + agentsTotal int + agentsCompleted int + findingsSoFar int + } + cases := map[string]progressCase{ + "recon_half": {"recon", 2, 1, 0}, + "recon_full": {"recon", 2, 2, 0}, + "hunt_done": {"hunt", 1, 1, 7}, + "zero_total": {"prove", 0, 0, 0}, + "overshoot": {"prove", 2, 5, 3}, + } + + wantNotes := readJSON[map[string]string](t, "golden/progress_notes.json") + wantFields := readJSON[map[string]map[string]any](t, "golden/progress_fields.json") + if len(wantNotes) != len(cases) { + t.Fatalf("golden has %d cases, the test has %d", len(wantNotes), len(cases)) + } + + // Pin the clock: the generator scripted monotonic() as 100.0 then 102.5, so + // every elapsed read is 2.5s. + base := time.Now() + restore := nowMonotonic + nowMonotonic = func() time.Time { return base.Add(2500 * time.Millisecond) } + defer func() { nowMonotonic = restore }() + + for name, tc := range cases { + name, tc := name, tc + t.Run(name, func(t *testing.T) { + o, fake := newTestOrchestrator(t) + o.StartedAt = base + // The generator set total_cost_usd = 0.123456; the note carries + // round(x, 4) = 0.1235 (banker's rounding). + o.registerCost(PhaseRecon, floatPtr(0.123456)) + + o.EmitProgress(context.Background(), tc.phase, tc.agentsTotal, tc.agentsCompleted, tc.findingsSoFar) + + if len(fake.Notes) != 1 { + t.Fatalf("notes = %d, want 1", len(fake.Notes)) + } + if got := fake.Notes[0].Message; got != wantNotes[name] { + t.Errorf("note message\n got: %s\nwant: %s", got, wantNotes[name]) + } + if want := []string{"audit", "progress", tc.phase}; !reflect.DeepEqual(fake.Notes[0].Tags, want) { + t.Errorf("tags = %v, want %v", fake.Notes[0].Tags, want) + } + + // And the structured fields, so a formatting change cannot hide an + // arithmetic change. + got := normalizeJSON(t, o.BuildProgress(tc.phase, tc.agentsTotal, tc.agentsCompleted, tc.findingsSoFar)) + if !reflect.DeepEqual(got, any(wantFields[name])) { + t.Errorf("progress fields\n got: %#v\nwant: %#v", got, wantFields[name]) + } + }) + } +} + +// TestProgressModelDumpJSON_Golden pins pyfmt.DumpsModelJSON against pydantic's +// serializer independently of the orchestrator's arithmetic. +func TestProgressModelDumpJSON_Golden(t *testing.T) { + want := readJSON[map[string]string](t, "golden/progress_model_dump_json.json") + + cases := map[string]schemas.AuditProgress{ + "unit_progress": { + Phase: "recon", PhaseProgress: 1.0, AgentsTotal: 2, AgentsCompleted: 2, + AgentsRunning: 0, FindingsSoFar: 0, ElapsedSeconds: 2.5, + EstimatedRemainingSeconds: 0.0, CostSoFarUsd: 0.0, + }, + "fractional": { + Phase: "hunt", PhaseProgress: 0.5, AgentsTotal: 4, AgentsCompleted: 2, + AgentsRunning: 2, FindingsSoFar: 13, ElapsedSeconds: 1.25, + EstimatedRemainingSeconds: 1.25, CostSoFarUsd: 0.1235, + }, + } + for name, progress := range cases { + if got := pyfmt.DumpsModelJSON(progress); got != want[name] { + t.Errorf("%s\n got: %s\nwant: %s", name, got, want[name]) + } + } +} + +func floatPtr(f float64) *float64 { return &f } + +// --------------------------------------------------------------------------- +// checkpoints +// --------------------------------------------------------------------------- + +// TestWriteCheckpoint_Golden compares the FILE BYTES with the ones Python +// wrote for the same payload and the same pinned clock. The bytes are the +// cross-runtime contract: a Go node must be able to resume from a Python +// node's checkpoint and vice versa. +func TestWriteCheckpoint_Golden(t *testing.T) { + pinned, err := time.Parse(time.RFC3339Nano, "2026-01-02T03:04:05.123456Z") + if err != nil { + t.Fatalf("parse pinned time: %v", err) + } + restore := nowUTC + nowUTC = func() time.Time { return pinned.UTC() } + defer func() { nowUTC = restore }() + + // The pinned isoformat the generator recorded. + created := readJSON[map[string]string](t, "golden/checkpoint_created_at.json") + if got := schemas.NewTimestamp(nowUTC()).String(); got != created["pinned"] { + t.Fatalf("created_at = %q, want %q", got, created["pinned"]) + } + + o, _ := newTestOrchestrator(t) + + t.Run("model payload", func(t *testing.T) { + if err := o.WriteCheckpoint("recon", reconFixture(t, "minimal")); err != nil { + t.Fatalf("WriteCheckpoint: %v", err) + } + got, err := os.ReadFile(o.CheckpointPath("recon")) + if err != nil { + t.Fatalf("read checkpoint: %v", err) + } + if want := readFile(t, "golden/checkpoint_recon.txt"); string(got) != want { + t.Errorf("checkpoint-recon.json\n got: %s\nwant: %s", got, want) + } + }) + + t.Run("list payload", func(t *testing.T) { + verified := []schemas.VerifiedFinding{readJSON[schemas.VerifiedFinding](t, "verified_fixture.json")} + if err := o.WriteCheckpoint("prove", verified); err != nil { + t.Fatalf("WriteCheckpoint: %v", err) + } + got, err := os.ReadFile(o.CheckpointPath("prove")) + if err != nil { + t.Fatalf("read checkpoint: %v", err) + } + if want := readFile(t, "golden/checkpoint_prove.txt"); string(got) != want { + t.Errorf("checkpoint-prove.json\n got: %s\nwant: %s", got, want) + } + }) + + t.Run("empty list payload", func(t *testing.T) { + if err := o.WriteCheckpoint("prove_empty", []schemas.VerifiedFinding{}); err != nil { + t.Fatalf("WriteCheckpoint: %v", err) + } + got, err := os.ReadFile(o.CheckpointPath("prove_empty")) + if err != nil { + t.Fatalf("read checkpoint: %v", err) + } + if want := readFile(t, "golden/checkpoint_prove_empty.txt"); string(got) != want { + t.Errorf("checkpoint-prove_empty.json\n got: %s\nwant: %s", got, want) + } + }) +} diff --git a/go/internal/orch/merge.go b/go/internal/orch/merge.go new file mode 100644 index 0000000..fb03312 --- /dev/null +++ b/go/internal/orch/merge.go @@ -0,0 +1,152 @@ +package orch + +import ( + "sort" + "strings" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// VerifiedFindingFallback ports the module-level `_verified_finding_fallback` +// (orchestrator.py:672) — a RawFinding promoted to an INCONCLUSIVE +// VerifiedFinding when no proof is available. +// +// VerifiedFinding( +// id=finding.id, fingerprint=finding.fingerprint, title=..., description=..., +// finding_type=..., cwe_id=..., cwe_name=..., owasp_category=..., +// tags=[], verdict=Verdict.INCONCLUSIVE, evidence_level=EvidenceLevel.STATIC_MATCH, +// rationale="Automated proof unavailable; requires manual review.", +// severity=finding.estimated_severity, exploitability_score=0.0, +// location=Location(file_path=..., start_line=..., end_line=..., +// function_name=..., code_snippet=...), +// sarif_rule_id=f"sec-af/{finding.finding_type.value}/{finding.cwe_id.lower()}", +// sarif_security_severity=0.0) +// +// Two things distinguish it from agents/prove.Fallback, which serves the same +// purpose elsewhere: +// +// - the rationale is a FIXED string, not "Verification incomplete: "; +// - the sarif_rule_id is built from the lowercased CWE ID +// ("sec-af/sast/cwe-89"), where prove's is built from the CWE NAME with +// spaces turned into hyphens ("sec-af/sast/sql-injection"). +// +// Python parity: nothing in the Python tree calls this function — every demotion +// path goes through agents/prove.fallback — so it is ported for completeness. +// `related_locations`, `compliance` and `reproduction_steps` keep pydantic's +// empty-list defaults; `drop_reason` stays None, which is what makes a finding +// built here invisible to the orchestrator's drop_reason sweep. +func VerifiedFindingFallback(finding schemas.RawFinding) schemas.VerifiedFinding { + snippet := finding.CodeSnippet + out := schemas.NewVerifiedFinding() + out.ID = finding.ID + out.Fingerprint = finding.Fingerprint + out.Title = finding.Title + out.Description = finding.Description + out.FindingType = finding.FindingType + out.CweID = finding.CweID + out.CweName = finding.CweName + out.OwaspCategory = finding.OwaspCategory + out.Tags = []string{} + out.Verdict = schemas.VerdictInconclusive + out.EvidenceLevel = schemas.EvidenceLevelStaticMatch + out.Rationale = "Automated proof unavailable; requires manual review." + out.Severity = finding.EstimatedSeverity + out.ExploitabilityScore = 0.0 + out.Location = schemas.Location{ + FilePath: finding.FilePath, + StartLine: finding.StartLine, + EndLine: finding.EndLine, + FunctionName: finding.FunctionName, + CodeSnippet: &snippet, + } + out.SarifRuleID = "sec-af/" + string(finding.FindingType) + "/" + strings.ToLower(finding.CweID) + out.SarifSecuritySeverity = 0.0 + return out +} + +// MergeReconFindingsIntoHunt ports the module-level +// `merge_recon_findings_into_hunt` (orchestrator.py:700) — how the findings +// RECON detected on its own (hardcoded secrets, misconfigs, weak TLS) join the +// hunters' output: +// +// if not recon_findings: return hunt +// merged_findings = [*recon_findings, *hunt.findings] +// strategies_run = list(hunt.strategies_run) +// if "recon" not in strategies_run: strategies_run.insert(0, "recon") +// return HuntResult(findings=merged_findings, chains=hunt.chains, +// total_raw=hunt.total_raw + len(recon_findings), +// deduplicated_count=len(merged_findings), +// chain_count=hunt.chain_count, strategies_run=strategies_run, +// hunt_duration_seconds=hunt.hunt_duration_seconds) +// +// Behaviours tests/test_recon_findings.py::test_merge_recon_findings_prepends_and_updates_counts +// pins, all reproduced: +// +// - recon findings are PREPENDED, so findings[0] is a recon finding; +// - total_raw GROWS by the recon count (it is not recomputed); +// - deduplicated_count is REPLACED by the merged length — no dedup pass runs, +// so a recon finding that duplicates a hunter finding survives twice; +// - "recon" is inserted at the FRONT of strategies_run, and only when absent. +// +// Python parity: an empty recon_findings list returns the hunt result +// UNCHANGED — the same value, not a copy — so a caller that mutates the result +// mutates the input. Go returns the same struct value, which copies the header +// but shares the backing arrays: identical aliasing. +func MergeReconFindingsIntoHunt(hunt schemas.HuntResult, reconFindings []schemas.RawFinding) schemas.HuntResult { + if len(reconFindings) == 0 { + return hunt + } + + merged := make([]schemas.RawFinding, 0, len(reconFindings)+len(hunt.Findings)) + merged = append(merged, reconFindings...) + merged = append(merged, hunt.Findings...) + + strategiesRun := make([]string, 0, len(hunt.StrategiesRun)+1) + strategiesRun = append(strategiesRun, hunt.StrategiesRun...) + found := false + for _, s := range strategiesRun { + if s == "recon" { + found = true + break + } + } + if !found { + strategiesRun = append([]string{"recon"}, strategiesRun...) + } + + return schemas.HuntResult{ + Findings: merged, + Chains: hunt.Chains, + TotalRaw: hunt.TotalRaw + len(reconFindings), + DeduplicatedCount: len(merged), + ChainCount: hunt.ChainCount, + StrategiesRun: strategiesRun, + HuntDurationSeconds: hunt.HuntDurationSeconds, + } +} + +// sortedNonEmpty ports `sorted({item for item in values if item})` — the +// framework projection `_merge_recon` applies to +// security_context.framework_security. +// +// Python parity: the filter is TRUTHINESS, so blank entries disappear before +// the set is built; case is preserved, so "Django" and "django" are two +// distinct frameworks. The set-then-sort erases the only nondeterminism. +// reasoners/phases.py's recon_phase carries the same expression, and +// internal/phases has its own copy for the same reason SEC-AF does. +func sortedNonEmpty(values []string) []string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + sort.Strings(out) + return out +} diff --git a/go/internal/orch/orch.go b/go/internal/orch/orch.go new file mode 100644 index 0000000..92a09fc --- /dev/null +++ b/go/internal/orch/orch.go @@ -0,0 +1,311 @@ +package orch + +import ( + "context" + "os" + "path/filepath" + "sync" + "time" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/config" + "github.com/Agent-Field/sec-af/go/internal/diffanalysis" + "github.com/Agent-Field/sec-af/go/internal/gates" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// nowMonotonic is `time.monotonic()` — the clock New stamps StartedAt with and +// elapsedSeconds measures against. It is a variable purely so a test can pin +// the elapsed times that reach the progress notes and duration_seconds; +// production never reassigns it. +// +// Go's time.Now carries a monotonic reading that Sub prefers over the wall +// clock, so the DIFFERENCE has exactly Python's time.monotonic() semantics +// (immune to clock adjustments) even though the absolute value is a wall time. +var nowMonotonic = time.Now + +// PhaseOrder ports `AuditOrchestrator._PHASE_ORDER = ("recon", "hunt", "prove")` +// — the three cost buckets, and the key set of cost_breakdown. +var PhaseOrder = [...]string{"recon", "hunt", "prove"} + +// The three phase names, spelled once so a typo cannot silently create a fourth +// cost bucket. +const ( + PhaseRecon = "recon" + PhaseHunt = "hunt" + PhaseProve = "prove" +) + +// AuditOrchestrator ports the class of the same name (orchestrator.py:53). +// +// Every field Python assigns in `__init__` is here, exported where a caller +// (app.py, and therefore internal/node) reads or writes it: +// +// self.app, self.input, self.started_at, self.repo_path, self.checkpoint_dir, +// self.is_pr_mode, self.diff_analysis, self.config, self.budget_config, +// self.max_cost_usd, self.max_duration_seconds, self.total_cost_usd, +// self.cost_breakdown, self.agent_invocations, self.budget_exhausted, +// self.findings_not_verified, self.prove_drop_summary, self.ai_gate +type AuditOrchestrator struct { + // App is the SDK agent. Python stores it as `cast("Any", app)` purely to + // silence the type checker; the capability set it actually uses is + // harness + ai + note (never call — the orchestrator's own pipeline is + // in-process). + App appx.App + // Input is the audit request. + Input schemas.AuditInput + // StartedAt is `time.monotonic()` at construction. Go's time.Now is a wall + // clock, but every read is a DIFFERENCE (`time.monotonic() - self.started_at`) + // and Go's time.Time carries a monotonic reading that Sub prefers, so the + // elapsed values match Python's semantics. + StartedAt time.Time + // RepoPath is `Path(os.getenv("SEC_AF_REPO_PATH", os.getcwd())).resolve()`. + // app.py replaces it right after construction — see SetRepoPath. + RepoPath string + // CheckpointDir is `self.repo_path / ".sec-af"`. + CheckpointDir string + // IsPRMode is `input.is_pr`. + IsPRMode bool + // DiffAnalysis is populated only in PR mode with a base commit; nil is + // Python's None. + DiffAnalysis *diffanalysis.DiffAnalysis + // Config is `AuditConfig.from_input(input, str(repo_path))`. + Config config.AuditConfig + // BudgetConfig is `self.config.budget`. + BudgetConfig config.BudgetConfig + // MaxCostUSD is `input.max_cost_usd` (None => nil => no cost budget). + MaxCostUSD *float64 + // MaxDurationSeconds is `input.max_duration_seconds`. + MaxDurationSeconds *int + // FindingsNotVerified is the count GenerateOutput reports in metadata. + // + // Unlike the two guarded counters it stays an EXPORTED field, because + // every writer runs on the sequential audit path and never inside the + // phase fan-out: run.go's two phases, and app.py's own + // `orchestrator.findings_not_verified = ...` (node/audit.go). + FindingsNotVerified int + // ProveDropSummary is the `{"demoted_total", "by_reason", "findings"}` + // dict, rebuilt at the start of each prove pass and copied verbatim into + // the result metadata. + // + // Typed `any`, not `map[string]any`, because on the app.py path it is + // assigned straight from a `.call` payload with + // `prove_dict.get("drop_summary", {...})` — a `.get`, so the default fires + // ONLY when the key is absent. A key that is present with an odd value (a + // JSON null, a string, a list) is threaded through to + // `metadata["prove_drop_summary"]` verbatim in Python, and a + // `map[string]any` field cannot hold it. See node/audit.go. + ProveDropSummary any + // AIGate is `AIGateWrapper(app=self.app)` — used by + // AssessReachabilityParallel and by GenerateOutput's compliance fallback. + AIGate *gates.AIGate + + // mu guards the cost/invocation counters. Python needs no lock (asyncio is + // single-threaded); the Go phases fan out across goroutines that share one + // proxy, so the bookkeeping must be safe for concurrent use. It guards + // totalCostUSD, costBreakdown, agentInvocations and budgetExhausted. + // + // All four are UNEXPORTED so the contract is enforceable rather than + // merely documented: every access goes through the guarded accessors below + // (TotalCostUSD, CostBreakdown, AgentInvocations, SetAgentInvocations, + // BudgetExhausted) and there is no way for a caller to read one while a + // phase proxy is still writing it. An earlier revision exported the two + // counters, and GenerateOutput read both of them bare — harmless today, + // because Run joins its errgroup before calling it, but a latent race for + // the first caller that reads a partial result (a progress endpoint, an + // early return on budget exhaustion) and one the race detector would only + // catch if a test happened to schedule the overlap. + mu sync.Mutex + totalCostUSD float64 + costBreakdown map[string]float64 + agentInvocations int + budgetExhausted bool +} + +// New ports `AuditOrchestrator.__init__(app, input)`. +// +// self.started_at = time.monotonic() +// self.repo_path = Path(os.getenv("SEC_AF_REPO_PATH", os.getcwd())).resolve() +// self.checkpoint_dir = self.repo_path / ".sec-af" +// self.is_pr_mode = input.is_pr +// if self.is_pr_mode and input.base_commit_sha: +// self.diff_analysis = analyze_diff(str(self.repo_path), input.base_commit_sha, input.commit_sha or "HEAD") +// self.config = AuditConfig.from_input(self.input, str(self.repo_path)) +// ... +// self.ai_gate = AIGateWrapper(app=self.app) +// +// Python parity: +// +// - the diff analysis runs against the CONSTRUCTOR's repo path, before app.py +// substitutes the resolved (possibly cloned) one. Reproduced: callers that +// want the diff against the real checkout must construct after resolving, +// which app.py does not do. +// - `input.base_commit_sha` and `input.commit_sha` are tested with PYTHON +// TRUTHINESS, so an empty string behaves like None — no diff analysis, and +// the head defaults to "HEAD". +// - AIGateWrapper's config falls back to `AIIntegrationConfig.from_env()`, +// which in Go can fail on a malformed SEC_AF_AI_* integer. Python crashes at +// import time for the same input; Go returns the error here, which is the +// earliest point it can. +// +// Signature parity note: Python's `__init__` is synchronous and carries no +// context, so New takes none. analyze_diff shells out to git, and +// diffanalysis.AnalyzeDiff gives each invocation its own 30s timeout, so +// context.Background() is the honest translation. NewWithContext is for a +// caller that already holds a cancellable context. +func New(app appx.App, input schemas.AuditInput) (*AuditOrchestrator, error) { + return NewWithContext(context.Background(), app, input) +} + +// NewWithContext is New with the context the PR-mode git commands run under. +func NewWithContext(ctx context.Context, app appx.App, input schemas.AuditInput) (*AuditOrchestrator, error) { + repoPath := resolveRepoPath() + + o := &AuditOrchestrator{ + App: app, + Input: input, + StartedAt: nowMonotonic(), + RepoPath: repoPath, + CheckpointDir: filepath.Join(repoPath, ".sec-af"), + IsPRMode: input.IsPr, + MaxCostUSD: input.MaxCostUsd, + MaxDurationSeconds: input.MaxDurationSeconds, + FindingsNotVerified: 0, + ProveDropSummary: NewDropSummary(), + costBreakdown: map[string]float64{}, + } + for _, phase := range PhaseOrder { + o.costBreakdown[phase] = 0.0 + } + + if o.IsPRMode && input.BaseCommitSha != nil && *input.BaseCommitSha != "" { + head := "HEAD" + if input.CommitSha != nil && *input.CommitSha != "" { + head = *input.CommitSha + } + analysis := diffanalysis.AnalyzeDiff(ctx, o.RepoPath, *input.BaseCommitSha, head) + o.DiffAnalysis = &analysis + } + + cfg, err := config.AuditConfig{}.FromInput(input, o.RepoPath) + if err != nil { + return nil, err + } + o.Config = cfg + o.BudgetConfig = cfg.Budget + + gate, err := gates.NewAIGate(app, nil) + if err != nil { + return nil, err + } + o.AIGate = gate + + return o, nil +} + +// SetRepoPath performs the two assignments app.py makes right after +// construction: +// +// orchestrator.repo_path = Path(repo_path) +// orchestrator.checkpoint_dir = orchestrator.repo_path / ".sec-af" +// +// Python parity: app.py does NOT re-resolve the path here (it passes the +// already-absolute result of _resolve_repo) and does NOT recompute self.config, +// so AuditConfig.repo_path keeps the constructor's value. Both are reproduced — +// this setter touches exactly the two fields Python touches. +func (o *AuditOrchestrator) SetRepoPath(repoPath string) { + o.RepoPath = repoPath + o.CheckpointDir = filepath.Join(repoPath, ".sec-af") +} + +// resolveRepoPath ports `Path(os.getenv("SEC_AF_REPO_PATH", os.getcwd())).resolve()`. +// +// Python parity: `Path.resolve()` makes the path absolute AND follows symlinks, +// with strict=False so a non-existent path still resolves. Go splits that in +// two: filepath.Abs handles the absolute part and always succeeds for a +// non-empty path; filepath.EvalSymlinks handles the link part but FAILS on a +// path that does not exist, in which case the absolute form is kept — the same +// answer Python gives for a path with no symlinked ancestors, which is every +// path in practice. +func resolveRepoPath() string { + raw := os.Getenv("SEC_AF_REPO_PATH") + if raw == "" { + cwd, err := os.Getwd() + if err != nil { + // os.getcwd() raises in Python too; there is no path that is more + // correct than the relative one. + cwd = "." + } + raw = cwd + } + abs, err := filepath.Abs(raw) + if err != nil { + return raw + } + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + return resolved + } + return abs +} + +// NewDropSummary builds the `{"demoted_total": 0, "by_reason": {}, "findings": []}` +// literal the orchestrator resets prove_drop_summary to. +func NewDropSummary() map[string]any { + return map[string]any{ + "demoted_total": 0, + "by_reason": map[string]int{}, + "findings": []map[string]any{}, + } +} + +// AgentInvocations reports `self.agent_invocations` — the number of harness +// invocations made through the phase proxy. +func (o *AuditOrchestrator) AgentInvocations() int { + o.mu.Lock() + defer o.mu.Unlock() + return o.agentInvocations +} + +// SetAgentInvocations OVERWRITES the counter, which is what app.py:220 does +// (`orchestrator.agent_invocations = total_selected + len(...) + 3`) before +// calling _generate_output. +func (o *AuditOrchestrator) SetAgentInvocations(n int) { + o.mu.Lock() + o.agentInvocations = n + o.mu.Unlock() +} + +// BudgetExhausted reports `self.budget_exhausted`, which latches true the first +// time a budget check trips. +func (o *AuditOrchestrator) BudgetExhausted() bool { + o.mu.Lock() + defer o.mu.Unlock() + return o.budgetExhausted +} + +// TotalCostUSD reports the accumulated harness cost (`self.total_cost_usd`). +func (o *AuditOrchestrator) TotalCostUSD() float64 { + o.mu.Lock() + defer o.mu.Unlock() + return o.totalCostUSD +} + +// CostBreakdown returns a COPY of the per-phase cost map +// (`self.cost_breakdown`), so a caller cannot corrupt the running totals. +func (o *AuditOrchestrator) CostBreakdown() map[string]float64 { + o.mu.Lock() + defer o.mu.Unlock() + out := make(map[string]float64, len(o.costBreakdown)) + for k, v := range o.costBreakdown { + out[k] = v + } + return out +} + +// depthProfile ports `_depth_profile()`: +// +// try: return DepthProfile(self.input.depth.lower()) +// except ValueError: return DepthProfile.STANDARD +func (o *AuditOrchestrator) depthProfile() config.DepthProfile { + return config.NormalizeDepth(o.Input.Depth) +} diff --git a/go/internal/orch/orch_test.go b/go/internal/orch/orch_test.go new file mode 100644 index 0000000..d7ef8a6 --- /dev/null +++ b/go/internal/orch/orch_test.go @@ -0,0 +1,1054 @@ +package orch + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/compliance" + "github.com/Agent-Field/sec-af/go/internal/phases" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// --------------------------------------------------------------------------- +// construction +// --------------------------------------------------------------------------- + +// TestNew_InitializesEveryField mirrors the assignments of Python's __init__. +func TestNew_InitializesEveryField(t *testing.T) { + repo := t.TempDir() + t.Setenv("SEC_AF_REPO_PATH", repo) + + input := schemas.NewAuditInput() + input.RepoURL = "https://example.invalid/repo" + cost := 12.5 + provers := 7 + duration := 900 + input.MaxCostUsd = &cost + input.MaxProvers = &provers + input.MaxDurationSeconds = &duration + + o, err := New(&appx.Fake{}, input) + if err != nil { + t.Fatalf("New: %v", err) + } + + wantRepo := repo + if resolved, err := filepath.EvalSymlinks(repo); err == nil { + wantRepo = resolved + } + if o.RepoPath != wantRepo { + t.Errorf("RepoPath = %q, want %q", o.RepoPath, wantRepo) + } + if want := filepath.Join(wantRepo, ".sec-af"); o.CheckpointDir != want { + t.Errorf("CheckpointDir = %q, want %q", o.CheckpointDir, want) + } + if o.IsPRMode { + t.Error("IsPRMode must default to false") + } + if o.DiffAnalysis != nil { + t.Error("DiffAnalysis must stay nil outside PR mode") + } + if o.Config.RepoPath != wantRepo { + t.Errorf("Config.RepoPath = %q, want %q", o.Config.RepoPath, wantRepo) + } + if o.MaxCostUSD == nil || *o.MaxCostUSD != cost { + t.Errorf("MaxCostUSD = %v, want %v", o.MaxCostUSD, cost) + } + if o.MaxDurationSeconds == nil || *o.MaxDurationSeconds != duration { + t.Errorf("MaxDurationSeconds = %v, want %v", o.MaxDurationSeconds, duration) + } + // budget_config comes from the config, and carries the three input caps. + if o.BudgetConfig.MaxProvers == nil || *o.BudgetConfig.MaxProvers != provers { + t.Errorf("BudgetConfig.MaxProvers = %v, want %v", o.BudgetConfig.MaxProvers, provers) + } + if o.BudgetConfig.MaxConcurrentHunters != 4 || o.BudgetConfig.MaxConcurrentProvers != 3 { + t.Errorf("BudgetConfig concurrency = %d/%d, want 4/3", + o.BudgetConfig.MaxConcurrentHunters, o.BudgetConfig.MaxConcurrentProvers) + } + // cost_breakdown starts as {phase: 0.0} for exactly the three phases. + if got := o.CostBreakdown(); !reflect.DeepEqual(got, map[string]float64{"recon": 0, "hunt": 0, "prove": 0}) { + t.Errorf("CostBreakdown = %v", got) + } + if o.AgentInvocations() != 0 || o.BudgetExhausted() || o.FindingsNotVerified != 0 { + t.Error("counters must start at zero") + } + want := map[string]any{"demoted_total": 0, "by_reason": map[string]int{}, "findings": []map[string]any{}} + if !reflect.DeepEqual(o.ProveDropSummary, want) { + t.Errorf("ProveDropSummary = %#v, want %#v", o.ProveDropSummary, want) + } + if o.AIGate == nil { + t.Error("AIGate must be constructed") + } +} + +// TestNew_RejectsInvalidDepth: AuditConfig.from_input uses the STRICT +// DepthProfile constructor, so an unrecognised depth is a ValueError — which +// app.py maps to HTTP 400. +func TestNew_RejectsInvalidDepth(t *testing.T) { + input := schemas.NewAuditInput() + input.RepoURL = "https://example.invalid/repo" + input.Depth = "QUICK" + if _, err := New(&appx.Fake{}, input); err == nil { + t.Fatal("want an error for depth QUICK") + } +} + +// TestSetRepoPath performs app.py's two post-construction assignments. +func TestSetRepoPath(t *testing.T) { + o, _ := newTestOrchestrator(t) + before := o.Config.RepoPath + o.SetRepoPath("/elsewhere/repo") + if o.RepoPath != "/elsewhere/repo" { + t.Errorf("RepoPath = %q", o.RepoPath) + } + if want := filepath.Join("/elsewhere/repo", ".sec-af"); o.CheckpointDir != want { + t.Errorf("CheckpointDir = %q, want %q", o.CheckpointDir, want) + } + // Python parity: app.py does NOT recompute self.config, so AuditConfig + // keeps the constructor's repo_path. + if o.Config.RepoPath != before { + t.Errorf("Config.RepoPath = %q, want it unchanged (%q)", o.Config.RepoPath, before) + } +} + +// TestNew_PRModeRunsDiffAnalysis: the branch fires only with is_pr AND a +// truthy base_commit_sha. +func TestNew_PRModeRunsDiffAnalysis(t *testing.T) { + cases := []struct { + name string + isPR bool + baseSHA *string + wantDiff bool + }{ + {"not a PR", false, strPtr("abc"), false}, + {"PR without base", true, nil, false}, + {"PR with empty base", true, strPtr(""), false}, + {"PR with base", true, strPtr("abc"), true}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Setenv("SEC_AF_REPO_PATH", t.TempDir()) + input := schemas.NewAuditInput() + input.RepoURL = "https://example.invalid/repo" + input.IsPr = tc.isPR + input.BaseCommitSha = tc.baseSHA + + o, err := New(&appx.Fake{}, input) + if err != nil { + t.Fatalf("New: %v", err) + } + if got := o.DiffAnalysis != nil; got != tc.wantDiff { + t.Errorf("DiffAnalysis present = %v, want %v", got, tc.wantDiff) + } + if tc.wantDiff && o.DiffAnalysis.HeadSHA != "HEAD" { + t.Errorf("HeadSHA = %q, want HEAD when commit_sha is absent", o.DiffAnalysis.HeadSHA) + } + }) + } +} + +func strPtr(s string) *string { return &s } + +// --------------------------------------------------------------------------- +// budget +// --------------------------------------------------------------------------- + +// TestBudget_CostChecks walks _check_cost_budget's two limits. +func TestBudget_CostChecks(t *testing.T) { + t.Run("no cost budget never trips", func(t *testing.T) { + o, _ := newTestOrchestrator(t) + o.registerCost(PhaseHunt, floatPtr(1000)) + if o.budgetOrTimeoutExhausted(PhaseHunt) { + t.Error("a nil max_cost_usd must never exhaust") + } + }) + + t.Run("total budget trips at >= and wins over the phase limit", func(t *testing.T) { + // The three phase percentages sum to exactly 1.0 (0.10 + 0.45 + 0.45), + // so spending under every phase share also keeps the total under the + // cap — and reaching the cap necessarily saturates a phase too. The + // TOTAL check runs first, so its message is the one Python raises. + o, _ := newTestOrchestrator(t, func(in *schemas.AuditInput) { in.MaxCostUsd = floatPtr(1.0) }) + o.registerCost(PhaseRecon, floatPtr(0.09)) // limit 0.10 + o.registerCost(PhaseHunt, floatPtr(0.44)) // limit 0.45 + o.registerCost(PhaseProve, floatPtr(0.44)) // limit 0.45 + if o.budgetOrTimeoutExhausted(PhaseHunt) { + t.Error("0.97 total with every phase under its share must not exhaust") + } + + o.registerCost(PhaseRecon, floatPtr(0.01)) + o.registerCost(PhaseHunt, floatPtr(0.01)) + o.registerCost(PhaseProve, floatPtr(0.01)) // total 1.0 + if !o.budgetOrTimeoutExhausted(PhaseHunt) { + t.Error("exactly the cap must exhaust (the comparison is >=)") + } + if !o.BudgetExhausted() { + t.Error("the flag must latch") + } + if err := o.checkCostBudget(PhaseHunt); !IsBudgetExhausted(err) || err.Error() != "Total budget exhausted" { + t.Errorf("err = %v, want `Total budget exhausted`", err) + } + }) + + t.Run("per-phase budget uses the configured percentages", func(t *testing.T) { + o, _ := newTestOrchestrator(t, func(in *schemas.AuditInput) { in.MaxCostUsd = floatPtr(10.0) }) + // recon_budget_pct = 0.10 -> a 1.0 allowance. + o.registerCost(PhaseRecon, floatPtr(0.99)) + if o.budgetOrTimeoutExhausted(PhaseRecon) { + t.Error("0.99 < 1.0 must not exhaust the recon phase") + } + o.registerCost(PhaseRecon, floatPtr(0.01)) + err := o.checkCostBudget(PhaseRecon) + if !IsBudgetExhausted(err) || err.Error() != "recon budget exhausted" { + t.Errorf("err = %v, want `recon budget exhausted`", err) + } + // The other phases are untouched. + if o.budgetOrTimeoutExhausted(PhaseHunt) { + t.Error("the hunt phase must still have budget") + } + }) +} + +// TestBudget_TimeCheck: strictly greater, and only when a duration cap is set. +func TestBudget_TimeCheck(t *testing.T) { + base := time.Now() + restore := nowMonotonic + defer func() { nowMonotonic = restore }() + + o, _ := newTestOrchestrator(t, func(in *schemas.AuditInput) { + limit := 10 + in.MaxDurationSeconds = &limit + }) + o.StartedAt = base + + nowMonotonic = func() time.Time { return base.Add(10 * time.Second) } + if err := o.checkTimeBudget(); err != nil { + t.Errorf("exactly the limit must NOT trip (>, not >=): %v", err) + } + nowMonotonic = func() time.Time { return base.Add(10*time.Second + time.Millisecond) } + err := o.checkTimeBudget() + if !IsBudgetExhausted(err) || err.Error() != "Duration budget exhausted" { + t.Errorf("err = %v, want `Duration budget exhausted`", err) + } + + unbounded, _ := newTestOrchestrator(t) + unbounded.StartedAt = base + if err := unbounded.checkTimeBudget(); err != nil { + t.Errorf("a nil max_duration_seconds must never trip: %v", err) + } +} + +// TestRegisterCost reproduces the None/negative guard and the double +// accumulation. +func TestRegisterCost(t *testing.T) { + o, _ := newTestOrchestrator(t) + o.registerCost(PhaseHunt, nil) + o.registerCost(PhaseHunt, floatPtr(-1)) + if o.TotalCostUSD() != 0 { + t.Errorf("total = %v, want 0 after a nil and a negative cost", o.TotalCostUSD()) + } + o.registerCost(PhaseHunt, floatPtr(0)) // exactly zero IS registered + o.registerCost(PhaseHunt, floatPtr(0.25)) + o.registerCost(PhaseProve, floatPtr(0.5)) + if got := o.TotalCostUSD(); got != 0.75 { + t.Errorf("total = %v, want 0.75", got) + } + if got := o.CostBreakdown(); got["hunt"] != 0.25 || got["prove"] != 0.5 || got["recon"] != 0 { + t.Errorf("breakdown = %v", got) + } +} + +// --------------------------------------------------------------------------- +// _PhaseHarnessProxy +// --------------------------------------------------------------------------- + +// TestPhaseProxy_CountsAndCosts is the happy path of the Python class. +func TestPhaseProxy_CountsAndCosts(t *testing.T) { + o, fake := newTestOrchestrator(t) + fake.HarnessFn = func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return &harness.Result{CostUSD: floatPtr(0.125)}, nil + } + + proxy := o.PhaseProxy(PhaseHunt) + for i := 0; i < 3; i++ { + if _, err := proxy.Harness(context.Background(), "p", nil, nil, harness.Options{}); err != nil { + t.Fatalf("Harness: %v", err) + } + } + if o.AgentInvocations() != 3 { + t.Errorf("AgentInvocations = %d, want 3", o.AgentInvocations()) + } + if got := o.TotalCostUSD(); got != 0.375 { + t.Errorf("total cost = %v, want 0.375", got) + } + if got := o.CostBreakdown()["hunt"]; got != 0.375 { + t.Errorf("hunt bucket = %v, want 0.375", got) + } + if got := o.CostBreakdown()["recon"]; got != 0 { + t.Errorf("recon bucket = %v, want 0", got) + } +} + +// TestPhaseProxy_BudgetExhaustion: the guard fires BEFORE the wrapped harness, +// so a spent phase makes no provider call at all. +func TestPhaseProxy_BudgetExhaustion(t *testing.T) { + o, fake := newTestOrchestrator(t, func(in *schemas.AuditInput) { in.MaxCostUsd = floatPtr(0.2) }) + fake.HarnessFn = func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return &harness.Result{CostUSD: floatPtr(0.5)}, nil + } + + proxy := o.PhaseProxy(PhaseProve) + if _, err := proxy.Harness(context.Background(), "first", nil, nil, harness.Options{}); err != nil { + t.Fatalf("the first call must succeed: %v", err) + } + _, err := proxy.Harness(context.Background(), "second", nil, nil, harness.Options{}) + if !IsBudgetExhausted(err) { + t.Fatalf("err = %v, want a BudgetExhaustedError", err) + } + // Python raises f"{phase} budget exhausted" whichever check tripped. + if err.Error() != "prove budget exhausted" { + t.Errorf("message = %q, want `prove budget exhausted`", err.Error()) + } + if len(fake.Harnesses) != 1 { + t.Errorf("harness calls = %d, want 1 (the second is blocked before the provider)", len(fake.Harnesses)) + } + if o.AgentInvocations() != 1 { + t.Errorf("AgentInvocations = %d, want 1", o.AgentInvocations()) + } + if !o.BudgetExhausted() { + t.Error("the flag must latch") + } +} + +// TestPhaseProxy_TransportErrorSkipsAccounting matches Python, where the raised +// `await` never reaches the counter or the cost registration. +func TestPhaseProxy_TransportErrorSkipsAccounting(t *testing.T) { + o, fake := newTestOrchestrator(t) + fake.HarnessFn = func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return nil, errors.New("provider unreachable") + } + if _, err := o.PhaseProxy(PhaseRecon).Harness(context.Background(), "p", nil, nil, harness.Options{}); err == nil { + t.Fatal("want the transport error") + } + if o.AgentInvocations() != 0 || o.TotalCostUSD() != 0 { + t.Errorf("a failed harness must not be counted or costed (%d, %v)", o.AgentInvocations(), o.TotalCostUSD()) + } +} + +// TestPhaseProxy_ExposesOnlyHarness pins the Python class's capability +// surface. `_PhaseHarnessProxy` (orchestrator.py:39) defines `harness` and +// nothing else — VERIFIED on the repo interpreter: +// +// hasattr(proxy, "ai") -> False +// hasattr(proxy, "note") -> False +// hasattr(proxy, "call") -> False +// +// The dedup pass reads exactly that (`hasattr(app, "ai")`, agents/dedup.py:150), +// so a proxy that satisfied appx.AIer would make the Go node issue AI duplicate +// checks Python never issues. +func TestPhaseProxy_ExposesOnlyHarness(t *testing.T) { + o, _ := newTestOrchestrator(t) + + for _, phase := range []string{PhaseRecon, PhaseHunt, PhaseProve} { + proxy := o.PhaseProxy(phase) + if _, ok := proxy.(appx.AIer); ok { + t.Errorf("%s proxy satisfies appx.AIer; Python's hasattr(proxy, \"ai\") is False", phase) + } + if _, ok := proxy.(appx.Noter); ok { + t.Errorf("%s proxy satisfies appx.Noter; Python's hasattr(proxy, \"note\") is False", phase) + } + if _, ok := proxy.(appx.Caller); ok { + t.Errorf("%s proxy satisfies appx.Caller; Python's hasattr(proxy, \"call\") is False", phase) + } + } +} + +// TestProvePhaseProxy_AIRaisesPythonsAttributeError: run_prove's verdict +// sub-agent calls `await app.ai(...)` unguarded (agents/prove/verdict.py:99), +// which against `_PhaseHarnessProxy` raises +// +// AttributeError: '_PhaseHarnessProxy' object has no attribute 'ai' +// +// The Go seam returns that error verbatim instead, and — like an attribute +// lookup that never happened — never reaches the wrapped app and never counts +// or costs anything. +func TestProvePhaseProxy_AIRaisesPythonsAttributeError(t *testing.T) { + o, fake := newTestOrchestrator(t) + fake.AIFn = appx.AIJSON(func(string) (json.RawMessage, error) { return json.RawMessage(`{"ok":true}`), nil }) + + resp, err := o.ProvePhaseProxy().AI(context.Background(), "hello") + if err == nil { + t.Fatal("AI must fail: Python's proxy has no `ai` attribute") + } + if resp != nil { + t.Errorf("resp = %v, want nil", resp) + } + const want = "'_PhaseHarnessProxy' object has no attribute 'ai'" + if err.Error() != want { + t.Errorf("message = %q, want %q", err.Error(), want) + } + var attrErr *AttributeError + if !errors.As(err, &attrErr) { + t.Errorf("err %T is not an *AttributeError", err) + } + if len(fake.AIs) != 0 { + t.Errorf("the wrapped app was reached %d times, want 0", len(fake.AIs)) + } + if o.AgentInvocations() != 0 || o.TotalCostUSD() != 0 { + t.Error("a nonexistent attribute must not be counted or costed") + } +} + +// TestRunProve_DemotesEveryFindingBecauseTheProxyHasNoAI is the observable +// consequence, at the level `audit(resume_from_checkpoint=...)` reaches it. +// +// Python: `_run_prove` hands run_prove the proxy; run_verifier's tracer, +// sanitization and exploit stages succeed through `.harness`, then +// `run_verdict_agent` raises AttributeError, which `_verify`'s +// `except BaseException` (agents/prove/__init__.py:89) turns into +// `verifier_fallback(finding, str(exc), drop_reason="verifier_error")`. So +// EVERY finding comes back INCONCLUSIVE / STATIC_MATCH / score 0.0 with +// tags ["low_confidence"], whatever the harness said. +func TestRunProve_DemotesEveryFindingBecauseTheProxyHasNoAI(t *testing.T) { + o, fake := newTestOrchestrator(t) + fake.HarnessFn = appx.HarnessJSON(func(prompt string, _ harness.Options) (json.RawMessage, error) { + switch { + case strings.Contains(prompt, "You are DataFlowTracer"): + return json.Marshal(map[string]any{ + "source": "req.query.id", "sink": "db.query", "steps": []string{"a.go:1"}, "sink_reached": true, + }) + case strings.Contains(prompt, "You are SanitizationAnalyzer"): + return json.Marshal(map[string]any{"found": false}) + case strings.Contains(prompt, "You are ExploitHypothesizer"): + return json.Marshal(map[string]any{ + "hypothesis": "h", "payload": "p", "expected_outcome": "o", + }) + } + return nil, errors.New("unexpected harness prompt") + }) + // A working `.ai()` on the WRAPPED app: the proxy must not reach it. + fake.AIFn = appx.AIJSON(func(string) (json.RawMessage, error) { + return json.Marshal(map[string]any{ + "verdict": "confirmed", "evidence_level": 6, "rationale": "r", "confidence": "high", + }) + }) + + finding := schemas.RawFinding{ + ID: "f1", Fingerprint: "fp1", HunterStrategy: "injection", + Title: "SQLi", Description: "d", FindingType: schemas.FindingTypeSast, + CweID: "CWE-89", CweName: "SQL Injection", + FilePath: "app.go", StartLine: 10, EndLine: 12, CodeSnippet: "q", + EstimatedSeverity: schemas.SeverityHigh, Confidence: schemas.ConfidenceHigh, + } + hunt := schemas.NewHuntResult() + hunt.Findings = []schemas.RawFinding{finding} + + verified, err := o.RunProve(context.Background(), schemas.NewReconResult(), hunt) + if err != nil { + t.Fatalf("RunProve: %v", err) + } + if len(verified) != 1 { + t.Fatalf("verified = %d findings, want 1", len(verified)) + } + got := verified[0] + if got.Verdict != schemas.VerdictInconclusive { + t.Errorf("verdict = %q, want %q", got.Verdict, schemas.VerdictInconclusive) + } + if got.EvidenceLevel != schemas.EvidenceLevelStaticMatch { + t.Errorf("evidence_level = %v, want STATIC_MATCH", got.EvidenceLevel) + } + if got.DropReason == nil || *got.DropReason != "verifier_error" { + t.Errorf("drop_reason = %v, want \"verifier_error\"", got.DropReason) + } + // Python's rationale is `f"Verification incomplete: {str(exc)}"`. Go's + // aix.Structured prefixes every `.ai()` failure with + // "aix.Structured[VerdictDecision]: " (a pre-existing, package-wide + // wrapping unrelated to the proxy), so the CPython text is asserted as a + // substring rather than the whole string. + if !strings.HasPrefix(got.Rationale, "Verification incomplete: ") || + !strings.HasSuffix(got.Rationale, "'_PhaseHarnessProxy' object has no attribute 'ai'") { + t.Errorf("rationale = %q, want `Verification incomplete: ...'_PhaseHarnessProxy' object has no attribute 'ai'`", got.Rationale) + } + if len(got.Tags) == 0 || got.Tags[0] != "low_confidence" { + t.Errorf("tags = %v, want low_confidence first", got.Tags) + } + // _assess_reachability_parallel runs on self.app (not the proxy), so the + // ONE recorded .ai() is the reachability gate — never the verdict agent. + if len(fake.AIs) != 1 { + t.Errorf("recorded .ai() calls = %d, want 1 (the reachability gate only)", len(fake.AIs)) + } +} + +// TestPhaseProxy_ConcurrentAccountingIsRaceFree is the Go-only requirement: +// the phases fan out across goroutines that share one proxy. +func TestPhaseProxy_ConcurrentAccountingIsRaceFree(t *testing.T) { + o, fake := newTestOrchestrator(t) + fake.HarnessFn = func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return &harness.Result{CostUSD: floatPtr(0.01)}, nil + } + proxy := o.PhaseProxy(PhaseHunt) + + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _ = proxy.Harness(context.Background(), "p", nil, nil, harness.Options{}) + }() + } + wg.Wait() + + if o.AgentInvocations() != 50 { + t.Errorf("AgentInvocations = %d, want 50", o.AgentInvocations()) + } + if got := o.CostBreakdown()["hunt"]; got < 0.49 || got > 0.51 { + t.Errorf("hunt bucket = %v, want ~0.5", got) + } +} + +// --------------------------------------------------------------------------- +// checkpoints +// --------------------------------------------------------------------------- + +// TestCheckpoints_RoundTrip covers write -> read for both payload shapes plus +// the _try_load_cached_recon fallbacks. +func TestCheckpoints_RoundTrip(t *testing.T) { + o, _ := newTestOrchestrator(t) + + if want := filepath.Join(o.CheckpointDir, "checkpoint-recon.json"); o.CheckpointPath("recon") != want { + t.Errorf("CheckpointPath = %q, want %q", o.CheckpointPath("recon"), want) + } + + recon := reconFixture(t, "full") + if err := o.WriteCheckpoint(PhaseRecon, recon); err != nil { + t.Fatalf("WriteCheckpoint: %v", err) + } + back, err := ReadCheckpoint(o, PhaseRecon, phases.BindReconResult) + if err != nil { + t.Fatalf("ReadCheckpoint: %v", err) + } + if !reflect.DeepEqual(normalizeJSON(t, back), normalizeJSON(t, recon)) { + t.Error("recon checkpoint did not round-trip") + } + + verified := []schemas.VerifiedFinding{readJSON[schemas.VerifiedFinding](t, "verified_fixture.json")} + if err := o.WriteCheckpoint(PhaseProve, verified); err != nil { + t.Fatalf("WriteCheckpoint: %v", err) + } + list, err := ReadCheckpointList(o, PhaseProve, phases.BindVerifiedFinding) + if err != nil { + t.Fatalf("ReadCheckpointList: %v", err) + } + if !reflect.DeepEqual(normalizeJSON(t, list), normalizeJSON(t, verified)) { + t.Error("prove checkpoint did not round-trip") + } + + // The directory is created on demand. + if _, err := os.Stat(o.CheckpointDir); err != nil { + t.Errorf("checkpoint dir was not created: %v", err) + } + + // _try_load_cached_recon: present -> value; absent/corrupt -> nil. + if cached := o.TryLoadCachedRecon(); cached == nil { + t.Error("TryLoadCachedRecon must find the checkpoint just written") + } + other, _ := newTestOrchestrator(t) + if cached := other.TryLoadCachedRecon(); cached != nil { + t.Error("TryLoadCachedRecon must return nil when the file is missing") + } + + // `_read_checkpoint(phase, schema)` is `schema(**data)`: it VALIDATES. + // A hand-edited checkpoint whose finding carries an out-of-vocabulary + // severity is a pydantic ValidationError in Python, and _try_load_cached_recon's + // blanket `except Exception` turns a failed validation into None. + corrupt, _ := newTestOrchestrator(t) + if err := os.MkdirAll(corrupt.CheckpointDir, 0o755); err != nil { + t.Fatal(err) + } + body := `{"phase":"prove","created_at":"2026-01-02T03:04:05+00:00","data":[{"title":"malformed"}]}` + if err := os.WriteFile(corrupt.CheckpointPath(PhaseProve), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + if _, err := ReadCheckpointList(corrupt, PhaseProve, phases.BindVerifiedFinding); err == nil { + t.Error("a checkpoint row that is not a VerifiedFinding must fail, not bind to defaults") + } + reconBody := `{"phase":"recon","created_at":"2026-01-02T03:04:05+00:00","data":{"languages":["go"]}}` + if err := os.WriteFile(corrupt.CheckpointPath(PhaseRecon), []byte(reconBody), 0o644); err != nil { + t.Fatal(err) + } + if _, err := ReadCheckpoint(corrupt, PhaseRecon, phases.BindReconResult); err == nil { + t.Error("a recon checkpoint missing its five required models must fail") + } + if cached := corrupt.TryLoadCachedRecon(); cached != nil { + t.Error("_try_load_cached_recon swallows a failed validation and returns None") + } + if err := os.MkdirAll(other.CheckpointDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(other.CheckpointPath(PhaseRecon), []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + if cached := other.TryLoadCachedRecon(); cached != nil { + t.Error("TryLoadCachedRecon must return nil for a corrupt file") + } +} + +// --------------------------------------------------------------------------- +// _assess_reachability_parallel +// --------------------------------------------------------------------------- + +// gateAI is an AIer that answers the reachability gate, records the prompts, and +// stalls so the semaphore bound is observable. +type gateAI struct { + mu sync.Mutex + prompts []string + inflight, peak int + reachability string + fail bool + delay time.Duration + promptsToObserve *[]string +} + +func (g *gateAI) AI(_ context.Context, prompt string, _ ...ai.Option) (*ai.Response, error) { + g.mu.Lock() + g.prompts = append(g.prompts, prompt) + g.inflight++ + if g.inflight > g.peak { + g.peak = g.inflight + } + g.mu.Unlock() + if g.delay > 0 { + time.Sleep(g.delay) + } + defer func() { + g.mu.Lock() + g.inflight-- + g.mu.Unlock() + }() + if g.fail { + return nil, errors.New("gate down") + } + body := `{"reachability":"` + g.reachability + `","rationale":"r","confidence":"high"}` + return &ai.Response{Choices: []ai.Choice{{Message: ai.Message{ + Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: body}}, + }}}}, nil +} + +func (g *gateAI) peakConcurrency() int { + g.mu.Lock() + defer g.mu.Unlock() + return g.peak +} + +func makeVerified(t *testing.T, n int, tags ...string) []schemas.VerifiedFinding { + t.Helper() + base := readJSON[schemas.VerifiedFinding](t, "verified_fixture.json") + out := make([]schemas.VerifiedFinding, 0, n) + for i := 0; i < n; i++ { + f := base + f.ID = base.ID + "-" + string(rune('a'+i)) + // `[]string{}`, never nil: pydantic's `tags: list[str] = Field( + // default_factory=list)` can never dump `null`, and a nil slice here + // would marshal a checkpoint the Go node's own BindVerifiedFinding + // rejects (as `VerifiedFinding(tags=None)` does in Python). + f.Tags = append([]string{}, tags...) + out = append(out, f) + } + return out +} + +func TestAssessReachabilityParallel(t *testing.T) { + t.Run("nothing to do when every finding is tagged", func(t *testing.T) { + o, _ := newTestOrchestrator(t) + gate := &gateAI{reachability: "externally_reachable"} + o.AIGate.App = gate + verified := makeVerified(t, 3, "requires_auth") + o.AssessReachabilityParallel(context.Background(), verified) + if len(gate.prompts) != 0 { + t.Errorf("gate called %d times, want 0", len(gate.prompts)) + } + for _, f := range verified { + if len(f.Tags) != 1 { + t.Errorf("tags = %v, want the original single tag", f.Tags) + } + } + }) + + t.Run("the gate answer is appended verbatim", func(t *testing.T) { + o, _ := newTestOrchestrator(t) + gate := &gateAI{reachability: "internal_only"} + o.AIGate.App = gate + verified := makeVerified(t, 2) + o.AssessReachabilityParallel(context.Background(), verified) + for _, f := range verified { + if !reflect.DeepEqual(f.Tags, []string{"internal_only"}) { + t.Errorf("tags = %v, want [internal_only]", f.Tags) + } + } + if len(gate.prompts) != 2 { + t.Fatalf("gate called %d times, want 2", len(gate.prompts)) + } + if want := ReachabilitySummary(verified[0]); !strings.Contains(gate.prompts[0], "Finding: ") { + t.Errorf("prompt %q does not embed the summary %q", gate.prompts[0], want) + } + }) + + t.Run("gate failure falls back to requires_auth", func(t *testing.T) { + o, _ := newTestOrchestrator(t) + // Retries would make the test slow; one attempt is enough to observe + // the fallback, so drop the backoff to zero. + o.AIGate.Config.MaxRetries = 1 + o.AIGate.App = &gateAI{fail: true} + verified := makeVerified(t, 2) + o.AssessReachabilityParallel(context.Background(), verified) + for _, f := range verified { + if !reflect.DeepEqual(f.Tags, []string{"requires_auth"}) { + t.Errorf("tags = %v, want [requires_auth]", f.Tags) + } + } + }) + + t.Run("semaphore is min(5, n)", func(t *testing.T) { + o, _ := newTestOrchestrator(t) + gate := &gateAI{reachability: "unreachable", delay: 25 * time.Millisecond} + o.AIGate.App = gate + o.AssessReachabilityParallel(context.Background(), makeVerified(t, 12)) + if got := gate.peakConcurrency(); got > 5 { + t.Errorf("peak concurrency = %d, want <= 5", got) + } + if got := gate.peakConcurrency(); got != 5 { + t.Errorf("peak concurrency = %d, want exactly 5", got) + } + }) + + t.Run("fewer findings than the limit", func(t *testing.T) { + o, _ := newTestOrchestrator(t) + gate := &gateAI{reachability: "unreachable", delay: 20 * time.Millisecond} + o.AIGate.App = gate + o.AssessReachabilityParallel(context.Background(), makeVerified(t, 2)) + if got := gate.peakConcurrency(); got > 2 { + t.Errorf("peak concurrency = %d, want <= 2", got) + } + }) +} + +// --------------------------------------------------------------------------- +// _track_drop / drop summary +// --------------------------------------------------------------------------- + +func TestTrackDrop(t *testing.T) { + o, fake := newTestOrchestrator(t) + o.TrackDrop(context.Background(), "First", nil, "verifier_error") + o.TrackDrop(context.Background(), "Second", strPtr("unverified"), "verdict_unverified") + o.TrackDrop(context.Background(), "Third", strPtr(""), "verifier_error") + + summary := dropSummaryMap(t, o) + if got := summary["demoted_total"]; got != 3 { + t.Errorf("demoted_total = %v, want 3", got) + } + want := map[string]int{"verifier_error": 2, "verdict_unverified": 1} + if got := summary["by_reason"]; !reflect.DeepEqual(got, want) { + t.Errorf("by_reason = %v, want %v", got, want) + } + entries := summary["findings"].([]map[string]any) + if len(entries) != 3 { + t.Fatalf("findings = %d, want 3", len(entries)) + } + if entries[0]["original_verdict"] != nil { + t.Errorf("entries[0].original_verdict = %v, want null", entries[0]["original_verdict"]) + } + if entries[1]["original_verdict"] != "unverified" { + t.Errorf("entries[1].original_verdict = %v", entries[1]["original_verdict"]) + } + + wantNotes := []string{ + "Demoted finding 'First' (verdict=unknown): verifier_error", + "Demoted finding 'Second' (verdict=unverified): verdict_unverified", + // PYTHON TRUTHINESS: an EMPTY verdict also prints "unknown". + "Demoted finding 'Third' (verdict=unknown): verifier_error", + } + if !reflect.DeepEqual(fake.NoteMessages(), wantNotes) { + t.Errorf("notes\n got: %q\nwant: %q", fake.NoteMessages(), wantNotes) + } + // The orchestrator's tags differ from phases' ["prove","drop","demotion"]. + for _, note := range fake.Notes { + if !reflect.DeepEqual(note.Tags, []string{"audit", "prove", "drop"}) { + t.Errorf("tags = %v, want [audit prove drop]", note.Tags) + } + } +} + +// TestRebuildDropSummary is the sweep both run() and _run_prove() perform. +func TestRebuildDropSummary(t *testing.T) { + o, fake := newTestOrchestrator(t) + verified := makeVerified(t, 3) + verified[0].DropReason = strPtr("verifier_error") + verified[1].DropReason = strPtr("") // falsy -> skipped + verified[2].DropReason = nil + + o.rebuildDropSummary(context.Background(), verified) + + if got := dropSummaryMap(t, o)["demoted_total"]; got != 1 { + t.Errorf("demoted_total = %v, want 1 (an empty drop_reason is falsy)", got) + } + if len(fake.Notes) != 1 { + t.Errorf("notes = %d, want 1", len(fake.Notes)) + } +} + +// --------------------------------------------------------------------------- +// _run_dast_verification +// --------------------------------------------------------------------------- + +// TestRunDASTVerification_IsUnreachableInProduction pins the two independent +// reasons DAST never runs, and the behavior of the loop when it is forced. +func TestRunDASTVerification_IsUnreachableInProduction(t *testing.T) { + if enableDast { + t.Fatal("enableDast must be false: AuditInput has no `enable_dast` field, so getattr(...) is always False") + } + + t.Run("no confirmed findings", func(t *testing.T) { + o, fake := newTestOrchestrator(t) + verified := makeVerified(t, 2) + for i := range verified { + verified[i].Verdict = schemas.VerdictInconclusive + } + o.RunDASTVerification(context.Background(), verified) + wantNotes := []string{"No confirmed findings available for DAST step"} + if !reflect.DeepEqual(fake.NoteMessages(), wantNotes) { + t.Errorf("notes = %q, want %q", fake.NoteMessages(), wantNotes) + } + }) + + t.Run("the production seam always raises the arity TypeError", func(t *testing.T) { + o, fake := newTestOrchestrator(t) + verified := makeVerified(t, 2) // the fixture verdict is "confirmed" + o.RunDASTVerification(context.Background(), verified) + for i, f := range verified { + if !contains(f.Tags, "dast_error") { + t.Errorf("verified[%d].tags = %v, want dast_error", i, f.Tags) + } + } + if len(fake.Notes) != 2 { + t.Fatalf("notes = %d, want one per confirmed finding", len(fake.Notes)) + } + for _, note := range fake.Notes { + if !strings.HasPrefix(note.Message, "DAST verifier failed for '") { + t.Errorf("note = %q", note.Message) + } + if !strings.Contains(note.Message, "missing 2 required positional arguments") { + t.Errorf("note %q must carry the arity TypeError", note.Message) + } + if !reflect.DeepEqual(note.Tags, []string{"audit", "prove", "dast", "error"}) { + t.Errorf("tags = %v", note.Tags) + } + } + }) + + t.Run("the ported loop body, with the seam substituted", func(t *testing.T) { + restore := dastVerify + dastVerify = func(context.Context, appx.Harnesser, string, schemas.VerifiedFinding) (dastOutcome, error) { + return dastOutcome{ + ExploitAttempted: true, + ExploitSucceeded: false, + Evidence: "no reflection observed", + Confidence: "medium", + ResponseAnalysis: "the payload was rejected", + }, nil + } + defer func() { dastVerify = restore }() + + o, _ := newTestOrchestrator(t) + verified := makeVerified(t, 1) + proof := schemas.Proof{} + verified[0].Proof = &proof + verified[0].Rationale = "base rationale" + + o.RunDASTVerification(context.Background(), verified) + + if want := []string{"dast_attempted", "dast_not_confirmed"}; !reflect.DeepEqual(verified[0].Tags, want) { + t.Errorf("tags = %v, want %v", verified[0].Tags, want) + } + if want := "base rationale\nDAST: the payload was rejected"; verified[0].Rationale != want { + t.Errorf("rationale = %q, want %q", verified[0].Rationale, want) + } + if verified[0].Proof.PocExecutionOutput == nil { + t.Fatal("poc_execution_output must be filled in") + } + // json.dumps({...}, indent=2) over a dict literal: INSERTION order. + want := "{\n \"exploit_attempted\": true,\n \"exploit_succeeded\": false,\n" + + " \"evidence\": \"no reflection observed\",\n \"confidence\": \"medium\"\n}" + if got := *verified[0].Proof.PocExecutionOutput; got != want { + t.Errorf("poc_execution_output\n got: %q\nwant: %q", got, want) + } + }) +} + +// --------------------------------------------------------------------------- +// run_from_checkpoint +// --------------------------------------------------------------------------- + +// TestRunFromCheckpoint_UnknownPhase pins the ValueError text app.py turns into +// HTTP 400 — including that the message carries the ORIGINAL spelling. +func TestRunFromCheckpoint_UnknownPhase(t *testing.T) { + o, _ := newTestOrchestrator(t) + for _, phase := range []string{"bogus", "Recon!", "", "hunt2"} { + _, err := o.RunFromCheckpoint(context.Background(), phase) + if err == nil { + t.Fatalf("phase %q: want an error", phase) + } + var target *UnknownCheckpointPhaseError + if !errors.As(err, &target) { + t.Fatalf("phase %q: err = %v, want UnknownCheckpointPhaseError", phase, err) + } + if want := "Unknown checkpoint phase: " + phase; err.Error() != want { + t.Errorf("message = %q, want %q", err.Error(), want) + } + } +} + +// TestRunFromCheckpoint_ProveBranch re-runs nothing: it reads all three +// checkpoints and goes straight to GenerateOutput. +func TestRunFromCheckpoint_ProveBranch(t *testing.T) { + compliance.ClearAICache() + o, fake := newTestOrchestrator(t) + fake.AIFn = appx.AIJSON(func(string) (json.RawMessage, error) { + return json.RawMessage(`{"mappings":[],"confidence":"low"}`), nil + }) + + recon := reconFixture(t, "minimal") + hunt := schemas.NewHuntResult() + hunt.TotalRaw = 4 + hunt.StrategiesRun = []string{"injection", "auth"} + verified := makeVerified(t, 2) + + for _, w := range []struct { + phase string + payload any + }{{PhaseRecon, recon}, {PhaseHunt, hunt}, {PhaseProve, verified}} { + if err := o.WriteCheckpoint(w.phase, w.payload); err != nil { + t.Fatalf("WriteCheckpoint(%s): %v", w.phase, err) + } + } + + for _, spelling := range []string{"prove", " PROVE ", "Prove"} { + result, err := o.RunFromCheckpoint(context.Background(), spelling) + if err != nil { + t.Fatalf("RunFromCheckpoint(%q): %v", spelling, err) + } + if result.TotalRawFindings != 4 { + t.Errorf("total_raw_findings = %d, want 4", result.TotalRawFindings) + } + if !reflect.DeepEqual(result.StrategiesUsed, []string{"injection", "auth"}) { + t.Errorf("strategies_used = %v", result.StrategiesUsed) + } + if len(result.Findings) != 2 { + t.Errorf("findings = %d, want 2", len(result.Findings)) + } + } +} + +// TestRunFromCheckpoint_MissingCheckpoint surfaces the file error rather than +// silently producing an empty report. +func TestRunFromCheckpoint_MissingCheckpoint(t *testing.T) { + o, _ := newTestOrchestrator(t) + if _, err := o.RunFromCheckpoint(context.Background(), "prove"); err == nil { + t.Fatal("want an error when checkpoint-recon.json is missing") + } +} + +// dropSummaryMap reads ProveDropSummary as the dict the orchestrator path +// always installs. The field is typed `any` so the app.py path can thread a +// non-dict `.call` value through untouched (see orch.go); every orchestrator +// caller still puts a real dict there. +func dropSummaryMap(t *testing.T, o *AuditOrchestrator) map[string]any { + t.Helper() + summary, ok := o.ProveDropSummary.(map[string]any) + if !ok { + t.Fatalf("ProveDropSummary = %#v, want a map[string]any", o.ProveDropSummary) + } + return summary +} + +// TestGuardedCountersAreSafeToReadWhileAPhaseIsRunning states the lock contract +// as behaviour rather than as a comment. +// +// Validation contract: +// +// - a caller may read agent_invocations / budget_exhausted / total_cost_usd / +// cost_breakdown AT ANY TIME, including while phase goroutines sharing one +// proxy are still incrementing them; +// - the values are the Python ones once the phases have joined. +// +// Python needs no lock (asyncio is single-threaded) and so has no equivalent +// test; this is the obligation the Go fan-out creates. Under -race it fails if +// any of the four accessors, or the writers behind registerInvocation / +// markBudgetExhausted, touches the state unguarded — which is exactly what the +// bare `o.AgentInvocations` and `o.BudgetExhausted` field reads inside +// GenerateOutput used to do. +func TestGuardedCountersAreSafeToReadWhileAPhaseIsRunning(t *testing.T) { + o, fake := newTestOrchestrator(t) + fake.HarnessFn = func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return &harness.Result{CostUSD: floatPtr(0.01)}, nil + } + proxy := o.PhaseProxy(PhaseHunt) + + const writers, perWriter = 4, 25 + stop := make(chan struct{}) + + var reader sync.WaitGroup + reader.Add(1) + go func() { + defer reader.Done() + for { + select { + case <-stop: + return + default: + } + // The four reads GenerateOutput performs. + _ = o.AgentInvocations() + _ = o.BudgetExhausted() + _ = o.TotalCostUSD() + _ = o.CostBreakdown() + } + }() + + var phases sync.WaitGroup + phases.Add(writers) + for i := 0; i < writers; i++ { + go func() { + defer phases.Done() + for j := 0; j < perWriter; j++ { + if _, err := proxy.Harness(context.Background(), "p", nil, nil, harness.Options{}); err != nil { + t.Error(err) + return + } + } + o.markBudgetExhausted() + }() + } + phases.Wait() + close(stop) + reader.Wait() + + if got := o.AgentInvocations(); got != writers*perWriter { + t.Errorf("AgentInvocations = %d, want %d", got, writers*perWriter) + } + if !o.BudgetExhausted() { + t.Error("BudgetExhausted = false, want true") + } +} diff --git a/go/internal/orch/output.go b/go/internal/orch/output.go new file mode 100644 index 0000000..c0b2545 --- /dev/null +++ b/go/internal/orch/output.go @@ -0,0 +1,266 @@ +package orch + +import ( + "context" + "errors" + "strconv" + "strings" + + "github.com/Agent-Field/sec-af/go/internal/compliance" + "github.com/Agent-Field/sec-af/go/internal/gates" + "github.com/Agent-Field/sec-af/go/internal/output" + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" + "github.com/Agent-Field/sec-af/go/internal/scoring" +) + +// severityOrder ports the local `severity_order` map `_generate_output` uses +// for the threshold filter. Note "info" scores 0, which is why a threshold of +// "info" (or an unrecognised threshold) disables filtering entirely. +var severityOrder = map[string]int{ + "critical": 4, + "high": 3, + "medium": 2, + "low": 1, + "info": 0, +} + +// ErrComplianceReportArity is the TypeError orchestrator.py:463 raises. +// +// PYTHON BUG, REPRODUCED. `_generate_output` calls +// +// compliance_report = generate_compliance_report(result, framework) +// +// but output/compliance_report.py declares +// +// def generate_compliance_report(result: SecurityAuditResult) -> str +// +// — ONE positional parameter. VERIFIED against the repo's own interpreter: +// calling it with two arguments raises +// +// TypeError: generate_compliance_report() takes 1 positional argument but 2 were given +// +// so any audit whose `compliance_frameworks` list is non-empty fails at the +// very end of _generate_output, after the SARIF/JSON/Markdown artifacts have +// been produced and after the checkpoint directory has been created, but before +// a single compliance-.md file is written. app.py turns that into +// HTTP 500 with `{"error": "audit execution failed: ..."}`. +// +// DESIGN.md §0.2 is explicit that a Python oddity is reproduced rather than +// improved, so GenerateOutput returns this error on the same input. Fixing it +// (dropping the second argument, or adding the parameter) is a change to the +// PYTHON tree and belongs in a separate PR against src/. +var ErrComplianceReportArity = errors.New("generate_compliance_report() takes 1 positional argument but 2 were given") + +// GenerateOutput ports `_generate_output(recon, hunt, verified)` +// (orchestrator.py:365) — the tail every entry point shares. app.py calls it +// directly after its four `.call`s; run() and run_from_checkpoint() call it +// last. +// +// Steps, in Python's order: +// +// 1. THRESHOLD FILTER. `severity_order.get(self.input.severity_threshold.lower(), 0)`; +// when that is > 0, drop every finding scoring below it. A threshold of +// "info" or an unknown string scores 0 and filters NOTHING. +// 2. PER-FINDING ENRICHMENT, in place: the CWE severity floor, the +// exploitability score, sarif_security_severity mirrored from it, and the +// compliance mappings (static table, with the AI gate as fallback). +// 3. COUNTS. verdict counts over the four Verdict members; severity counts +// seeded with every Severity member so a severity with no findings still +// reports 0. +// 4. NOISE REDUCTION. `not_exploitable / hunt.total_raw * 100`, rounded to 2 +// with Python's banker's rounding. Guarded on total_raw > 0. +// 5. ATTACK CHAINS. PotentialChain -> AttackChain, with `description` taking +// the chain's combined_impact (so description and combined_impact are the +// same string) and mitre_attack_mapping left at None. +// 6. COMPLIANCE GAPS, the SecurityAuditResult, then SARIF, then the JSON and +// Markdown reports whose return values are DISCARDED (`_ = generate_json(...)`) +// — they are produced for their side-effect-free validation only. +// 7. The compliance-report loop, which raises (see ErrComplianceReportArity). +// +// Python parity details worth stating: +// +// - `verified` is REBOUND by the filter, so the caller's slice is not +// truncated — but the surviving findings are the SAME objects and step 2 +// mutates them. Go reproduces both: a new slice header, sharing elements. +// - `self.input.compliance_frameworks or None` passes None for an EMPTY list, +// which makes get_compliance_mappings_hybrid use its default framework set. +// - `commit_sha=self.input.commit_sha or "HEAD"` is Python truthiness: an +// empty string also becomes "HEAD". +// - `timestamp=datetime.now(UTC)` is read here, not at construction. +// - `duration_seconds` is `time.monotonic() - self.started_at`, i.e. the whole +// orchestrator lifetime, not just this function. +// - the budget note fires only when the flag is latched, and reports +// findings_not_verified — which app.py copies from prove_phase's payload. +func (o *AuditOrchestrator) GenerateOutput( + ctx context.Context, + recon schemas.ReconResult, + hunt schemas.HuntResult, + verified []schemas.VerifiedFinding, +) (schemas.SecurityAuditResult, error) { + _ = recon // Python: `_ = recon` — accepted for symmetry, never read. + + // 1. severity threshold + thresholdValue := severityOrder[strings.ToLower(o.Input.SeverityThreshold)] + if thresholdValue > 0 { + filtered := make([]schemas.VerifiedFinding, 0, len(verified)) + for _, finding := range verified { + if severityOrder[strings.ToLower(string(finding.Severity))] >= thresholdValue { + filtered = append(filtered, finding) + } + } + verified = filtered + } + + // 2. per-finding enrichment + frameworks := o.Input.ComplianceFrameworks + if len(frameworks) == 0 { + // Python: `frameworks=self.input.compliance_frameworks or None` + frameworks = nil + } + gate := o.complianceGate() + for i := range verified { + verified[i].Severity = scoring.ApplyCWESeverityFloor(verified[i].CweID, verified[i].Severity) + verified[i].ExploitabilityScore = scoring.ComputeExploitabilityScore(verified[i]) + verified[i].SarifSecuritySeverity = verified[i].ExploitabilityScore + verified[i].Compliance = compliance.GetComplianceMappingsHybrid(ctx, verified[i].CweID, frameworks, gate) + } + + // 3. counts + verdictCounts := map[schemas.Verdict]int{ + schemas.VerdictConfirmed: 0, + schemas.VerdictLikely: 0, + schemas.VerdictInconclusive: 0, + schemas.VerdictNotExploitable: 0, + } + severityCounts := map[string]int{} + for _, severity := range schemas.AllSeverities { + severityCounts[string(severity)] = 0 + } + for _, finding := range verified { + // Python: `verdict_counts[finding.verdict] += 1` — a dict subscript, + // which raises KeyError for a verdict outside the four members. The + // Verdict enum has exactly four, so the Go map write is equivalent. + verdictCounts[finding.Verdict]++ + severityCounts[string(finding.Severity)]++ + } + + // 4. noise reduction + totalRaw := hunt.TotalRaw + notExploitable := verdictCounts[schemas.VerdictNotExploitable] + noiseReduction := 0.0 + if totalRaw > 0 { + noiseReduction = float64(notExploitable) / float64(totalRaw) * 100.0 + } + + // 5. attack chains + chains := make([]schemas.AttackChain, 0, len(hunt.Chains)) + for _, chain := range hunt.Chains { + chains = append(chains, schemas.AttackChain{ + ChainID: chain.ChainID, + Title: chain.Title, + Description: chain.CombinedImpact, + Findings: chain.FindingIDs, + CombinedSeverity: chain.EstimatedSeverity, + CombinedImpact: chain.CombinedImpact, + // mitre_attack_mapping is not passed, so it keeps pydantic's None. + MitreAttackMapping: nil, + }) + } + + if o.BudgetExhausted() { + o.App.Note(ctx, + "Budget exhausted; unverified findings: "+strconv.Itoa(o.FindingsNotVerified), + "audit", "budget", "exhausted") + } + + // 6. the result + complianceGaps := compliance.GetComplianceGaps(verified) + + commitSha := "HEAD" + if o.Input.CommitSha != nil && *o.Input.CommitSha != "" { + commitSha = *o.Input.CommitSha + } + branch := o.Input.Branch + + costBreakdown := o.CostBreakdown() + roundedBreakdown := make(map[string]float64, len(costBreakdown)) + for phase, cost := range costBreakdown { + roundedBreakdown[phase] = pyfmt.Round(cost, 4) + } + + result := schemas.NewSecurityAuditResult() + result.Repository = o.Input.RepoURL + result.CommitSha = commitSha + result.Branch = &branch + result.Timestamp = schemas.NewTimestamp(nowUTC()) + result.DepthProfile = o.Input.Depth + result.StrategiesUsed = hunt.StrategiesRun + result.Provider = "harness" + result.Findings = verified + result.AttackChains = chains + result.TotalRawFindings = totalRaw + result.Confirmed = verdictCounts[schemas.VerdictConfirmed] + result.Likely = verdictCounts[schemas.VerdictLikely] + result.Inconclusive = verdictCounts[schemas.VerdictInconclusive] + result.NotExploitable = notExploitable + result.NoiseReductionPct = pyfmt.Round(noiseReduction, 2) + result.BySeverity = severityCounts + result.ComplianceGaps = complianceGaps + result.DurationSeconds = o.elapsedSeconds() + result.AgentInvocations = o.AgentInvocations() + result.CostUsd = pyfmt.Round(o.TotalCostUSD(), 4) + result.CostBreakdown = roundedBreakdown + result.Metadata = map[string]any{ + "findings_not_verified": o.FindingsNotVerified, + "prove_drop_summary": o.ProveDropSummary, + } + result.Sarif = "" + + result.Sarif = output.GenerateSarif(result) + // Python: `_ = generate_json(result, pretty=True)` and `_ = generate_report(result)`. + // Both return values are discarded; the calls stay because they exercise the + // same rendering the caller may ask for later, and because dropping them + // would change nothing except which code runs. + _ = output.GenerateJSON(result, true) + _ = output.GenerateReport(result) + + // 7. per-framework compliance reports — see ErrComplianceReportArity. + if len(o.Input.ComplianceFrameworks) > 0 { + if err := mkdirCheckpointDir(o.CheckpointDir); err != nil { + return result, err + } + // Python raises on the FIRST loop iteration, so no report file is ever + // written. The built result is handed back alongside the error purely so + // a Go caller can inspect it; Python has no return value on this path. + return result, ErrComplianceReportArity + } + + return result, nil +} + +// complianceGate adapts the orchestrator's AIGateWrapper to the narrow seam +// compliance.GetComplianceMappingsHybrid needs: +// +// suggestion = await ai_gate.invoke(user=prompt, schema=ComplianceGate) +// +// A nil AIGate yields a nil seam, which is Python's `ai_gate=None` — the static +// table then has no fallback. +func (o *AuditOrchestrator) complianceGate() compliance.AIGateLike { + if o.AIGate == nil { + return nil + } + return compliance.AIGateFunc(func(ctx context.Context, user string) (schemas.ComplianceGate, error) { + return gates.Invoke[schemas.ComplianceGate](ctx, o.AIGate, user, "") + }) +} + +// elapsedSeconds is `time.monotonic() - self.started_at`. +// +// It reads the clock through nowMonotonic (orch.go) rather than time.Since so a +// test — and scripts/gen_golden_phases.py's Python counterpart, which swaps the +// orchestrator module's `time` name — can pin the elapsed value that lands in +// the progress notes and in duration_seconds. +func (o *AuditOrchestrator) elapsedSeconds() float64 { + return nowMonotonic().Sub(o.StartedAt).Seconds() +} diff --git a/go/internal/orch/output_test.go b/go/internal/orch/output_test.go new file mode 100644 index 0000000..c943cdc --- /dev/null +++ b/go/internal/orch/output_test.go @@ -0,0 +1,459 @@ +package orch + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/compliance" + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" + "github.com/Agent-Field/sec-af/go/internal/scoring" +) + +// verifiedWith builds a VerifiedFinding from the fixture with the given +// identity, verdict and severity. +func verifiedWith(t *testing.T, id string, verdict schemas.Verdict, severity schemas.Severity, cwe string) schemas.VerifiedFinding { + t.Helper() + f := readJSON[schemas.VerifiedFinding](t, "verified_fixture.json") + f.ID = id + f.Fingerprint = "fp-" + id + f.Verdict = verdict + f.Severity = severity + f.CweID = cwe + f.Tags = []string{} + return f +} + +// TestGenerateOutput_SeverityThreshold walks the filter, including the two +// spellings that disable it. +func TestGenerateOutput_SeverityThreshold(t *testing.T) { + compliance.ClearAICache() + + all := []schemas.VerifiedFinding{ + verifiedWith(t, "crit", schemas.VerdictConfirmed, schemas.SeverityCritical, "CWE-79"), + verifiedWith(t, "high", schemas.VerdictLikely, schemas.SeverityHigh, "CWE-79"), + verifiedWith(t, "med", schemas.VerdictInconclusive, schemas.SeverityMedium, "CWE-79"), + verifiedWith(t, "low", schemas.VerdictInconclusive, schemas.SeverityLow, "CWE-79"), + verifiedWith(t, "info", schemas.VerdictNotExploitable, schemas.SeverityInfo, "CWE-79"), + } + + cases := []struct { + threshold string + wantIDs []string + }{ + {"critical", []string{"crit"}}, + {"HIGH", []string{"crit", "high"}}, + {"medium", []string{"crit", "high", "med"}}, + {"low", []string{"crit", "high", "med", "low"}}, + // "info" scores 0, so the filter is skipped entirely. + {"info", []string{"crit", "high", "med", "low", "info"}}, + // An unrecognised threshold also scores 0 — no filtering, no error. + {"bogus", []string{"crit", "high", "med", "low", "info"}}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.threshold, func(t *testing.T) { + o, _ := newTestOrchestrator(t, func(in *schemas.AuditInput) { in.SeverityThreshold = tc.threshold }) + verified := append([]schemas.VerifiedFinding(nil), all...) + + result, err := o.GenerateOutput(context.Background(), schemas.NewReconResult(), schemas.NewHuntResult(), verified) + if err != nil { + t.Fatalf("GenerateOutput: %v", err) + } + got := make([]string, 0, len(result.Findings)) + for _, f := range result.Findings { + got = append(got, f.ID) + } + if !reflect.DeepEqual(got, tc.wantIDs) { + t.Errorf("threshold %q kept %v, want %v", tc.threshold, got, tc.wantIDs) + } + }) + } +} + +// TestGenerateOutput_EnrichesEveryFinding pins the four per-finding mutations. +func TestGenerateOutput_EnrichesEveryFinding(t *testing.T) { + compliance.ClearAICache() + o, _ := newTestOrchestrator(t) + + // CWE-89 carries a severity floor, so a "low" finding must come back raised. + finding := verifiedWith(t, "sqli", schemas.VerdictConfirmed, schemas.SeverityLow, "CWE-89") + before := finding + + result, err := o.GenerateOutput(context.Background(), schemas.NewReconResult(), schemas.NewHuntResult(), + []schemas.VerifiedFinding{finding}) + if err != nil { + t.Fatalf("GenerateOutput: %v", err) + } + got := result.Findings[0] + + wantSeverity := scoring.ApplyCWESeverityFloor(before.CweID, before.Severity) + if got.Severity != wantSeverity { + t.Errorf("severity = %q, want the CWE floor %q", got.Severity, wantSeverity) + } + if got.ExploitabilityScore == 0 { + t.Error("exploitability_score must be recomputed, not left at the input's 0") + } + if got.SarifSecuritySeverity != got.ExploitabilityScore { + t.Errorf("sarif_security_severity = %v, want it mirrored from exploitability_score %v", + got.SarifSecuritySeverity, got.ExploitabilityScore) + } + if got.Compliance == nil { + t.Error("compliance must be replaced by the mapping lookup, never left nil") + } + if len(got.Compliance) == 0 { + t.Error("CWE-89 is in the static compliance table; want at least one mapping") + } +} + +// TestGenerateOutput_CountsAndNoiseReduction pins the counters, the seeded +// by_severity map and the rounded percentage. +func TestGenerateOutput_CountsAndNoiseReduction(t *testing.T) { + compliance.ClearAICache() + // AuditInput's default severity_threshold is "low", which scores 1 and + // would drop the "info" finding before the counters ever see it. "info" + // scores 0 and disables the filter, which is what this test needs. + o, _ := newTestOrchestrator(t, func(in *schemas.AuditInput) { in.SeverityThreshold = "info" }) + + verified := []schemas.VerifiedFinding{ + verifiedWith(t, "a", schemas.VerdictConfirmed, schemas.SeverityHigh, "CWE-79"), + verifiedWith(t, "b", schemas.VerdictConfirmed, schemas.SeverityHigh, "CWE-79"), + verifiedWith(t, "c", schemas.VerdictLikely, schemas.SeverityMedium, "CWE-79"), + verifiedWith(t, "d", schemas.VerdictInconclusive, schemas.SeverityLow, "CWE-79"), + verifiedWith(t, "e", schemas.VerdictNotExploitable, schemas.SeverityInfo, "CWE-79"), + } + hunt := schemas.NewHuntResult() + hunt.TotalRaw = 7 + + result, err := o.GenerateOutput(context.Background(), schemas.NewReconResult(), hunt, verified) + if err != nil { + t.Fatalf("GenerateOutput: %v", err) + } + + if result.Confirmed != 2 || result.Likely != 1 || result.Inconclusive != 1 || result.NotExploitable != 1 { + t.Errorf("verdict counts = %d/%d/%d/%d, want 2/1/1/1", + result.Confirmed, result.Likely, result.Inconclusive, result.NotExploitable) + } + if result.TotalRawFindings != 7 { + t.Errorf("total_raw_findings = %d, want 7 (it comes from hunt.total_raw)", result.TotalRawFindings) + } + + // by_severity is seeded with EVERY Severity member, so a severity with no + // findings still reports 0. The CWE-79 floor may raise some entries, so the + // assertion is on the key set plus the total. + if len(result.BySeverity) != len(schemas.AllSeverities) { + t.Errorf("by_severity keys = %v, want one per Severity member", result.BySeverity) + } + total := 0 + for _, severity := range schemas.AllSeverities { + count, present := result.BySeverity[string(severity)] + if !present { + t.Errorf("by_severity is missing %q", severity) + } + total += count + } + if total != len(verified) { + t.Errorf("by_severity sums to %d, want %d", total, len(verified)) + } + + // 1 not_exploitable of 7 raw -> 14.285714...% -> round(x, 2) = 14.29. + if want := pyfmt.Round(1.0/7.0*100.0, 2); result.NoiseReductionPct != want { + t.Errorf("noise_reduction_pct = %v, want %v", result.NoiseReductionPct, want) + } + if want := 14.29; result.NoiseReductionPct != want { + t.Errorf("noise_reduction_pct = %v, want %v", result.NoiseReductionPct, want) + } +} + +// TestGenerateOutput_NoiseReductionGuard: total_raw of 0 yields 0.0, not a +// division by zero. +func TestGenerateOutput_NoiseReductionGuard(t *testing.T) { + compliance.ClearAICache() + o, _ := newTestOrchestrator(t, func(in *schemas.AuditInput) { in.SeverityThreshold = "info" }) + verified := []schemas.VerifiedFinding{ + verifiedWith(t, "a", schemas.VerdictNotExploitable, schemas.SeverityInfo, "CWE-79"), + } + result, err := o.GenerateOutput(context.Background(), schemas.NewReconResult(), schemas.NewHuntResult(), verified) + if err != nil { + t.Fatalf("GenerateOutput: %v", err) + } + if result.NoiseReductionPct != 0 { + t.Errorf("noise_reduction_pct = %v, want 0", result.NoiseReductionPct) + } +} + +// TestGenerateOutput_AttackChains pins the PotentialChain -> AttackChain +// projection, including `description = combined_impact`. +func TestGenerateOutput_AttackChains(t *testing.T) { + compliance.ClearAICache() + o, _ := newTestOrchestrator(t) + + hunt := schemas.NewHuntResult() + hunt.Chains = []schemas.PotentialChain{{ + ChainID: "chain-1", + Title: "SSRF to RCE", + FindingIDs: []string{"a", "b"}, + CombinedImpact: "internal metadata service reachable, then RCE", + EstimatedSeverity: schemas.SeverityCritical, + }} + + result, err := o.GenerateOutput(context.Background(), schemas.NewReconResult(), hunt, nil) + if err != nil { + t.Fatalf("GenerateOutput: %v", err) + } + if len(result.AttackChains) != 1 { + t.Fatalf("attack_chains = %d, want 1", len(result.AttackChains)) + } + chain := result.AttackChains[0] + if chain.ChainID != "chain-1" || chain.Title != "SSRF to RCE" { + t.Errorf("chain identity = %+v", chain) + } + if chain.Description != chain.CombinedImpact { + t.Errorf("description = %q, want it to equal combined_impact %q", chain.Description, chain.CombinedImpact) + } + if !reflect.DeepEqual(chain.Findings, []string{"a", "b"}) { + t.Errorf("findings = %v", chain.Findings) + } + if chain.CombinedSeverity != schemas.SeverityCritical { + t.Errorf("combined_severity = %q", chain.CombinedSeverity) + } + if chain.MitreAttackMapping != nil { + t.Errorf("mitre_attack_mapping = %v, want nil (it is not passed)", chain.MitreAttackMapping) + } +} + +// TestGenerateOutput_ResultEnvelope pins the scalar fields and the metadata. +func TestGenerateOutput_ResultEnvelope(t *testing.T) { + compliance.ClearAICache() + + base := time.Now() + restoreMono := nowMonotonic + nowMonotonic = func() time.Time { return base.Add(3 * time.Second) } + defer func() { nowMonotonic = restoreMono }() + + pinned, _ := time.Parse(time.RFC3339Nano, "2026-01-02T03:04:05.123456Z") + restoreUTC := nowUTC + nowUTC = func() time.Time { return pinned.UTC() } + defer func() { nowUTC = restoreUTC }() + + sha := "deadbeef" + o, _ := newTestOrchestrator(t, func(in *schemas.AuditInput) { + in.RepoURL = "https://example.invalid/acme/api" + in.Branch = "release/1.2" + in.CommitSha = &sha + in.Depth = "thorough" + }) + o.StartedAt = base + o.SetAgentInvocations(17) + o.FindingsNotVerified = 4 + o.registerCost(PhaseRecon, floatPtr(0.123456)) + o.registerCost(PhaseHunt, floatPtr(1.987654)) + o.TrackDrop(context.Background(), "dropped", nil, "verifier_error") + + hunt := schemas.NewHuntResult() + hunt.StrategiesRun = []string{"injection", "auth"} + + result, err := o.GenerateOutput(context.Background(), schemas.NewReconResult(), hunt, nil) + if err != nil { + t.Fatalf("GenerateOutput: %v", err) + } + + if result.Repository != "https://example.invalid/acme/api" { + t.Errorf("repository = %q", result.Repository) + } + if result.CommitSha != "deadbeef" { + t.Errorf("commit_sha = %q", result.CommitSha) + } + if result.Branch == nil || *result.Branch != "release/1.2" { + t.Errorf("branch = %v", result.Branch) + } + if got := result.Timestamp.String(); got != "2026-01-02T03:04:05.123456+00:00" { + t.Errorf("timestamp = %q", got) + } + if result.DepthProfile != "thorough" { + t.Errorf("depth_profile = %q", result.DepthProfile) + } + if result.Provider != "harness" { + t.Errorf("provider = %q, want harness", result.Provider) + } + if !reflect.DeepEqual(result.StrategiesUsed, []string{"injection", "auth"}) { + t.Errorf("strategies_used = %v", result.StrategiesUsed) + } + if result.AgentInvocations != 17 { + t.Errorf("agent_invocations = %d, want 17", result.AgentInvocations) + } + if result.DurationSeconds != 3 { + t.Errorf("duration_seconds = %v, want 3", result.DurationSeconds) + } + // round(total, 4) and round(each phase, 4) — Python's banker's rounding. + if want := pyfmt.Round(0.123456+1.987654, 4); result.CostUsd != want { + t.Errorf("cost_usd = %v, want %v", result.CostUsd, want) + } + wantBreakdown := map[string]float64{ + "recon": pyfmt.Round(0.123456, 4), + "hunt": pyfmt.Round(1.987654, 4), + "prove": 0, + } + if !reflect.DeepEqual(result.CostBreakdown, wantBreakdown) { + t.Errorf("cost_breakdown = %v, want %v", result.CostBreakdown, wantBreakdown) + } + if got := result.Metadata["findings_not_verified"]; got != 4 { + t.Errorf("metadata.findings_not_verified = %v, want 4", got) + } + if got, ok := result.Metadata["prove_drop_summary"].(map[string]any); !ok || got["demoted_total"] != 1 { + t.Errorf("metadata.prove_drop_summary = %#v", result.Metadata["prove_drop_summary"]) + } + if result.Sarif == "" { + t.Error("sarif must be filled in by generate_sarif") + } + var sarif map[string]any + if err := json.Unmarshal([]byte(result.Sarif), &sarif); err != nil { + t.Errorf("sarif is not valid JSON: %v", err) + } + // policy_violations is never passed, so it keeps the pydantic default. + if result.PolicyViolations == nil || len(result.PolicyViolations) != 0 { + t.Errorf("policy_violations = %v, want []", result.PolicyViolations) + } +} + +// TestGenerateOutput_CommitShaFallback: an absent OR empty commit_sha becomes +// "HEAD" (Python truthiness). +func TestGenerateOutput_CommitShaFallback(t *testing.T) { + compliance.ClearAICache() + for _, sha := range []*string{nil, strPtr("")} { + o, _ := newTestOrchestrator(t, func(in *schemas.AuditInput) { in.CommitSha = sha }) + result, err := o.GenerateOutput(context.Background(), schemas.NewReconResult(), schemas.NewHuntResult(), nil) + if err != nil { + t.Fatalf("GenerateOutput: %v", err) + } + if result.CommitSha != "HEAD" { + t.Errorf("commit_sha = %q, want HEAD", result.CommitSha) + } + } +} + +// TestGenerateOutput_BudgetNote fires only when the flag latched. +func TestGenerateOutput_BudgetNote(t *testing.T) { + compliance.ClearAICache() + + t.Run("silent when the budget held", func(t *testing.T) { + o, fake := newTestOrchestrator(t) + if _, err := o.GenerateOutput(context.Background(), schemas.NewReconResult(), schemas.NewHuntResult(), nil); err != nil { + t.Fatalf("GenerateOutput: %v", err) + } + for _, note := range fake.Notes { + if note.Message[:6] == "Budget" { + t.Errorf("unexpected budget note %q", note.Message) + } + } + }) + + t.Run("reports the unverified count when exhausted", func(t *testing.T) { + o, fake := newTestOrchestrator(t) + o.markBudgetExhausted() + o.FindingsNotVerified = 12 + if _, err := o.GenerateOutput(context.Background(), schemas.NewReconResult(), schemas.NewHuntResult(), nil); err != nil { + t.Fatalf("GenerateOutput: %v", err) + } + want := "Budget exhausted; unverified findings: 12" + found := false + for _, note := range fake.Notes { + if note.Message == want { + found = true + if !reflect.DeepEqual(note.Tags, []string{"audit", "budget", "exhausted"}) { + t.Errorf("tags = %v", note.Tags) + } + } + } + if !found { + t.Errorf("missing %q in %q", want, fake.NoteMessages()) + } + }) +} + +// TestGenerateOutput_ComplianceFrameworksHitThePythonBug reproduces +// orchestrator.py:463's TypeError — see ErrComplianceReportArity. The +// checkpoint directory IS created first, and no compliance-.md file +// is ever written. +func TestGenerateOutput_ComplianceFrameworksHitThePythonBug(t *testing.T) { + compliance.ClearAICache() + o, _ := newTestOrchestrator(t, func(in *schemas.AuditInput) { + in.ComplianceFrameworks = []string{"SOC2", "PCI-DSS"} + }) + + result, err := o.GenerateOutput(context.Background(), schemas.NewReconResult(), schemas.NewHuntResult(), + []schemas.VerifiedFinding{verifiedWith(t, "a", schemas.VerdictConfirmed, schemas.SeverityHigh, "CWE-89")}) + if !errors.Is(err, ErrComplianceReportArity) { + t.Fatalf("err = %v, want ErrComplianceReportArity", err) + } + // The result is still built (Python gets that far too, it just cannot + // return it). + if result.Sarif == "" { + t.Error("the SARIF artifact is produced before the failure") + } + if _, statErr := os.Stat(o.CheckpointDir); statErr != nil { + t.Errorf("the checkpoint dir must be created before the failure: %v", statErr) + } + for _, framework := range []string{"SOC2", "PCI-DSS"} { + path := filepath.Join(o.CheckpointDir, "compliance-"+framework+".md") + if _, statErr := os.Stat(path); statErr == nil { + t.Errorf("%s must NOT be written — Python raises on the first iteration", path) + } + } +} + +// TestGenerateOutput_ComplianceFrameworksSelectTheMappings: a non-empty list is +// passed through to get_compliance_mappings_hybrid; an empty one becomes None +// so the default framework set applies. +func TestGenerateOutput_ComplianceFrameworksSelectTheMappings(t *testing.T) { + compliance.ClearAICache() + + // Empty list -> None -> every framework the static table knows for CWE-89. + o, _ := newTestOrchestrator(t) + result, err := o.GenerateOutput(context.Background(), schemas.NewReconResult(), schemas.NewHuntResult(), + []schemas.VerifiedFinding{verifiedWith(t, "a", schemas.VerdictConfirmed, schemas.SeverityHigh, "CWE-89")}) + if err != nil { + t.Fatalf("GenerateOutput: %v", err) + } + unfiltered := result.Findings[0].Compliance + if len(unfiltered) == 0 { + t.Fatal("CWE-89 must map to at least one control") + } + want := compliance.GetComplianceMappings("CWE-89", nil) + if !reflect.DeepEqual(unfiltered, want) { + t.Errorf("compliance = %v, want the unfiltered table %v", unfiltered, want) + } +} + +// TestGenerateOutput_AIGateFallbackForUnknownCWE: a CWE the static table does +// not know reaches the AI gate through the orchestrator's adapter. +func TestGenerateOutput_AIGateFallbackForUnknownCWE(t *testing.T) { + compliance.ClearAICache() + defer compliance.ClearAICache() + + o, fake := newTestOrchestrator(t) + fake.AIFn = appx.AIJSON(func(string) (json.RawMessage, error) { + return json.RawMessage(`{"mappings":[{"framework":"SOC2","control_id":"CC9.9","control_name":"Invented"}],"confidence":"low"}`), nil + }) + + result, err := o.GenerateOutput(context.Background(), schemas.NewReconResult(), schemas.NewHuntResult(), + []schemas.VerifiedFinding{verifiedWith(t, "a", schemas.VerdictConfirmed, schemas.SeverityHigh, "CWE-999999")}) + if err != nil { + t.Fatalf("GenerateOutput: %v", err) + } + got := result.Findings[0].Compliance + if len(got) != 1 || got[0].ControlID != "CC9.9" { + t.Errorf("compliance = %v, want the AI gate's single mapping", got) + } + if len(fake.AIs) == 0 { + t.Error("the AI gate must be consulted for an unknown CWE") + } +} diff --git a/go/internal/orch/progress.go b/go/internal/orch/progress.go new file mode 100644 index 0000000..e4ff7c2 --- /dev/null +++ b/go/internal/orch/progress.go @@ -0,0 +1,141 @@ +package orch + +import ( + "context" + + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// EmitProgress ports `_emit_progress` (orchestrator.py:636): +// +// elapsed = time.monotonic() - self.started_at +// safe_total = max(1, agents_total) +// phase_progress = min(1.0, agents_completed / safe_total) +// estimated_total = elapsed / phase_progress if phase_progress > 0 else elapsed +// progress = AuditProgress( +// phase=phase, phase_progress=phase_progress, +// agents_total=agents_total, agents_completed=agents_completed, +// agents_running=max(0, agents_total - agents_completed), +// findings_so_far=findings_so_far, elapsed_seconds=elapsed, +// estimated_remaining_seconds=max(0.0, estimated_total - elapsed), +// cost_so_far_usd=round(self.total_cost_usd, 4)) +// self.app.note(progress.model_dump_json(), tags=["audit", "progress", phase]) +// +// Python parity: +// +// - `agents_completed / safe_total` is TRUE division, so 1/2 is 0.5, not 0; +// - `safe_total = max(1, agents_total)` guards a zero total, and the outer +// min clamps a completed count that overshoots; +// - `estimated_total` divides by phase_progress, so a phase with zero progress +// reports an estimated remaining of 0.0 rather than infinity; +// - `round(self.total_cost_usd, 4)` is Python's BANKER'S rounding +// (pyfmt.Round), not Go's strconv default. +// +// The note MESSAGE is `progress.model_dump_json()` — pydantic's serializer, not +// json.dumps: no space after `:` or `,`, and a float field always carries a +// decimal point (`"phase_progress":1.0`). pyfmt.DumpsModelJSON is that +// spelling; a Go `json.Marshal` would emit `1` and break byte parity with the +// Python node's notes. +func (o *AuditOrchestrator) EmitProgress(ctx context.Context, phase string, agentsTotal, agentsCompleted, findingsSoFar int) { + progress := o.BuildProgress(phase, agentsTotal, agentsCompleted, findingsSoFar) + o.App.Note(ctx, pyfmt.DumpsModelJSON(progress), "audit", "progress", phase) +} + +// BuildProgress is EmitProgress without the note — the AuditProgress value it +// serializes. Split out so a test can assert the arithmetic without scripting a +// fake, and so the note message can be golden-compared. +func (o *AuditOrchestrator) BuildProgress(phase string, agentsTotal, agentsCompleted, findingsSoFar int) schemas.AuditProgress { + elapsed := o.elapsedSeconds() + + safeTotal := agentsTotal + if safeTotal < 1 { + safeTotal = 1 + } + phaseProgress := float64(agentsCompleted) / float64(safeTotal) + if phaseProgress > 1.0 { + phaseProgress = 1.0 + } + + estimatedTotal := elapsed + if phaseProgress > 0 { + estimatedTotal = elapsed / phaseProgress + } + remaining := estimatedTotal - elapsed + if remaining < 0 { + remaining = 0.0 + } + + agentsRunning := agentsTotal - agentsCompleted + if agentsRunning < 0 { + agentsRunning = 0 + } + + return schemas.AuditProgress{ + Phase: phase, + PhaseProgress: phaseProgress, + AgentsTotal: agentsTotal, + AgentsCompleted: agentsCompleted, + AgentsRunning: agentsRunning, + FindingsSoFar: findingsSoFar, + ElapsedSeconds: elapsed, + EstimatedRemainingSeconds: remaining, + CostSoFarUsd: pyfmt.Round(o.TotalCostUSD(), 4), + } +} + +// TrackDrop ports `AuditOrchestrator._track_drop` (orchestrator.py:654) — the +// demotion bookkeeping written into prove_drop_summary, plus its note. +// +// It is the same bookkeeping as reasoners/phases.py's module-level `_track_drop` +// with ONE difference that is easy to miss: the note tags here are +// ["audit", "prove", "drop"], where phases uses ["prove", "drop", "demotion"]. +// +// Python parity: +// +// - `setdefault` recreates a missing "by_reason"/"findings" entry, which the +// Go type assertions reproduce; +// - the recorded `original_verdict` is None (JSON null) when absent; +// - the note's `original_verdict or 'unknown'` is PYTHON TRUTHINESS, so an +// empty string also prints "unknown". +func (o *AuditOrchestrator) TrackDrop(ctx context.Context, findingTitle string, originalVerdict *string, reason string) { + // ProveDropSummary is typed `any` so the app.py path can thread a + // non-dict `.call` value through untouched (see orch.go). Every caller of + // TrackDrop is on the ORCHESTRATOR path, where rebuildDropSummary has just + // installed a real dict, so the assertion always succeeds; the fallback + // mirrors the pre-existing nil guard rather than reproducing the + // AttributeError `None.setdefault(...)` would raise in Python. + summary, isMap := o.ProveDropSummary.(map[string]any) + if !isMap { + summary = NewDropSummary() + o.ProveDropSummary = summary + } + + total, _ := summary["demoted_total"].(int) + summary["demoted_total"] = total + 1 + + byReason, ok := summary["by_reason"].(map[string]int) + if !ok { + byReason = map[string]int{} + summary["by_reason"] = byReason + } + byReason[reason]++ + + findings, ok := summary["findings"].([]map[string]any) + if !ok { + findings = []map[string]any{} + } + entry := map[string]any{"title": findingTitle, "original_verdict": nil, "reason": reason} + if originalVerdict != nil { + entry["original_verdict"] = *originalVerdict + } + summary["findings"] = append(findings, entry) + + verdictLabel := "unknown" + if originalVerdict != nil && *originalVerdict != "" { + verdictLabel = *originalVerdict + } + o.App.Note(ctx, + "Demoted finding '"+findingTitle+"' (verdict="+verdictLabel+"): "+reason, + "audit", "prove", "drop") +} diff --git a/go/internal/orch/reachability.go b/go/internal/orch/reachability.go new file mode 100644 index 0000000..e7c15df --- /dev/null +++ b/go/internal/orch/reachability.go @@ -0,0 +1,126 @@ +package orch + +import ( + "context" + "strconv" + "sync" + + "golang.org/x/sync/semaphore" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// reachabilityTags ports the `reachability_tags` set: a finding already +// carrying ANY of these needs no assessment. +var reachabilityTags = map[string]struct{}{ + "externally_reachable": {}, + "requires_auth": {}, + "internal_only": {}, + "unreachable": {}, +} + +// ReachabilitySummary builds the exact prompt body `_assess_one` hands to +// AIGateWrapper.assess_reachability (orchestrator.py:580): +// +// summary = (f"Finding: {finding.title}\n" +// f"Description: {finding.description}\n" +// f"CWE: {finding.cwe_id}\n" +// f"File: {finding.location.file_path}:{finding.location.start_line}\n" +// f"Verdict: {finding.verdict.value}") +// +// No trailing newline. `verdict.value` is the enum's string value, which the Go +// enum already is. It is a separate function so the golden test can compare the +// bytes without scripting a gate. +func ReachabilitySummary(finding schemas.VerifiedFinding) string { + return "Finding: " + finding.Title + "\n" + + "Description: " + finding.Description + "\n" + + "CWE: " + finding.CweID + "\n" + + "File: " + finding.Location.FilePath + ":" + strconv.Itoa(finding.Location.StartLine) + "\n" + + "Verdict: " + string(finding.Verdict) +} + +// AssessReachabilityParallel ports `_assess_reachability_parallel(verified)` +// (orchestrator.py:568): +// +// needs_assessment = [f for f in verified if not any(tag in reachability_tags for tag in f.tags)] +// if not needs_assessment: return +// semaphore = asyncio.Semaphore(min(5, len(needs_assessment))) +// async def _assess_one(finding): +// async with semaphore: +// try: +// gate_result = await self.ai_gate.assess_reachability(summary) +// finding.tags.append(gate_result.reachability) +// except Exception: +// finding.tags.append("requires_auth") # safe default +// await asyncio.gather(*[_assess_one(f) for f in needs_assessment]) +// +// Python parity: +// +// - the semaphore is `min(5, len(needs_assessment))`, NOT max(1, ...): the +// early return above guarantees the count is at least 1, so the limit is +// between 1 and 5. +// - EVERY failure — a gate error, a malformed response, anything — appends the +// literal "requires_auth". That is the documented safe default: an +// unassessable finding is treated as authenticated-only rather than +// internet-facing, which lowers its reachability multiplier in scoring. +// - the gate's `reachability` string is appended VERBATIM, with no validation. +// A gate that answers "maybe" puts "maybe" in the tags, and +// scoring.reachabilityMultiplier then falls through to its default. +// - findings are MUTATED IN PLACE. The Go port takes the slice and indexes +// into it, so the caller sees the tags without a return value — the same +// aliasing Python has. +// +// Concurrency: each goroutine appends to a DIFFERENT element's Tags slice, so +// no lock is needed around the append itself; the WaitGroup provides the +// happens-before edge the caller needs to read them. +func (o *AuditOrchestrator) AssessReachabilityParallel(ctx context.Context, verified []schemas.VerifiedFinding) { + needs := make([]int, 0, len(verified)) + for i := range verified { + if !hasReachabilityTag(verified[i].Tags) { + needs = append(needs, i) + } + } + if len(needs) == 0 { + return + } + + limit := 5 + if len(needs) < limit { + limit = len(needs) + } + sem := semaphore.NewWeighted(int64(limit)) + + var wg sync.WaitGroup + for _, idx := range needs { + idx := idx + wg.Add(1) + go func() { + defer wg.Done() + if err := sem.Acquire(ctx, 1); err != nil { + // A cancelled context is one of the exceptions the bare + // `except Exception` swallows. + verified[idx].Tags = append(verified[idx].Tags, "requires_auth") + return + } + defer sem.Release(1) + + gateResult, err := o.AIGate.AssessReachability(ctx, ReachabilitySummary(verified[idx])) + if err != nil { + verified[idx].Tags = append(verified[idx].Tags, "requires_auth") + return + } + verified[idx].Tags = append(verified[idx].Tags, gateResult.Reachability) + }() + } + wg.Wait() +} + +// hasReachabilityTag ports `any(tag in reachability_tags for tag in f.tags)`. +func hasReachabilityTag(tags []string) bool { + for _, tag := range tags { + if _, ok := reachabilityTags[tag]; ok { + return true + } + } + return false +} diff --git a/go/internal/orch/run.go b/go/internal/orch/run.go new file mode 100644 index 0000000..39f1038 --- /dev/null +++ b/go/internal/orch/run.go @@ -0,0 +1,678 @@ +package orch + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "golang.org/x/sync/errgroup" + + huntagent "github.com/Agent-Field/sec-af/go/internal/agents/hunt" + proveagent "github.com/Agent-Field/sec-af/go/internal/agents/prove" + reconagent "github.com/Agent-Field/sec-af/go/internal/agents/recon" + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/config" + "github.com/Agent-Field/sec-af/go/internal/phases" + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// enableDast ports `getattr(self.input, "enable_dast", False)` — the guard on +// both DAST branches. +// +// PYTHON QUIRK, REPRODUCED. app.py's `audit` reasoner accepts an `enable_dast` +// parameter and passes it to the AuditInput constructor: +// +// audit_input = AuditInput(..., enable_dast=enable_dast) +// +// but schemas/input.py declares the field as `dast_enabled`, and pydantic's +// default `extra="ignore"` silently DROPS the unknown keyword. VERIFIED: +// `hasattr(AuditInput(repo_url="x", enable_dast=True), "enable_dast")` is False. +// So `getattr(self.input, "enable_dast", False)` is ALWAYS False and neither +// DAST branch has ever executed in production. +// +// It is a package variable rather than a constant so a test can flip it and +// exercise the ported branch; production never assigns it. +var enableDast = false + +// ErrDastVerifierArity is the TypeError orchestrator.py:341 raises. +// +// PYTHON BUG, REPRODUCED. `_run_dast_verification` calls +// +// await run_dast_verifier(_PhaseHarnessProxy(self, "prove"), str(self.repo_path), finding) +// +// with THREE positional arguments, while agents/prove/dast_verifier.py declares +// +// async def run_dast_verifier(app, repo_path, finding, exploit_payload, depth) +// +// — five required parameters. The call raises before the agent runs, the +// surrounding `except Exception` catches it, and the finding is tagged +// "dast_error". (Two further mismatches sit behind it: `finding` is a +// VerifiedFinding where a RawFinding is expected, and the result attributes the +// loop body reads — exploit_attempted, exploit_succeeded, evidence, confidence, +// response_analysis — do not exist on DastVerificationResult, which carries +// payload_sent / response_summary / exploit_confirmed / safety_notes.) +var ErrDastVerifierArity = errors.New( + "run_dast_verifier() missing 2 required positional arguments: 'exploit_payload' and 'depth'") + +// dastOutcome models the attribute set `_run_dast_verification` READS off the +// DAST result. It is deliberately NOT schemas.DastVerificationResult: the +// orchestrator reads five attributes that model does not have (see +// ErrDastVerifierArity). Keeping the shape the Python code expects is what lets +// the ported loop body be written — and tested — as written. +type dastOutcome struct { + ExploitAttempted bool + ExploitSucceeded bool + Evidence string + Confidence string + ResponseAnalysis string +} + +// dastVerify is the `run_dast_verifier(...)` call site, as a seam so a test can +// drive the loop body. The production value always fails, exactly as Python +// does — see ErrDastVerifierArity. +var dastVerify = func(_ context.Context, _ appx.Harnesser, _ string, _ schemas.VerifiedFinding) (dastOutcome, error) { + return dastOutcome{}, ErrDastVerifierArity +} + +// --------------------------------------------------------------------------- +// run() +// --------------------------------------------------------------------------- + +// Run ports `AuditOrchestrator.run()` (orchestrator.py:82) — the STREAMING, +// fully in-process pipeline. +// +// note("Starting SEC-AF streaming orchestrator", tags=["audit","start","streaming"]) +// fast_recon = await self._run_fast_recon(); self._write_checkpoint("recon_fast", fast_recon) +// findings_queue = asyncio.Queue() +// deep_result, hunt, verified = await asyncio.gather( +// self._run_deep_recon_async(fast_recon), +// self._run_hunt_streaming(fast_recon, findings_queue), +// self._run_prove_streaming(findings_queue)) +// self.findings_not_verified = max(0, len(hunt.findings) - len(verified)) +// recon = self._merge_recon(fast_recon, deep_result) +// write "recon", "hunt", "prove" +// await self._assess_reachability_parallel(verified) +// rebuild prove_drop_summary from each finding's drop_reason +// (DAST branch — dead, see enableDast) +// result = await self._generate_output(...) +// note("SEC-AF audit complete", tags=["audit","complete"]) +// +// app.py does NOT use this path: it issues four `.call`s into internal/phases +// and then calls GenerateOutput directly. Run is ported because +// RunFromCheckpoint shares its `_run_hunt`/`_run_prove` halves and because +// DESIGN.md §3 requires the whole class. +// +// Concurrency parity: the three tasks run together, the hunt publishing batches +// to the prove consumer through a queue. Go uses one errgroup (no +// WithContext — `asyncio.gather` does not cancel siblings on failure either), +// so the FIRST error is returned once all three have finished. The Go channel +// is buffered to the strategy count and CLOSED by RunHuntStreaming in place of +// Python's None sentinel. +func (o *AuditOrchestrator) Run(ctx context.Context) (schemas.SecurityAuditResult, error) { + o.App.Note(ctx, "Starting SEC-AF streaming orchestrator", "audit", "start", "streaming") + + fastRecon, err := o.RunFastRecon(ctx) + if err != nil { + return schemas.SecurityAuditResult{}, err + } + if err := o.WriteCheckpoint("recon_fast", fastRecon); err != nil { + return schemas.SecurityAuditResult{}, err + } + + // Buffered to the number of hunters so a producer's send never blocks, + // which is what Python's unbounded asyncio.Queue guarantees. + strategyCount := len(huntagent.SelectStrategies(config.NormalizeDepth(o.Input.Depth))) + findingsQueue := make(chan []schemas.RawFinding, strategyCount) + + var ( + dataFlows schemas.DataFlowMap + securityContext schemas.SecurityContext + hunt schemas.HuntResult + verified []schemas.VerifiedFinding + ) + var g errgroup.Group + g.Go(func() error { + var err error + dataFlows, securityContext, err = o.RunDeepReconAsync(ctx, fastRecon) + return err + }) + g.Go(func() error { + var err error + hunt, err = o.RunHuntStreaming(ctx, fastRecon, findingsQueue) + return err + }) + g.Go(func() error { + verified = o.RunProveStreaming(ctx, findingsQueue) + return nil + }) + if err := g.Wait(); err != nil { + return schemas.SecurityAuditResult{}, err + } + + o.FindingsNotVerified = len(hunt.Findings) - len(verified) + if o.FindingsNotVerified < 0 { + o.FindingsNotVerified = 0 + } + + recon := o.MergeRecon(fastRecon, dataFlows, securityContext) + if err := o.WriteCheckpoint(PhaseRecon, recon); err != nil { + return schemas.SecurityAuditResult{}, err + } + if err := o.WriteCheckpoint(PhaseHunt, hunt); err != nil { + return schemas.SecurityAuditResult{}, err + } + if err := o.WriteCheckpoint(PhaseProve, verified); err != nil { + return schemas.SecurityAuditResult{}, err + } + + o.AssessReachabilityParallel(ctx, verified) + o.rebuildDropSummary(ctx, verified) + + if enableDast { + o.App.Note(ctx, "DAST-like runtime verification enabled", "audit", "prove", "dast") + o.RunDASTVerification(ctx, verified) + } + + result, err := o.GenerateOutput(ctx, recon, hunt, verified) + if err != nil { + return result, err + } + o.App.Note(ctx, "SEC-AF audit complete", "audit", "complete") + return result, nil +} + +// rebuildDropSummary ports the identical three lines run() and _run_prove share: +// +// self.prove_drop_summary = {"demoted_total": 0, "by_reason": {}, "findings": []} +// for finding in verified: +// if finding.drop_reason: +// self._track_drop(finding_title=finding.title, original_verdict=None, reason=finding.drop_reason) +// +// Python parity: `if finding.drop_reason` is TRUTHINESS, so an empty-string +// drop_reason is skipped, and original_verdict is always None here even though +// the demoting code knew it. +func (o *AuditOrchestrator) rebuildDropSummary(ctx context.Context, verified []schemas.VerifiedFinding) { + o.ProveDropSummary = NewDropSummary() + for _, finding := range verified { + if finding.DropReason != nil && *finding.DropReason != "" { + o.TrackDrop(ctx, finding.Title, nil, *finding.DropReason) + } + } +} + +// --------------------------------------------------------------------------- +// run_from_checkpoint() +// --------------------------------------------------------------------------- + +// UnknownCheckpointPhaseError is `ValueError(f"Unknown checkpoint phase: {phase}")`. +// +// app.py maps a ValueError to HTTP 400, so the type matters as much as the +// text: `except ValueError as exc: raise HTTPException(400, {"error": str(exc)})`. +type UnknownCheckpointPhaseError struct{ Phase string } + +func (e *UnknownCheckpointPhaseError) Error() string { + return "Unknown checkpoint phase: " + e.Phase +} + +// RunFromCheckpoint ports `run_from_checkpoint(phase)` (orchestrator.py:121): +// +// normalized_phase = phase.lower().strip() +// if normalized_phase not in {"recon", "hunt", "prove"}: +// raise ValueError(f"Unknown checkpoint phase: {phase}") +// recon: always read from checkpoint-recon.json +// "recon": run hunt, write it, run prove, write it +// "hunt": read hunt, run prove, write it +// "prove": read hunt AND the prove list — nothing is re-run +// return await self._generate_output(recon=recon, hunt=hunt, verified=verified) +// +// Python parity: +// +// - the error message carries the ORIGINAL phase string, not the normalized +// one, so `run_from_checkpoint(" Recon ")` succeeds while +// `run_from_checkpoint("Recon!")` reports "Unknown checkpoint phase: Recon!". +// - normalization is `.lower()` THEN `.strip()`; both orders agree for +// whitespace, and Go's TrimSpace(ToLower(s)) matches for every input that is +// not exotic Unicode whitespace. +// - the "prove" branch reads a LIST of VerifiedFinding, the other two read +// single models — hence the two read helpers. +func (o *AuditOrchestrator) RunFromCheckpoint(ctx context.Context, phase string) (schemas.SecurityAuditResult, error) { + normalized := strings.TrimSpace(strings.ToLower(phase)) + if normalized != PhaseRecon && normalized != PhaseHunt && normalized != PhaseProve { + return schemas.SecurityAuditResult{}, &UnknownCheckpointPhaseError{Phase: phase} + } + + recon, err := ReadCheckpoint(o, PhaseRecon, phases.BindReconResult) + if err != nil { + return schemas.SecurityAuditResult{}, err + } + + var ( + hunt schemas.HuntResult + verified []schemas.VerifiedFinding + ) + switch normalized { + case PhaseRecon: + hunt, err = o.RunHunt(ctx, recon) + if err != nil { + return schemas.SecurityAuditResult{}, err + } + if err = o.WriteCheckpoint(PhaseHunt, hunt); err != nil { + return schemas.SecurityAuditResult{}, err + } + verified, err = o.RunProve(ctx, recon, hunt) + if err != nil { + return schemas.SecurityAuditResult{}, err + } + if err = o.WriteCheckpoint(PhaseProve, verified); err != nil { + return schemas.SecurityAuditResult{}, err + } + case PhaseHunt: + hunt, err = ReadCheckpoint(o, PhaseHunt, phases.BindHuntResult) + if err != nil { + return schemas.SecurityAuditResult{}, err + } + verified, err = o.RunProve(ctx, recon, hunt) + if err != nil { + return schemas.SecurityAuditResult{}, err + } + if err = o.WriteCheckpoint(PhaseProve, verified); err != nil { + return schemas.SecurityAuditResult{}, err + } + default: // PhaseProve + hunt, err = ReadCheckpoint(o, PhaseHunt, phases.BindHuntResult) + if err != nil { + return schemas.SecurityAuditResult{}, err + } + verified, err = ReadCheckpointList(o, PhaseProve, phases.BindVerifiedFinding) + if err != nil { + return schemas.SecurityAuditResult{}, err + } + } + + return o.GenerateOutput(ctx, recon, hunt, verified) +} + +// --------------------------------------------------------------------------- +// recon +// --------------------------------------------------------------------------- + +// RunRecon ports `_run_recon()` (orchestrator.py:149) — the non-streaming RECON +// phase. +// +// Python parity: NOTHING calls it. run() uses _run_fast_recon plus +// _run_deep_recon_async, and run_from_checkpoint always reads recon from disk. +// It is ported for completeness, and it is the only place the PR-mode recon +// cache is consulted with the "Using cached recon for PR-mode scan" wording +// (note the trailing " scan", which _run_fast_recon's otherwise-identical note +// lacks). +func (o *AuditOrchestrator) RunRecon(ctx context.Context) (schemas.ReconResult, error) { + o.App.Note(ctx, "Phase: RECON", "audit", "recon") + if o.IsPRMode { + if cached := o.TryLoadCachedRecon(); cached != nil { + o.App.Note(ctx, "Using cached recon for PR-mode scan", "audit", "recon", "cached") + o.EmitProgress(ctx, PhaseRecon, 1, 1, 0) + return *cached, nil + } + } + recon, err := reconagent.RunRecon(ctx, o.PhaseProxy(PhaseRecon), o.RepoPath, o.Input.Depth) + if err != nil { + return schemas.ReconResult{}, err + } + o.EmitProgress(ctx, PhaseRecon, 1, 1, 0) + return recon, nil +} + +// RunFastRecon ports `_run_fast_recon()` (orchestrator.py:165) — the three +// cheap mappers, plus the PR-mode cache short circuit. +// +// Python parity: the progress event reports agents_total=2 / agents_completed=1 +// on BOTH paths (cached and fresh), because the deep half is still to come. +func (o *AuditOrchestrator) RunFastRecon(ctx context.Context) (schemas.ReconResult, error) { + o.App.Note(ctx, "Phase: FAST RECON", "audit", "recon", "fast") + if o.IsPRMode { + if cached := o.TryLoadCachedRecon(); cached != nil { + o.App.Note(ctx, "Using cached recon for PR-mode", "audit", "recon", "cached") + o.EmitProgress(ctx, PhaseRecon, 2, 1, 0) + return *cached, nil + } + } + fast, err := reconagent.RunFastRecon(ctx, o.PhaseProxy(PhaseRecon), o.RepoPath) + if err != nil { + return schemas.ReconResult{}, err + } + o.EmitProgress(ctx, PhaseRecon, 2, 1, 0) + return fast, nil +} + +// RunDeepReconAsync ports `_run_deep_recon_async(fast_recon)` +// (orchestrator.py:182): +// +// if self.config.depth == DepthProfile.QUICK: +// return fast_recon.data_flows, fast_recon.security_context +// result = await run_deep_recon(app=proxy, repo_path=..., architecture=fast_recon.architecture) +// self._emit_progress(phase="recon", agents_total=2, agents_completed=2, findings_so_far=0) +// return result +// +// Python parity: the QUICK short circuit reads `self.config.depth`, which +// AuditConfig.from_input already normalized — NOT `self._depth_profile()`. The +// two agree, but the config is the one the code consults. It also emits NO +// progress event, so a quick audit reports recon 1/2 and never 2/2. +func (o *AuditOrchestrator) RunDeepReconAsync(ctx context.Context, fastRecon schemas.ReconResult) ( + schemas.DataFlowMap, schemas.SecurityContext, error, +) { + if o.Config.Depth == config.DepthQuick { + return fastRecon.DataFlows, fastRecon.SecurityContext, nil + } + dataFlows, securityContext, err := reconagent.RunDeepRecon( + ctx, o.PhaseProxy(PhaseRecon), o.RepoPath, fastRecon.Architecture) + if err != nil { + return schemas.DataFlowMap{}, schemas.SecurityContext{}, err + } + o.EmitProgress(ctx, PhaseRecon, 2, 2, 0) + return dataFlows, securityContext, nil +} + +// MergeRecon ports `_merge_recon(fast, deep_result)` (orchestrator.py:196): +// +// data_flows, security_context = deep_result +// frameworks = sorted({item for item in security_context.framework_security if item}) +// return ReconResult(architecture=fast.architecture, data_flows=data_flows, +// dependencies=fast.dependencies, config=fast.config, +// security_context=security_context, languages=fast.languages, +// frameworks=frameworks, lines_of_code=fast.lines_of_code, +// file_count=fast.file_count) +// +// Python parity: `recon_duration_seconds` is NOT carried over from `fast` — the +// merged result keeps the pydantic default 0.0 — and the frameworks come from +// the DEEP security context, not the fast one. +func (o *AuditOrchestrator) MergeRecon( + fast schemas.ReconResult, + dataFlows schemas.DataFlowMap, + securityContext schemas.SecurityContext, +) schemas.ReconResult { + return schemas.ReconResult{ + Architecture: fast.Architecture, + DataFlows: dataFlows, + Dependencies: fast.Dependencies, + Config: fast.Config, + SecurityContext: securityContext, + Languages: fast.Languages, + Frameworks: sortedNonEmpty(securityContext.FrameworkSecurity), + LinesOfCode: fast.LinesOfCode, + FileCount: fast.FileCount, + } +} + +// --------------------------------------------------------------------------- +// hunt +// --------------------------------------------------------------------------- + +// huntIncludePaths ports the include-path selection both hunt entry points +// share: +// +// include_paths = self.config.include_paths +// if self.is_pr_mode and self.diff_analysis and self.diff_analysis.changed_files: +// include_paths = self.diff_analysis.all_relevant_files +// self.app.note(f"PR-mode: scanning {file_count} files " +// f"({len(changed_files)} changed + {len(blast_radius_files)} blast radius)", +// tags=["audit", "hunt", "pr-mode"]) +// +// Python parity: the guard needs a NON-EMPTY changed_files list — a diff +// analysis that found nothing leaves the configured include paths in place and +// emits no note. +func (o *AuditOrchestrator) huntIncludePaths(ctx context.Context) []string { + includePaths := o.Config.IncludePaths + if o.IsPRMode && o.DiffAnalysis != nil && len(o.DiffAnalysis.ChangedFiles) > 0 { + includePaths = o.DiffAnalysis.AllRelevantFiles + o.App.Note(ctx, + "PR-mode: scanning "+strconv.Itoa(o.DiffAnalysis.FileCount())+" files ("+ + strconv.Itoa(len(o.DiffAnalysis.ChangedFiles))+" changed + "+ + strconv.Itoa(len(o.DiffAnalysis.BlastRadiusFiles))+" blast radius)", + "audit", "hunt", "pr-mode") + } + return includePaths +} + +// RunHuntStreaming ports `_run_hunt_streaming(recon, findings_queue)` +// (orchestrator.py:211) — run_hunt_streaming plus the recon-finding merge and +// the progress event. +// +// The channel is CLOSED by agents/hunt.RunHuntStreaming in place of Python's +// None sentinel, including when a hunter fails, so the prove consumer always +// terminates. +func (o *AuditOrchestrator) RunHuntStreaming( + ctx context.Context, + recon schemas.ReconResult, + findingsQueue chan<- []schemas.RawFinding, +) (schemas.HuntResult, error) { + o.App.Note(ctx, "Phase: HUNT (streaming)", "audit", "hunt", "streaming") + + includePaths := o.huntIncludePaths(ctx) + + hunt, err := huntagent.RunHuntStreaming( + ctx, + o.PhaseProxy(PhaseHunt), + o.RepoPath, + recon, + findingsQueue, + o.Input.Depth, + o.BudgetConfig.MaxConcurrentHunters, + o.BudgetConfig.HunterEarlyStopFileThreshold, + includePaths, + ) + if err != nil { + return schemas.HuntResult{}, err + } + + hunt = MergeReconFindingsIntoHunt(hunt, reconagent.ExtractReconFindings(recon)) + o.EmitProgress(ctx, PhaseHunt, 1, 1, len(hunt.Findings)) + return hunt, nil +} + +// RunHunt ports `_run_hunt(recon)` (orchestrator.py:263) — the non-streaming +// hunt used by run_from_checkpoint("recon"). +func (o *AuditOrchestrator) RunHunt(ctx context.Context, recon schemas.ReconResult) (schemas.HuntResult, error) { + o.App.Note(ctx, "Phase: HUNT", "audit", "hunt") + + includePaths := o.huntIncludePaths(ctx) + + hunt, err := huntagent.RunHunt( + ctx, + o.PhaseProxy(PhaseHunt), + o.RepoPath, + recon, + o.Input.Depth, + o.BudgetConfig.MaxConcurrentHunters, + o.BudgetConfig.HunterEarlyStopFileThreshold, + includePaths, + ) + if err != nil { + return schemas.HuntResult{}, err + } + + hunt = MergeReconFindingsIntoHunt(hunt, reconagent.ExtractReconFindings(recon)) + o.EmitProgress(ctx, PhaseHunt, 1, 1, len(hunt.Findings)) + return hunt, nil +} + +// --------------------------------------------------------------------------- +// prove +// --------------------------------------------------------------------------- + +// RunProveStreaming ports `_run_prove_streaming(findings_queue)` +// (orchestrator.py:244). +// +// Python parity: it RESETS findings_not_verified to 0 before consuming, so the +// value run() computes afterwards (`len(hunt.findings) - len(verified)`) is the +// one that survives. run_prove_streaming itself cannot fail, so neither can +// this. +func (o *AuditOrchestrator) RunProveStreaming( + ctx context.Context, + findingsQueue <-chan []schemas.RawFinding, +) []schemas.VerifiedFinding { + o.App.Note(ctx, "Phase: PROVE (streaming)", "audit", "prove", "streaming") + + proverCap := o.ProverCap() + o.FindingsNotVerified = 0 + + verified := proveagent.RunProveStreaming( + ctx, + o.ProvePhaseProxy(), + o.RepoPath, + findingsQueue, + o.Input.Depth, + o.BudgetConfig.MaxConcurrentProvers, + proverCap, + ) + o.EmitProgress(ctx, PhaseProve, 1, 1, len(verified)) + return verified +} + +// RunProve ports `_run_prove(recon, hunt)` (orchestrator.py:290) — the +// non-streaming prove used by run_from_checkpoint. +// +// prioritized = self._prioritize_findings(hunt.findings) +// limited_hunt = HuntResult(findings=prioritized[:self._prover_cap()], ) +// self.findings_not_verified = max(0, len(hunt.findings) - len(limited_hunt.findings)) +// verified = await run_prove(app=proxy, ...) +// await self._assess_reachability_parallel(verified) +// rebuild prove_drop_summary +// (DAST branch — dead) +// self._emit_progress(phase="prove", agents_total=1, agents_completed=1, findings_so_far=len(verified)) +// +// Python parity: `recon` is accepted and immediately discarded (`_ = recon`), +// and the limited hunt copies chains/total_raw/deduplicated_count/chain_count/ +// strategies_run/hunt_duration_seconds VERBATIM — only the findings list is +// truncated, so total_raw keeps counting the findings that were dropped. +func (o *AuditOrchestrator) RunProve( + ctx context.Context, + recon schemas.ReconResult, + hunt schemas.HuntResult, +) ([]schemas.VerifiedFinding, error) { + _ = recon // Python: `_ = recon` + + o.App.Note(ctx, "Phase: PROVE", "audit", "prove") + + prioritized := o.PrioritizeFindings(hunt.Findings) + proverCap := o.ProverCap() + if proverCap < len(prioritized) { + prioritized = prioritized[:proverCap] + } + limitedHunt := schemas.HuntResult{ + Findings: prioritized, + Chains: hunt.Chains, + TotalRaw: hunt.TotalRaw, + DeduplicatedCount: hunt.DeduplicatedCount, + ChainCount: hunt.ChainCount, + StrategiesRun: hunt.StrategiesRun, + HuntDurationSeconds: hunt.HuntDurationSeconds, + } + + o.FindingsNotVerified = len(hunt.Findings) - len(limitedHunt.Findings) + if o.FindingsNotVerified < 0 { + o.FindingsNotVerified = 0 + } + + verified, err := proveagent.RunProve( + ctx, + o.ProvePhaseProxy(), + o.RepoPath, + limitedHunt, + o.Input.Depth, + o.BudgetConfig.MaxConcurrentProvers, + ) + if err != nil { + return verified, err + } + + o.AssessReachabilityParallel(ctx, verified) + o.rebuildDropSummary(ctx, verified) + + if enableDast { + o.App.Note(ctx, "DAST-like runtime verification enabled", "audit", "prove", "dast") + o.RunDASTVerification(ctx, verified) + } + + o.EmitProgress(ctx, PhaseProve, 1, 1, len(verified)) + return verified, nil +} + +// RunDASTVerification ports `_run_dast_verification(verified)` +// (orchestrator.py:332). +// +// confirmed = [f for f in verified if f.verdict == Verdict.CONFIRMED] +// if not confirmed: +// note("No confirmed findings available for DAST step", tags=["audit","prove","dast"]); return +// for finding in confirmed: +// try: dast_result = await run_dast_verifier(proxy, str(self.repo_path), finding) +// except Exception as exc: +// finding.tags.append("dast_error") +// note(f"DAST verifier failed for '{finding.title}': {exc}", tags=["audit","prove","dast","error"]) +// continue +// finding.tags.append("dast_attempted" if dast_result.exploit_attempted else "dast_skipped") +// finding.tags.append("dast_confirmed" if dast_result.exploit_succeeded else "dast_not_confirmed") +// finding.rationale = f"{finding.rationale}\nDAST: {dast_result.response_analysis}" +// if finding.proof is not None: +// finding.proof.poc_execution_output = json.dumps({...}, indent=2) +// +// UNREACHABLE IN PRODUCTION on two independent counts — the enableDast guard +// and ErrDastVerifierArity — both documented above. With the production seam +// every confirmed finding takes the except branch: tagged "dast_error", noted, +// and otherwise untouched. +// +// Findings are mutated IN PLACE through the shared backing array, matching +// Python's aliasing. +func (o *AuditOrchestrator) RunDASTVerification(ctx context.Context, verified []schemas.VerifiedFinding) { + confirmed := make([]int, 0, len(verified)) + for i := range verified { + if verified[i].Verdict == schemas.VerdictConfirmed { + confirmed = append(confirmed, i) + } + } + if len(confirmed) == 0 { + o.App.Note(ctx, "No confirmed findings available for DAST step", "audit", "prove", "dast") + return + } + + proxy := o.PhaseProxy(PhaseProve) + for _, idx := range confirmed { + finding := &verified[idx] + + outcome, err := dastVerify(ctx, proxy, o.RepoPath, *finding) + if err != nil { + finding.Tags = append(finding.Tags, "dast_error") + o.App.Note(ctx, + fmt.Sprintf("DAST verifier failed for '%s': %v", finding.Title, err), + "audit", "prove", "dast", "error") + continue + } + + if outcome.ExploitAttempted { + finding.Tags = append(finding.Tags, "dast_attempted") + } else { + finding.Tags = append(finding.Tags, "dast_skipped") + } + if outcome.ExploitSucceeded { + finding.Tags = append(finding.Tags, "dast_confirmed") + } else { + finding.Tags = append(finding.Tags, "dast_not_confirmed") + } + finding.Rationale = finding.Rationale + "\nDAST: " + outcome.ResponseAnalysis + + if finding.Proof != nil { + // Python: json.dumps({...}, indent=2) over a dict literal, so the + // key order is the literal's — pyfmt.O preserves it where a Go map + // would sort. + body := pyfmt.Dumps(pyfmt.O( + "exploit_attempted", outcome.ExploitAttempted, + "exploit_succeeded", outcome.ExploitSucceeded, + "evidence", outcome.Evidence, + "confidence", outcome.Confidence, + ), 2) + finding.Proof.PocExecutionOutput = &body + } + } +} diff --git a/go/internal/orch/run_test.go b/go/internal/orch/run_test.go new file mode 100644 index 0000000..cc66269 --- /dev/null +++ b/go/internal/orch/run_test.go @@ -0,0 +1,395 @@ +package orch + +import ( + "context" + "encoding/json" + "os" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/compliance" + "github.com/Agent-Field/sec-af/go/internal/diffanalysis" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// emptyHarness answers every structured harness call with "{}", which the +// schemas package's default-seeding UnmarshalJSON turns into a fully-defaulted +// model. That is enough to drive the whole in-process pipeline without an LLM: +// no locations found, so no enrichment, no findings, no verification. +func emptyHarness() func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + return appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return json.RawMessage(`{}`), nil + }) +} + +// TestRun_StreamingPipeline drives `run()` end to end with an empty harness and +// asserts the observable contract: the notes, the four checkpoint files, and a +// result that reaches GenerateOutput. +func TestRun_StreamingPipeline(t *testing.T) { + compliance.ClearAICache() + o, fake := newTestOrchestrator(t, func(in *schemas.AuditInput) { in.Depth = "quick" }) + fake.HarnessFn = emptyHarness() + fake.AIFn = appx.AIJSON(func(string) (json.RawMessage, error) { + return json.RawMessage(`{"mappings":[],"confidence":"low"}`), nil + }) + + result, err := o.Run(context.Background()) + if err != nil { + t.Fatalf("Run: %v", err) + } + + msgs := fake.NoteMessages() + if msgs[0] != "Starting SEC-AF streaming orchestrator" { + t.Errorf("first note = %q", msgs[0]) + } + if !reflect.DeepEqual(fake.Notes[0].Tags, []string{"audit", "start", "streaming"}) { + t.Errorf("start tags = %v", fake.Notes[0].Tags) + } + if msgs[len(msgs)-1] != "SEC-AF audit complete" { + t.Errorf("last note = %q", msgs[len(msgs)-1]) + } + for _, want := range []string{"Phase: FAST RECON", "Phase: HUNT (streaming)", "Phase: PROVE (streaming)"} { + if !containsMessage(msgs, want) { + t.Errorf("missing note %q in %q", want, msgs) + } + } + + // run() writes FOUR checkpoints: the fast recon snapshot plus the three + // phase results. + for _, phase := range []string{"recon_fast", "recon", "hunt", "prove"} { + if _, statErr := os.Stat(o.CheckpointPath(phase)); statErr != nil { + t.Errorf("checkpoint-%s.json was not written: %v", phase, statErr) + } + } + + if result.Provider != "harness" { + t.Errorf("provider = %q", result.Provider) + } + if result.DepthProfile != "quick" { + t.Errorf("depth_profile = %q", result.DepthProfile) + } + if o.FindingsNotVerified != 0 { + t.Errorf("findings_not_verified = %d, want 0 for an empty hunt", o.FindingsNotVerified) + } + // Every harness invocation went through a phase proxy, so it was counted. + if o.AgentInvocations() != len(fake.Harnesses) { + t.Errorf("AgentInvocations = %d, want %d (one per harness call)", o.AgentInvocations(), len(fake.Harnesses)) + } +} + +// TestRun_QuickDepthSkipsDeepRecon: _run_deep_recon_async short-circuits on +// self.config.depth, so the merged recon reuses the fast placeholders and no +// second recon progress event is emitted. +func TestRun_QuickDepthSkipsDeepRecon(t *testing.T) { + compliance.ClearAICache() + o, fake := newTestOrchestrator(t, func(in *schemas.AuditInput) { in.Depth = "quick" }) + fake.HarnessFn = emptyHarness() + fake.AIFn = appx.AIJSON(func(string) (json.RawMessage, error) { + return json.RawMessage(`{"mappings":[],"confidence":"low"}`), nil + }) + + fast := schemas.NewReconResult() + fast.SecurityContext.AuthModel = "placeholder" + dataFlows, securityContext, err := o.RunDeepReconAsync(context.Background(), fast) + if err != nil { + t.Fatalf("RunDeepReconAsync: %v", err) + } + if securityContext.AuthModel != "placeholder" { + t.Errorf("quick depth must reuse the fast security context, got %q", securityContext.AuthModel) + } + if !reflect.DeepEqual(dataFlows, fast.DataFlows) { + t.Error("quick depth must reuse the fast data flows") + } + if len(fake.Harnesses) != 0 { + t.Errorf("quick depth must not run the deep mappers (%d harness calls)", len(fake.Harnesses)) + } + if len(fake.Notes) != 0 { + t.Errorf("quick depth emits no progress event, got %q", fake.NoteMessages()) + } +} + +func containsMessage(msgs []string, want string) bool { + for _, m := range msgs { + if m == want { + return true + } + } + return false +} + +// TestMergeRecon pins _merge_recon: the deep half wins for data_flows and +// security_context, the fast half for everything else, frameworks are derived +// from the DEEP context, and recon_duration_seconds is NOT carried over. +func TestMergeRecon(t *testing.T) { + o, _ := newTestOrchestrator(t) + + fast := reconFixture(t, "full") + fast.ReconDurationSeconds = 42.5 + + deepFlows := schemas.NewDataFlowMap() + deepFlows.Sinks = []schemas.Sink{{SinkType: "sql", FilePath: "db.py", Line: 12}} + deepContext := schemas.NewSecurityContext() + deepContext.AuthModel = "oauth2" + deepContext.FrameworkSecurity = []string{"fastapi", "", "fastapi", "Starlette"} + + merged := o.MergeRecon(fast, deepFlows, deepContext) + + if !reflect.DeepEqual(merged.DataFlows, deepFlows) { + t.Error("data_flows must come from the deep half") + } + if merged.SecurityContext.AuthModel != "oauth2" { + t.Errorf("security_context = %+v, want the deep half", merged.SecurityContext) + } + if !reflect.DeepEqual(merged.Architecture, fast.Architecture) { + t.Error("architecture must come from the fast half") + } + if !reflect.DeepEqual(merged.Languages, fast.Languages) { + t.Error("languages must come from the fast half") + } + if merged.LinesOfCode != fast.LinesOfCode || merged.FileCount != fast.FileCount { + t.Error("metrics must come from the fast half") + } + // sorted({item for item in deep.framework_security if item}) — blanks and + // repeats dropped, case preserved. + if want := []string{"Starlette", "fastapi"}; !reflect.DeepEqual(merged.Frameworks, want) { + t.Errorf("frameworks = %v, want %v", merged.Frameworks, want) + } + if merged.ReconDurationSeconds != 0 { + t.Errorf("recon_duration_seconds = %v, want 0 (it is not carried over)", merged.ReconDurationSeconds) + } +} + +// TestRunFastRecon_PRModeCache: in PR mode a previous recon checkpoint short +// circuits the mappers, with the PR-specific note. +func TestRunFastRecon_PRModeCache(t *testing.T) { + t.Run("cache hit", func(t *testing.T) { + o, fake := newTestOrchestrator(t) + o.IsPRMode = true + fake.HarnessFn = emptyHarness() + + cached := reconFixture(t, "full") + if err := o.WriteCheckpoint(PhaseRecon, cached); err != nil { + t.Fatalf("WriteCheckpoint: %v", err) + } + + got, err := o.RunFastRecon(context.Background()) + if err != nil { + t.Fatalf("RunFastRecon: %v", err) + } + if len(fake.Harnesses) != 0 { + t.Errorf("a cache hit must not run any mapper (%d harness calls)", len(fake.Harnesses)) + } + if !reflect.DeepEqual(normalizeJSON(t, got), normalizeJSON(t, cached)) { + t.Error("the cached recon must be returned unchanged") + } + if !containsMessage(fake.NoteMessages(), "Using cached recon for PR-mode") { + t.Errorf("missing the PR-mode cache note in %q", fake.NoteMessages()) + } + }) + + t.Run("cache miss falls through to the mappers", func(t *testing.T) { + o, fake := newTestOrchestrator(t) + o.IsPRMode = true + fake.HarnessFn = emptyHarness() + + if _, err := o.RunFastRecon(context.Background()); err != nil { + t.Fatalf("RunFastRecon: %v", err) + } + if len(fake.Harnesses) != 3 { + t.Errorf("harness calls = %d, want 3 (the three cheap mappers)", len(fake.Harnesses)) + } + }) + + t.Run("the cache is only consulted in PR mode", func(t *testing.T) { + o, fake := newTestOrchestrator(t) + fake.HarnessFn = emptyHarness() + if err := o.WriteCheckpoint(PhaseRecon, reconFixture(t, "full")); err != nil { + t.Fatalf("WriteCheckpoint: %v", err) + } + if _, err := o.RunFastRecon(context.Background()); err != nil { + t.Fatalf("RunFastRecon: %v", err) + } + if len(fake.Harnesses) != 3 { + t.Errorf("harness calls = %d, want 3 — a non-PR run ignores the cache", len(fake.Harnesses)) + } + }) +} + +// TestRunRecon_CacheNoteDiffers guards the one-word difference between +// _run_recon's and _run_fast_recon's cache notes. +func TestRunRecon_CacheNoteDiffers(t *testing.T) { + o, fake := newTestOrchestrator(t) + o.IsPRMode = true + if err := o.WriteCheckpoint(PhaseRecon, reconFixture(t, "full")); err != nil { + t.Fatalf("WriteCheckpoint: %v", err) + } + if _, err := o.RunRecon(context.Background()); err != nil { + t.Fatalf("RunRecon: %v", err) + } + if !containsMessage(fake.NoteMessages(), "Using cached recon for PR-mode scan") { + t.Errorf("_run_recon's note must end in ' scan'; got %q", fake.NoteMessages()) + } +} + +// TestHuntIncludePaths pins the PR-mode include-path substitution and its note. +func TestHuntIncludePaths(t *testing.T) { + t.Run("no PR mode keeps the configured include paths", func(t *testing.T) { + o, fake := newTestOrchestrator(t, func(in *schemas.AuditInput) { + in.IncludePaths = []string{"src/"} + }) + if got := o.huntIncludePaths(context.Background()); !reflect.DeepEqual(got, []string{"src/"}) { + t.Errorf("include paths = %v", got) + } + if len(fake.Notes) != 0 { + t.Errorf("no note expected, got %q", fake.NoteMessages()) + } + }) + + t.Run("PR mode with changed files substitutes the blast radius", func(t *testing.T) { + o, fake := newTestOrchestrator(t, func(in *schemas.AuditInput) { + in.IncludePaths = []string{"src/"} + }) + o.IsPRMode = true + o.DiffAnalysis = &diffanalysis.DiffAnalysis{ + ChangedFiles: []string{"a.py", "b.py"}, + BlastRadiusFiles: []string{"c.py"}, + AllRelevantFiles: []string{"a.py", "b.py", "c.py"}, + } + got := o.huntIncludePaths(context.Background()) + if want := []string{"a.py", "b.py", "c.py"}; !reflect.DeepEqual(got, want) { + t.Errorf("include paths = %v, want %v", got, want) + } + want := "PR-mode: scanning 3 files (2 changed + 1 blast radius)" + if !containsMessage(fake.NoteMessages(), want) { + t.Errorf("missing %q in %q", want, fake.NoteMessages()) + } + for _, note := range fake.Notes { + if note.Message == want && !reflect.DeepEqual(note.Tags, []string{"audit", "hunt", "pr-mode"}) { + t.Errorf("tags = %v", note.Tags) + } + } + }) + + t.Run("PR mode with an EMPTY diff keeps the configured paths", func(t *testing.T) { + o, fake := newTestOrchestrator(t, func(in *schemas.AuditInput) { + in.IncludePaths = []string{"src/"} + }) + o.IsPRMode = true + empty := diffanalysis.NewDiffAnalysis() + o.DiffAnalysis = &empty + if got := o.huntIncludePaths(context.Background()); !reflect.DeepEqual(got, []string{"src/"}) { + t.Errorf("include paths = %v, want the configured ones", got) + } + if len(fake.Notes) != 0 { + t.Errorf("no note expected for an empty diff, got %q", fake.NoteMessages()) + } + }) +} + +// TestRunFromCheckpoint_ReconBranch re-runs hunt and prove and writes both +// checkpoints back. +func TestRunFromCheckpoint_ReconBranch(t *testing.T) { + compliance.ClearAICache() + o, fake := newTestOrchestrator(t, func(in *schemas.AuditInput) { in.Depth = "quick" }) + fake.HarnessFn = emptyHarness() + fake.AIFn = appx.AIJSON(func(string) (json.RawMessage, error) { + return json.RawMessage(`{"mappings":[],"confidence":"low"}`), nil + }) + + if err := o.WriteCheckpoint(PhaseRecon, reconFixture(t, "minimal")); err != nil { + t.Fatalf("WriteCheckpoint: %v", err) + } + + if _, err := o.RunFromCheckpoint(context.Background(), "recon"); err != nil { + t.Fatalf("RunFromCheckpoint: %v", err) + } + for _, phase := range []string{PhaseHunt, PhaseProve} { + if _, statErr := os.Stat(o.CheckpointPath(phase)); statErr != nil { + t.Errorf("checkpoint-%s.json was not written: %v", phase, statErr) + } + } + if !containsMessage(fake.NoteMessages(), "Phase: HUNT") { + t.Errorf("missing the non-streaming HUNT note in %q", fake.NoteMessages()) + } + if !containsMessage(fake.NoteMessages(), "Phase: PROVE") { + t.Errorf("missing the non-streaming PROVE note in %q", fake.NoteMessages()) + } + // The streaming notes must NOT appear on this path. + for _, msg := range fake.NoteMessages() { + if strings.Contains(msg, "(streaming)") { + t.Errorf("unexpected streaming note %q", msg) + } + } +} + +// TestRunFromCheckpoint_HuntBranch reads the hunt checkpoint instead of +// re-running it. +func TestRunFromCheckpoint_HuntBranch(t *testing.T) { + compliance.ClearAICache() + o, fake := newTestOrchestrator(t, func(in *schemas.AuditInput) { in.Depth = "quick" }) + fake.HarnessFn = emptyHarness() + fake.AIFn = appx.AIJSON(func(string) (json.RawMessage, error) { + return json.RawMessage(`{"mappings":[],"confidence":"low"}`), nil + }) + + hunt := schemas.NewHuntResult() + hunt.TotalRaw = 9 + hunt.StrategiesRun = []string{"injection"} + if err := o.WriteCheckpoint(PhaseRecon, reconFixture(t, "minimal")); err != nil { + t.Fatalf("WriteCheckpoint: %v", err) + } + if err := o.WriteCheckpoint(PhaseHunt, hunt); err != nil { + t.Fatalf("WriteCheckpoint: %v", err) + } + + result, err := o.RunFromCheckpoint(context.Background(), "hunt") + if err != nil { + t.Fatalf("RunFromCheckpoint: %v", err) + } + if result.TotalRawFindings != 9 { + t.Errorf("total_raw_findings = %d, want the checkpointed 9", result.TotalRawFindings) + } + if containsMessage(fake.NoteMessages(), "Phase: HUNT") { + t.Errorf("the hunt branch must NOT re-run HUNT; notes = %q", fake.NoteMessages()) + } + if !containsMessage(fake.NoteMessages(), "Phase: PROVE") { + t.Errorf("missing the PROVE note in %q", fake.NoteMessages()) + } +} + +// TestRunProve_LimitsAndCounters pins the limited-hunt projection: only the +// findings list is truncated, and findings_not_verified is the remainder. +func TestRunProve_LimitsAndCounters(t *testing.T) { + o, fake := newTestOrchestrator(t, func(in *schemas.AuditInput) { + cap2 := 2 + in.MaxProvers = &cap2 + }) + fake.HarnessFn = emptyHarness() + fake.AIFn = appx.AIJSON(func(string) (json.RawMessage, error) { + return json.RawMessage(`{"verdict":"inconclusive","evidence_level":1,"rationale":"r","confidence":"low"}`), nil + }) + + hunt := schemas.NewHuntResult() + hunt.Findings = findingsFixture(t) // 5 + hunt.TotalRaw = 11 + hunt.DeduplicatedCount = 5 + hunt.StrategiesRun = []string{"injection"} + + verified, err := o.RunProve(context.Background(), schemas.NewReconResult(), hunt) + if err != nil { + t.Fatalf("RunProve: %v", err) + } + if len(verified) != 2 { + t.Errorf("verified = %d, want 2 (the prover cap)", len(verified)) + } + if o.FindingsNotVerified != 3 { + t.Errorf("findings_not_verified = %d, want 3", o.FindingsNotVerified) + } + if !containsMessage(fake.NoteMessages(), "Phase: PROVE") { + t.Errorf("missing the PROVE note in %q", fake.NoteMessages()) + } +} diff --git a/go/internal/orch/strategies.go b/go/internal/orch/strategies.go new file mode 100644 index 0000000..eba7f6c --- /dev/null +++ b/go/internal/orch/strategies.go @@ -0,0 +1,167 @@ +package orch + +import ( + "sort" + "strings" + + "github.com/Agent-Field/sec-af/go/internal/config" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// DefaultStrategies ports `AuditOrchestrator._default_strategies(recon)` +// (orchestrator.py:507). +// +// This is the ORCHESTRATOR's variant and it is NOT the same list as +// reasoners/phases.py `_default_strategies(recon, depth)`: +// +// base: injection, dos, ssrf, auth, data_exposure, config_secrets +// + crypto when security_context.crypto_usage +// + supply_chain when dependencies.direct_count > 0 +// + api_security when architecture.api_surface +// + business_logic when depth is standard or thorough +// + python_specific when depth is thorough and "python" in the lowered languages +// + javascript_specific when depth is thorough and any language is javascript/typescript +// +// Differences from the phases copy, both deliberate: XSS is NEVER added here +// (phases adds it at standard/thorough), and the two language-specific +// strategies exist only here. +// +// Python parity: +// +// - the depth comes from `self._depth_profile()`, i.e. the orchestrator's own +// input, not a parameter; +// - `"python" in {lang.lower() for lang in recon.languages}` is an EXACT +// match on the lowered name — "python3" does not qualify; +// - the javascript test uses `any(lang.lower() in {"javascript", "typescript"})`, +// so either language alone adds JAVASCRIPT_SPECIFIC, and both together add +// it once; +// - the trailing pass keeps FIRST-seen order and drops repeats. No branch can +// add a duplicate today, but it is reproduced. +func (o *AuditOrchestrator) DefaultStrategies(recon schemas.ReconResult) []schemas.HuntStrategy { + strategies := []schemas.HuntStrategy{ + schemas.HuntStrategyInjection, + schemas.HuntStrategyDos, + schemas.HuntStrategySSRF, + schemas.HuntStrategyAuth, + schemas.HuntStrategyDataExposure, + schemas.HuntStrategyConfigSecrets, + } + if len(recon.SecurityContext.CryptoUsage) > 0 { + strategies = append(strategies, schemas.HuntStrategyCrypto) + } + if recon.Dependencies.DirectCount > 0 { + strategies = append(strategies, schemas.HuntStrategySupplyChain) + } + if len(recon.Architecture.APISurface) > 0 { + strategies = append(strategies, schemas.HuntStrategyAPISecurity) + } + + depth := o.depthProfile() + if depth == config.DepthStandard || depth == config.DepthThorough { + strategies = append(strategies, schemas.HuntStrategyBusinessLogic) + } + if depth == config.DepthThorough && hasLoweredLanguage(recon.Languages, "python") { + strategies = append(strategies, schemas.HuntStrategyPythonSpecific) + } + if depth == config.DepthThorough && hasLoweredLanguage(recon.Languages, "javascript", "typescript") { + strategies = append(strategies, schemas.HuntStrategyJavascriptSpecific) + } + + ordered := make([]schemas.HuntStrategy, 0, len(strategies)) + for _, s := range strategies { + seen := false + for _, o := range ordered { + if o == s { + seen = true + break + } + } + if !seen { + ordered = append(ordered, s) + } + } + return ordered +} + +// hasLoweredLanguage reports whether any entry of languages, lowercased, is one +// of wanted. +func hasLoweredLanguage(languages []string, wanted ...string) bool { + for _, lang := range languages { + lowered := strings.ToLower(lang) + for _, w := range wanted { + if lowered == w { + return true + } + } + } + return false +} + +// PrioritizeFindings ports `AuditOrchestrator._prioritize_findings` +// (orchestrator.py:539) — severity first, confidence second, both descending, +// unknown values scoring 0. +// +// It is byte-identical to reasoners/phases.py's `_prioritize_findings` and to +// agents/prove's `_priority_sort`; SEC-AF carries three copies and each Go +// package ports its own. +// +// Python parity: `sorted(..., reverse=True)` is STABLE (CPython reverses, sorts, +// reverses), so ties keep input order — sort.SliceStable with a strictly-greater +// comparison. The input slice is not mutated. +func (o *AuditOrchestrator) PrioritizeFindings(findings []schemas.RawFinding) []schemas.RawFinding { + severityRank := map[schemas.Severity]int{ + schemas.SeverityCritical: 5, + schemas.SeverityHigh: 4, + schemas.SeverityMedium: 3, + schemas.SeverityLow: 2, + schemas.SeverityInfo: 1, + } + confidenceRank := map[schemas.Confidence]int{ + schemas.ConfidenceHigh: 3, + schemas.ConfidenceMedium: 2, + schemas.ConfidenceLow: 1, + } + + out := make([]schemas.RawFinding, len(findings)) + copy(out, findings) + sort.SliceStable(out, func(i, j int) bool { + si, sj := severityRank[out[i].EstimatedSeverity], severityRank[out[j].EstimatedSeverity] + if si != sj { + return si > sj + } + return confidenceRank[out[i].Confidence] > confidenceRank[out[j].Confidence] + }) + return out +} + +// ProverCap ports `AuditOrchestrator._prover_cap()` (orchestrator.py:557): +// +// defaults = {QUICK: 10, STANDARD: 30, THOROUGH: 10_000} +// default_cap = defaults[self._depth_profile()] +// if self.input.max_provers is None: return default_cap +// return max(0, min(self.input.max_provers, default_cap)) +// +// Same arithmetic as phases' `_prover_cap`, but the depth and max_provers come +// from the orchestrator's own input rather than from arguments. +func (o *AuditOrchestrator) ProverCap() int { + defaultCap := 30 + switch o.depthProfile() { + case config.DepthQuick: + defaultCap = 10 + case config.DepthStandard: + defaultCap = 30 + case config.DepthThorough: + defaultCap = 10_000 + } + if o.Input.MaxProvers == nil { + return defaultCap + } + v := *o.Input.MaxProvers + if v > defaultCap { + v = defaultCap + } + if v < 0 { + v = 0 + } + return v +} diff --git a/go/internal/orch/testdata/fallback_finding.json b/go/internal/orch/testdata/fallback_finding.json new file mode 100644 index 0000000..bc337c5 --- /dev/null +++ b/go/internal/orch/testdata/fallback_finding.json @@ -0,0 +1,20 @@ +{ + "code_snippet": "cursor.execute(f\"SELECT * FROM users WHERE id = {user_id}\")", + "confidence": "high", + "cwe_id": "CWE-89", + "cwe_name": "SQL Injection", + "data_flow": [], + "description": "Potential injection from request parameter", + "end_line": 12, + "estimated_severity": "high", + "file_path": "src/users.py", + "finding_type": "sast", + "fingerprint": "fp-1", + "function_name": "get_user", + "hunter_strategy": "injection", + "id": "raw-1", + "owasp_category": "A03:2021", + "related_files": [], + "start_line": 10, + "title": "Potential SQL injection" +} diff --git a/go/internal/orch/testdata/findings_fixture.json b/go/internal/orch/testdata/findings_fixture.json new file mode 100644 index 0000000..e31c0ec --- /dev/null +++ b/go/internal/orch/testdata/findings_fixture.json @@ -0,0 +1,87 @@ +[ + { + "code_snippet": "x", + "confidence": "high", + "cwe_id": "CWE-200", + "cwe_name": "Information Exposure", + "description": "d", + "end_line": 1, + "estimated_severity": "low", + "file_path": "a.py", + "finding_type": "sast", + "fingerprint": "fp-low-high", + "hunter_strategy": "injection", + "id": "low-high", + "related_files": [], + "start_line": 1, + "title": "Low severity, high confidence" + }, + { + "code_snippet": "x", + "confidence": "medium", + "cwe_id": "CWE-287", + "cwe_name": "Improper Authentication", + "description": "d", + "end_line": 2, + "estimated_severity": "medium", + "file_path": "b.py", + "finding_type": "sast", + "fingerprint": "fp-medium-a", + "hunter_strategy": "auth", + "id": "medium-medium-a", + "related_files": [], + "start_line": 2, + "title": "Medium A" + }, + { + "code_snippet": "x", + "confidence": "low", + "cwe_id": "CWE-89", + "cwe_name": "SQL Injection", + "description": "d", + "end_line": 3, + "estimated_severity": "critical", + "file_path": "c.py", + "finding_type": "sast", + "fingerprint": "fp-critical-low", + "hunter_strategy": "injection", + "id": "critical-low", + "related_files": [], + "start_line": 3, + "title": "Critical, low confidence" + }, + { + "code_snippet": "x", + "confidence": "medium", + "cwe_id": "CWE-400", + "cwe_name": "Resource Exhaustion", + "description": "d", + "end_line": 4, + "estimated_severity": "medium", + "file_path": "d.py", + "finding_type": "sast", + "fingerprint": "fp-medium-b", + "hunter_strategy": "dos", + "id": "medium-medium-b", + "related_files": [], + "start_line": 4, + "title": "Medium B" + }, + { + "code_snippet": "x", + "confidence": "low", + "cwe_id": "CWE-79", + "cwe_name": "XSS", + "description": "d", + "end_line": 5, + "estimated_severity": "info", + "file_path": "e.py", + "finding_type": "sast", + "fingerprint": "fp-info", + "hunter_strategy": "xss", + "id": "unknown-unknown", + "related_files": [], + "start_line": 5, + "title": "Unrecognised severity" + } +] diff --git a/go/internal/orch/testdata/golden/checkpoint_created_at.json b/go/internal/orch/testdata/golden/checkpoint_created_at.json new file mode 100644 index 0000000..b1cdc37 --- /dev/null +++ b/go/internal/orch/testdata/golden/checkpoint_created_at.json @@ -0,0 +1,3 @@ +{ + "pinned": "2026-01-02T03:04:05.123456+00:00" +} diff --git a/go/internal/orch/testdata/golden/checkpoint_prove.txt b/go/internal/orch/testdata/golden/checkpoint_prove.txt new file mode 100644 index 0000000..858cec2 --- /dev/null +++ b/go/internal/orch/testdata/golden/checkpoint_prove.txt @@ -0,0 +1,44 @@ +{ + "phase": "prove", + "created_at": "2026-01-02T03:04:05.123456+00:00", + "data": [ + { + "id": "vf-1", + "fingerprint": "fp-1", + "title": "SQL injection in user lookup", + "description": "User id flows unsanitised into an f-string query", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "SQL Injection", + "owasp_category": null, + "tags": [], + "verdict": "confirmed", + "evidence_level": 4, + "rationale": "Traced source to sink", + "severity": "high", + "cvss_v4": null, + "epss": null, + "exploitability_score": 0.0, + "proof": null, + "location": { + "file_path": "src/users.py", + "start_line": 10, + "end_line": 12, + "start_column": null, + "end_column": null, + "function_name": null, + "code_snippet": null + }, + "related_locations": [], + "chain_id": null, + "chain_step": null, + "enables": null, + "compliance": [], + "reproduction_steps": [], + "remediation": null, + "sarif_rule_id": "sec-af/sast/sql-injection", + "sarif_security_severity": 0.0, + "drop_reason": null + } + ] +} \ No newline at end of file diff --git a/go/internal/orch/testdata/golden/checkpoint_prove_empty.txt b/go/internal/orch/testdata/golden/checkpoint_prove_empty.txt new file mode 100644 index 0000000..77e2cea --- /dev/null +++ b/go/internal/orch/testdata/golden/checkpoint_prove_empty.txt @@ -0,0 +1,5 @@ +{ + "phase": "prove_empty", + "created_at": "2026-01-02T03:04:05.123456+00:00", + "data": [] +} \ No newline at end of file diff --git a/go/internal/orch/testdata/golden/checkpoint_recon.txt b/go/internal/orch/testdata/golden/checkpoint_recon.txt new file mode 100644 index 0000000..21a4224 --- /dev/null +++ b/go/internal/orch/testdata/golden/checkpoint_recon.txt @@ -0,0 +1,43 @@ +{ + "phase": "recon", + "created_at": "2026-01-02T03:04:05.123456+00:00", + "data": { + "architecture": { + "app_type": null, + "modules": [], + "entry_points": [], + "trust_boundaries": [], + "services": [], + "api_surface": [] + }, + "data_flows": { + "flows": [], + "sanitization_points": [], + "sinks": [] + }, + "dependencies": { + "sbom": [], + "known_cves": [], + "outdated": [], + "direct_count": 0, + "transitive_count": 0 + }, + "config": { + "secrets": [], + "misconfigs": [] + }, + "security_context": { + "auth_model": "", + "auth_details": "", + "crypto_usage": [], + "framework_security": [], + "security_headers": [], + "deployment_signals": [] + }, + "languages": [], + "frameworks": [], + "lines_of_code": 0, + "file_count": 0, + "recon_duration_seconds": 0.0 + } +} \ No newline at end of file diff --git a/go/internal/orch/testdata/golden/default_strategies.json b/go/internal/orch/testdata/golden/default_strategies.json new file mode 100644 index 0000000..406342f --- /dev/null +++ b/go/internal/orch/testdata/golden/default_strategies.json @@ -0,0 +1,149 @@ +{ + "full|": [ + "injection", + "dos", + "ssrf", + "auth", + "data_exposure", + "config_secrets", + "crypto", + "supply_chain", + "api_security", + "business_logic" + ], + "full|QUICK": [ + "injection", + "dos", + "ssrf", + "auth", + "data_exposure", + "config_secrets", + "crypto", + "supply_chain", + "api_security" + ], + "full|Thorough": [ + "injection", + "dos", + "ssrf", + "auth", + "data_exposure", + "config_secrets", + "crypto", + "supply_chain", + "api_security", + "business_logic", + "python_specific", + "javascript_specific" + ], + "full|bogus": [ + "injection", + "dos", + "ssrf", + "auth", + "data_exposure", + "config_secrets", + "crypto", + "supply_chain", + "api_security", + "business_logic" + ], + "full|quick": [ + "injection", + "dos", + "ssrf", + "auth", + "data_exposure", + "config_secrets", + "crypto", + "supply_chain", + "api_security" + ], + "full|standard": [ + "injection", + "dos", + "ssrf", + "auth", + "data_exposure", + "config_secrets", + "crypto", + "supply_chain", + "api_security", + "business_logic" + ], + "full|thorough": [ + "injection", + "dos", + "ssrf", + "auth", + "data_exposure", + "config_secrets", + "crypto", + "supply_chain", + "api_security", + "business_logic", + "python_specific", + "javascript_specific" + ], + "minimal|": [ + "injection", + "dos", + "ssrf", + "auth", + "data_exposure", + "config_secrets", + "business_logic" + ], + "minimal|QUICK": [ + "injection", + "dos", + "ssrf", + "auth", + "data_exposure", + "config_secrets" + ], + "minimal|Thorough": [ + "injection", + "dos", + "ssrf", + "auth", + "data_exposure", + "config_secrets", + "business_logic" + ], + "minimal|bogus": [ + "injection", + "dos", + "ssrf", + "auth", + "data_exposure", + "config_secrets", + "business_logic" + ], + "minimal|quick": [ + "injection", + "dos", + "ssrf", + "auth", + "data_exposure", + "config_secrets" + ], + "minimal|standard": [ + "injection", + "dos", + "ssrf", + "auth", + "data_exposure", + "config_secrets", + "business_logic" + ], + "minimal|thorough": [ + "injection", + "dos", + "ssrf", + "auth", + "data_exposure", + "config_secrets", + "business_logic" + ] +} diff --git a/go/internal/orch/testdata/golden/merge_recon_findings.json b/go/internal/orch/testdata/golden/merge_recon_findings.json new file mode 100644 index 0000000..7c87730 --- /dev/null +++ b/go/internal/orch/testdata/golden/merge_recon_findings.json @@ -0,0 +1,60 @@ +{ + "already_present": [ + "recon", + "injection" + ], + "empty_recon_is_identity": true, + "merged": { + "chain_count": 0, + "chains": [], + "deduplicated_count": 2, + "findings": [ + { + "code_snippet": "x", + "confidence": "low", + "cwe_id": "CWE-89", + "cwe_name": "SQL Injection", + "data_flow": null, + "description": "d", + "end_line": 3, + "estimated_severity": "critical", + "file_path": "c.py", + "finding_type": "sast", + "fingerprint": "fp-critical-low", + "function_name": null, + "hunter_strategy": "injection", + "id": "critical-low", + "owasp_category": null, + "related_files": [], + "start_line": 3, + "title": "Critical, low confidence" + }, + { + "code_snippet": "x", + "confidence": "high", + "cwe_id": "CWE-200", + "cwe_name": "Information Exposure", + "data_flow": null, + "description": "d", + "end_line": 1, + "estimated_severity": "low", + "file_path": "a.py", + "finding_type": "sast", + "fingerprint": "fp-low-high", + "function_name": null, + "hunter_strategy": "injection", + "id": "low-high", + "owasp_category": null, + "related_files": [], + "start_line": 1, + "title": "Low severity, high confidence" + } + ], + "hunt_duration_seconds": 0.0, + "strategies_run": [ + "recon", + "injection" + ], + "total_raw": 2 + } +} diff --git a/go/internal/orch/testdata/golden/prioritize_findings.json b/go/internal/orch/testdata/golden/prioritize_findings.json new file mode 100644 index 0000000..10314d5 --- /dev/null +++ b/go/internal/orch/testdata/golden/prioritize_findings.json @@ -0,0 +1,7 @@ +[ + "critical-low", + "medium-medium-a", + "medium-medium-b", + "low-high", + "unknown-unknown" +] diff --git a/go/internal/orch/testdata/golden/progress_fields.json b/go/internal/orch/testdata/golden/progress_fields.json new file mode 100644 index 0000000..ba493bc --- /dev/null +++ b/go/internal/orch/testdata/golden/progress_fields.json @@ -0,0 +1,57 @@ +{ + "hunt_done": { + "agents_completed": 1, + "agents_running": 0, + "agents_total": 1, + "cost_so_far_usd": 0.1235, + "elapsed_seconds": 2.5, + "estimated_remaining_seconds": 0.0, + "findings_so_far": 7, + "phase": "hunt", + "phase_progress": 1.0 + }, + "overshoot": { + "agents_completed": 5, + "agents_running": 0, + "agents_total": 2, + "cost_so_far_usd": 0.1235, + "elapsed_seconds": 2.5, + "estimated_remaining_seconds": 0.0, + "findings_so_far": 3, + "phase": "prove", + "phase_progress": 1.0 + }, + "recon_full": { + "agents_completed": 2, + "agents_running": 0, + "agents_total": 2, + "cost_so_far_usd": 0.1235, + "elapsed_seconds": 2.5, + "estimated_remaining_seconds": 0.0, + "findings_so_far": 0, + "phase": "recon", + "phase_progress": 1.0 + }, + "recon_half": { + "agents_completed": 1, + "agents_running": 1, + "agents_total": 2, + "cost_so_far_usd": 0.1235, + "elapsed_seconds": 2.5, + "estimated_remaining_seconds": 2.5, + "findings_so_far": 0, + "phase": "recon", + "phase_progress": 0.5 + }, + "zero_total": { + "agents_completed": 0, + "agents_running": 0, + "agents_total": 0, + "cost_so_far_usd": 0.1235, + "elapsed_seconds": 2.5, + "estimated_remaining_seconds": 0.0, + "findings_so_far": 0, + "phase": "prove", + "phase_progress": 0.0 + } +} diff --git a/go/internal/orch/testdata/golden/progress_model_dump_json.json b/go/internal/orch/testdata/golden/progress_model_dump_json.json new file mode 100644 index 0000000..c75d4ca --- /dev/null +++ b/go/internal/orch/testdata/golden/progress_model_dump_json.json @@ -0,0 +1,4 @@ +{ + "fractional": "{\"phase\":\"hunt\",\"phase_progress\":0.5,\"agents_total\":4,\"agents_completed\":2,\"agents_running\":2,\"findings_so_far\":13,\"elapsed_seconds\":1.25,\"estimated_remaining_seconds\":1.25,\"cost_so_far_usd\":0.1235}", + "unit_progress": "{\"phase\":\"recon\",\"phase_progress\":1.0,\"agents_total\":2,\"agents_completed\":2,\"agents_running\":0,\"findings_so_far\":0,\"elapsed_seconds\":2.5,\"estimated_remaining_seconds\":0.0,\"cost_so_far_usd\":0.0}" +} diff --git a/go/internal/orch/testdata/golden/progress_notes.json b/go/internal/orch/testdata/golden/progress_notes.json new file mode 100644 index 0000000..0314e6a --- /dev/null +++ b/go/internal/orch/testdata/golden/progress_notes.json @@ -0,0 +1,7 @@ +{ + "hunt_done": "{\"phase\":\"hunt\",\"phase_progress\":1.0,\"agents_total\":1,\"agents_completed\":1,\"agents_running\":0,\"findings_so_far\":7,\"elapsed_seconds\":2.5,\"estimated_remaining_seconds\":0.0,\"cost_so_far_usd\":0.1235}", + "overshoot": "{\"phase\":\"prove\",\"phase_progress\":1.0,\"agents_total\":2,\"agents_completed\":5,\"agents_running\":0,\"findings_so_far\":3,\"elapsed_seconds\":2.5,\"estimated_remaining_seconds\":0.0,\"cost_so_far_usd\":0.1235}", + "recon_full": "{\"phase\":\"recon\",\"phase_progress\":1.0,\"agents_total\":2,\"agents_completed\":2,\"agents_running\":0,\"findings_so_far\":0,\"elapsed_seconds\":2.5,\"estimated_remaining_seconds\":0.0,\"cost_so_far_usd\":0.1235}", + "recon_half": "{\"phase\":\"recon\",\"phase_progress\":0.5,\"agents_total\":2,\"agents_completed\":1,\"agents_running\":1,\"findings_so_far\":0,\"elapsed_seconds\":2.5,\"estimated_remaining_seconds\":2.5,\"cost_so_far_usd\":0.1235}", + "zero_total": "{\"phase\":\"prove\",\"phase_progress\":0.0,\"agents_total\":0,\"agents_completed\":0,\"agents_running\":0,\"findings_so_far\":0,\"elapsed_seconds\":2.5,\"estimated_remaining_seconds\":0.0,\"cost_so_far_usd\":0.1235}" +} diff --git a/go/internal/orch/testdata/golden/prover_cap.json b/go/internal/orch/testdata/golden/prover_cap.json new file mode 100644 index 0000000..3117874 --- /dev/null +++ b/go/internal/orch/testdata/golden/prover_cap.json @@ -0,0 +1,11 @@ +{ + "bogus|null": 30, + "quick|3": 3, + "quick|50": 10, + "quick|null": 10, + "standard|-5": 0, + "standard|0": 0, + "standard|null": 30, + "thorough|12345": 10000, + "thorough|null": 10000 +} diff --git a/go/internal/orch/testdata/golden/reachability_fallback_tags.json b/go/internal/orch/testdata/golden/reachability_fallback_tags.json new file mode 100644 index 0000000..b563c9a --- /dev/null +++ b/go/internal/orch/testdata/golden/reachability_fallback_tags.json @@ -0,0 +1,5 @@ +{ + "tags_after_gate_failure": [ + "requires_auth" + ] +} diff --git a/go/internal/orch/testdata/golden/reachability_summary.txt b/go/internal/orch/testdata/golden/reachability_summary.txt new file mode 100644 index 0000000..5476e6c --- /dev/null +++ b/go/internal/orch/testdata/golden/reachability_summary.txt @@ -0,0 +1,5 @@ +Finding: SQL injection in user lookup +Description: User id flows unsanitised into an f-string query +CWE: CWE-89 +File: src/users.py:10 +Verdict: confirmed \ No newline at end of file diff --git a/go/internal/orch/testdata/golden/verified_finding_fallback.json b/go/internal/orch/testdata/golden/verified_finding_fallback.json new file mode 100644 index 0000000..3d36995 --- /dev/null +++ b/go/internal/orch/testdata/golden/verified_finding_fallback.json @@ -0,0 +1,38 @@ +{ + "chain_id": null, + "chain_step": null, + "compliance": [], + "cvss_v4": null, + "cwe_id": "CWE-89", + "cwe_name": "SQL Injection", + "description": "Potential injection from request parameter", + "drop_reason": null, + "enables": null, + "epss": null, + "evidence_level": 1, + "exploitability_score": 0.0, + "finding_type": "sast", + "fingerprint": "fp-1", + "id": "raw-1", + "location": { + "code_snippet": "cursor.execute(f\"SELECT * FROM users WHERE id = {user_id}\")", + "end_column": null, + "end_line": 12, + "file_path": "src/users.py", + "function_name": "get_user", + "start_column": null, + "start_line": 10 + }, + "owasp_category": "A03:2021", + "proof": null, + "rationale": "Automated proof unavailable; requires manual review.", + "related_locations": [], + "remediation": null, + "reproduction_steps": [], + "sarif_rule_id": "sec-af/sast/cwe-89", + "sarif_security_severity": 0.0, + "severity": "high", + "tags": [], + "title": "Potential SQL injection", + "verdict": "inconclusive" +} diff --git a/go/internal/orch/testdata/recon_fixture.json b/go/internal/orch/testdata/recon_fixture.json new file mode 100644 index 0000000..0de5f12 --- /dev/null +++ b/go/internal/orch/testdata/recon_fixture.json @@ -0,0 +1,143 @@ +{ + "full": { + "architecture": { + "api_surface": [ + { + "auth_required": true, + "file_path": "src/api/users.py", + "handler": "list_users", + "line": 12, + "method": "GET", + "path": "/users", + "rate_limited": false + }, + { + "auth_required": false, + "file_path": "src/api/users.py", + "handler": "delete_user", + "line": 40, + "method": "DELETE", + "path": "/users/{id}", + "rate_limited": false + } + ], + "app_type": "web_api", + "entry_points": [], + "modules": [ + { + "dependencies": [], + "language": "Python", + "name": "api", + "path": "src/api" + }, + { + "dependencies": [], + "language": "TypeScript", + "name": "web", + "path": "web" + }, + { + "dependencies": [], + "language": "python", + "name": "legacy", + "path": "legacy" + }, + { + "dependencies": [], + "language": "", + "name": "blank", + "path": "blank" + } + ], + "services": [], + "trust_boundaries": [] + }, + "config": { + "misconfigs": [ + { + "category": "tls", + "file_path": "nginx.conf", + "id": "misconfig-1", + "key": "ssl_protocols", + "line": 3, + "risk": "TLS 1.0 enabled", + "value": "TLSv1" + } + ], + "secrets": [ + { + "confidence": "high", + "file_path": "src/config.py", + "id": "secret-1", + "line": 7, + "match": "API_KEY = \"sk-live-123\"", + "secret_type": "api_key" + } + ] + }, + "data_flows": {}, + "dependencies": { + "direct_count": 5, + "known_cves": [ + { + "cve_id": "CVE-2024-0001", + "cvss_v4_score": 9.1, + "direct": true, + "fixed_version": "2.32.0", + "installed_version": "2.20.0", + "package": "requests" + } + ], + "outdated": [], + "sbom": [], + "transitive_count": 20 + }, + "file_count": 42, + "frameworks": [ + "django" + ], + "languages": [ + "python", + "typescript" + ], + "lines_of_code": 5000, + "security_context": { + "auth_details": "Bearer token", + "auth_model": "jwt", + "crypto_usage": [ + { + "algorithm": "TLSv1.0", + "is_weak": true, + "usage_context": "legacy tls terminator" + }, + { + "algorithm": "AES-256-GCM", + "is_weak": false, + "usage_context": "at-rest encryption" + } + ], + "deployment_signals": [], + "framework_security": [ + "django-rest-framework", + "", + "django-rest-framework", + "Django" + ], + "security_headers": [] + } + }, + "minimal": { + "architecture": {}, + "config": {}, + "data_flows": {}, + "dependencies": {}, + "file_count": 0, + "frameworks": [], + "languages": [], + "lines_of_code": 0, + "security_context": { + "auth_details": "", + "auth_model": "" + } + } +} diff --git a/go/internal/orch/testdata/verified_fixture.json b/go/internal/orch/testdata/verified_fixture.json new file mode 100644 index 0000000..21ce32c --- /dev/null +++ b/go/internal/orch/testdata/verified_fixture.json @@ -0,0 +1,22 @@ +{ + "cwe_id": "CWE-89", + "cwe_name": "SQL Injection", + "description": "User id flows unsanitised into an f-string query", + "evidence_level": 4, + "exploitability_score": 0.0, + "finding_type": "sast", + "fingerprint": "fp-1", + "id": "vf-1", + "location": { + "end_line": 12, + "file_path": "src/users.py", + "start_line": 10 + }, + "rationale": "Traced source to sink", + "sarif_rule_id": "sec-af/sast/sql-injection", + "sarif_security_severity": 0.0, + "severity": "high", + "tags": [], + "title": "SQL injection in user lookup", + "verdict": "confirmed" +} diff --git a/go/internal/output/compliance_report.go b/go/internal/output/compliance_report.go new file mode 100644 index 0000000..e04b220 --- /dev/null +++ b/go/internal/output/compliance_report.go @@ -0,0 +1,311 @@ +package output + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// This file ports src/sec_af/output/compliance_report.py — the +// framework-organised Markdown report meant for PDF conversion. + +// severityIconTable ports the literal dict inside _severity_icon. +var severityIconTable = map[string]string{ + "critical": "CRITICAL", + "high": "HIGH", + "medium": "MEDIUM", + "low": "LOW", +} + +// verdictLabelTable ports the literal dict inside _verdict_label. +var verdictLabelTable = map[string]string{ + "confirmed": "Confirmed", + "likely": "Likely", + "inconclusive": "Inconclusive", + "not_exploitable": "Not Exploitable", +} + +// severityOrder is the fixed iteration order of _render_executive_summary's +// severity table. +var severityOrder = []string{"critical", "high", "medium", "low", "info"} + +// severityIcon ports _severity_icon: note the default is "INFO", so "info" and +// any unknown severity share a label. +func severityIcon(severity string) string { + if icon, ok := severityIconTable[strings.ToLower(severity)]; ok { + return icon + } + return "INFO" +} + +// verdictLabel ports _verdict_label: an unknown verdict is echoed back +// verbatim (`dict.get(v, v)`). +func verdictLabel(verdictValue string) string { + if label, ok := verdictLabelTable[verdictValue]; ok { + return label + } + return verdictValue +} + +// renderComplianceHeader ports _render_header. +// +// `now` is the value Python reads from `datetime.now(UTC)`; it is a parameter +// rather than a call so the golden test can pin it — see +// GenerateComplianceReportAt. +func renderComplianceHeader(result schemas.SecurityAuditResult, now time.Time) []string { + branch := "N/A" + if result.Branch != nil && *result.Branch != "" { + branch = *result.Branch + } + return []string{ + "# SEC-AF Compliance Report", + "", + fmt.Sprintf("**Generated:** %s", now.UTC().Format("2006-01-02 15:04")+" UTC"), + fmt.Sprintf("**Repository:** %s", result.Repository), + fmt.Sprintf("**Commit:** %s", result.CommitSha), + fmt.Sprintf("**Branch:** %s", branch), + fmt.Sprintf("**Scan Depth:** %s", result.DepthProfile), + fmt.Sprintf("**Total Findings:** %d", len(result.Findings)), + "", + "---", + "", + } +} + +// renderExecutiveSummary ports _render_executive_summary. +func renderExecutiveSummary(result schemas.SecurityAuditResult) []string { + summaryLine := fmt.Sprintf( + "This report covers a security audit of `%s` at commit `%s`. "+ + "The scan identified **%d** findings, of which **%d** are confirmed exploitable.", + result.Repository, headRunes(result.CommitSha, 8), len(result.Findings), result.Confirmed) + + lines := []string{ + "## Executive Summary", + "", + summaryLine, + "", + "### Verdict Distribution", + "", + "| Verdict | Count |", + "|---------|-------|", + fmt.Sprintf("| Confirmed | %d |", result.Confirmed), + fmt.Sprintf("| Likely | %d |", result.Likely), + fmt.Sprintf("| Inconclusive | %d |", result.Inconclusive), + fmt.Sprintf("| Not Exploitable | %d |", result.NotExploitable), + "", + fmt.Sprintf("**Noise Reduction:** %.1f%%", result.NoiseReductionPct), + "", + } + + if len(result.BySeverity) > 0 { + lines = append(lines, + "### Severity Distribution", + "", + "| Severity | Count |", + "|----------|-------|", + ) + for _, severity := range severityOrder { + count := result.BySeverity[severity] + if count > 0 { + lines = append(lines, fmt.Sprintf("| %s | %d |", severityIcon(severity), count)) + } + } + lines = append(lines, "") + } + return lines +} + +// renderComplianceSection ports _render_compliance_section: gaps grouped by +// framework (frameworks sorted, gaps within a framework sorted by control id), +// with the CWE column truncated at five entries. +func renderComplianceSection(result schemas.SecurityAuditResult) []string { + if len(result.ComplianceGaps) == 0 { + return []string{"## Compliance Status", "", "No compliance gaps identified.", ""} + } + + byFramework := map[string][]schemas.ComplianceGap{} + frameworks := make([]string, 0) + for _, gap := range result.ComplianceGaps { + if _, seen := byFramework[gap.Framework]; !seen { + frameworks = append(frameworks, gap.Framework) + } + byFramework[gap.Framework] = append(byFramework[gap.Framework], gap) + } + sort.Strings(frameworks) + + lines := []string{"## Compliance Gap Analysis", ""} + for _, framework := range frameworks { + gaps := append([]schemas.ComplianceGap(nil), byFramework[framework]...) + // Python parity: `sorted(gaps, key=lambda g: g.control_id)` is a STABLE + // sort on the control id alone, so equal control ids keep the order they + // had in result.compliance_gaps. + sort.SliceStable(gaps, func(i, j int) bool { return gaps[i].ControlID < gaps[j].ControlID }) + + lines = append(lines, + fmt.Sprintf("### %s", framework), + "", + "| Control ID | Control Name | Findings | Max Severity | CWEs |", + "|-----------|-------------|----------|-------------|------|", + ) + for _, gap := range gaps { + shown := gap.CweIDs + if len(shown) > 5 { + shown = shown[:5] + } + cweStr := strings.Join(shown, ", ") + if len(gap.CweIDs) > 5 { + cweStr += fmt.Sprintf(" (+%d more)", len(gap.CweIDs)-5) + } + lines = append(lines, fmt.Sprintf("| %s | %s | %d | %s | %s |", + gap.ControlID, gap.ControlName, gap.FindingCount, severityIcon(gap.MaxSeverity), cweStr)) + } + lines = append(lines, "") + } + return lines +} + +// renderFindingsByFramework ports _render_findings_by_framework. +// +// Python parity: a finding is listed once per DISTINCT framework among its +// compliance mappings (the `seen_frameworks` set), and findings with no +// mappings at all land in the trailing "Uncategorized Findings" section. +func renderFindingsByFramework(result schemas.SecurityAuditResult) []string { + if len(result.Findings) == 0 { + return []string{"## Detailed Findings", "", "No findings to report.", ""} + } + + frameworkFindings := map[string][]schemas.VerifiedFinding{} + frameworks := make([]string, 0) + uncategorized := make([]schemas.VerifiedFinding, 0) + + for _, finding := range result.Findings { + if len(finding.Compliance) == 0 { + uncategorized = append(uncategorized, finding) + continue + } + seen := map[string]struct{}{} + for _, mapping := range finding.Compliance { + if _, ok := seen[mapping.Framework]; ok { + continue + } + if _, known := frameworkFindings[mapping.Framework]; !known { + frameworks = append(frameworks, mapping.Framework) + } + frameworkFindings[mapping.Framework] = append(frameworkFindings[mapping.Framework], finding) + seen[mapping.Framework] = struct{}{} + } + } + sort.Strings(frameworks) + + lines := []string{"## Detailed Findings by Framework", ""} + for _, framework := range frameworks { + lines = append(lines, fmt.Sprintf("### %s", framework), "") + for _, finding := range frameworkFindings[framework] { + lines = append(lines, + fmt.Sprintf("#### %s", finding.Title), + "", + fmt.Sprintf("- **Verdict:** %s", verdictLabel(string(finding.Verdict))), + fmt.Sprintf("- **Severity:** %s", severityIcon(string(finding.Severity))), + fmt.Sprintf("- **CWE:** %s (%s)", finding.CweID, finding.CweName), + fmt.Sprintf("- **Location:** `%s:%d`", finding.Location.FilePath, finding.Location.StartLine), + fmt.Sprintf("- **Evidence Level:** %d/6", int(finding.EvidenceLevel)), + fmt.Sprintf("- **Exploitability Score:** %.1f/10", finding.ExploitabilityScore), + "", + ) + if finding.Rationale != "" { + lines = append(lines, fmt.Sprintf("**Rationale:** %s", finding.Rationale), "") + } + complianceLines := make([]string, 0, len(finding.Compliance)) + for _, mapping := range finding.Compliance { + complianceLines = append(complianceLines, + fmt.Sprintf(" - %s %s: %s", mapping.Framework, mapping.ControlID, mapping.ControlName)) + } + if len(complianceLines) > 0 { + lines = append(lines, "**Compliance Mappings:**") + lines = append(lines, complianceLines...) + lines = append(lines, "") + } + } + } + + if len(uncategorized) > 0 { + lines = append(lines, "### Uncategorized Findings", "") + for _, finding := range uncategorized { + lines = append(lines, + fmt.Sprintf("#### %s", finding.Title), + "", + fmt.Sprintf("- **Verdict:** %s", verdictLabel(string(finding.Verdict))), + fmt.Sprintf("- **Severity:** %s", severityIcon(string(finding.Severity))), + fmt.Sprintf("- **CWE:** %s", finding.CweID), + fmt.Sprintf("- **Location:** `%s:%d`", finding.Location.FilePath, finding.Location.StartLine), + "", + ) + } + } + return lines +} + +// renderComplianceFooter ports _render_footer. +func renderComplianceFooter(result schemas.SecurityAuditResult) []string { + return []string{ + "---", + "", + "## Audit Metadata", + "", + fmt.Sprintf("- **Duration:** %.1fs", result.DurationSeconds), + fmt.Sprintf("- **Agent Invocations:** %d", result.AgentInvocations), + fmt.Sprintf("- **Cost:** $%.2f", result.CostUsd), + fmt.Sprintf("- **Strategies Used:** %s", strings.Join(result.StrategiesUsed, ", ")), + "", + "*Report generated by SEC-AF -- Composite Intelligence Security Auditor*", + } +} + +// GenerateComplianceReport ports generate_compliance_report: a +// compliance-focused Markdown report, structured for direct PDF conversion via +// pandoc/weasyprint. +// +// The header stamps the CURRENT time, exactly as the Python function does +// (`datetime.now(UTC)`), so two calls a minute apart differ. Tests and any +// caller that needs a reproducible document use GenerateComplianceReportAt. +func GenerateComplianceReport(result schemas.SecurityAuditResult) string { + return GenerateComplianceReportAt(result, time.Now().UTC()) +} + +// GenerateComplianceReportAt is GenerateComplianceReport with the "Generated:" +// timestamp supplied by the caller. +// +// This seam is Go-only: Python reads the clock inline, so pinning it there +// takes monkeypatching (which is what scripts/gen_golden.py does to produce the +// committed golden). Nothing about the rest of the document depends on it. +func GenerateComplianceReportAt(result schemas.SecurityAuditResult, now time.Time) string { + sections := [][]string{ + renderComplianceHeader(result, now), + renderExecutiveSummary(result), + renderComplianceSection(result), + renderFindingsByFramework(result), + renderComplianceFooter(result), + } + lines := make([]string, 0, 64) + for _, section := range sections { + lines = append(lines, section...) + } + return strings.Join(lines, "\n") +} + +// headRunes ports Python's `s[:n]` string slice: it counts CHARACTERS, and a +// string shorter than n is returned whole rather than panicking (which +// s[:n] on a Go string would do). +func headRunes(s string, n int) string { + count := 0 + for i := range s { + if count == n { + return s[:i] + } + count++ + } + return s +} diff --git a/go/internal/output/compliance_report_test.go b/go/internal/output/compliance_report_test.go new file mode 100644 index 0000000..f545724 --- /dev/null +++ b/go/internal/output/compliance_report_test.go @@ -0,0 +1,204 @@ +package output + +import ( + "strings" + "testing" + "time" +) + +// This file ports tests/test_compliance_report.py. Its `_make_result()` fixture +// is the testdata/audit_result_report.json fixture (gen_golden_output.py +// builds it from the same values, with the finding's uuid4 id pinned). + +// complianceReport renders the ported fixture with the frozen clock. +func complianceReport(t *testing.T) string { + t.Helper() + return GenerateComplianceReportAt(loadFixture(t, "audit_result_report"), goldenComplianceReportAt) +} + +// TestComplianceReportContainsHeader ports test_compliance_report_contains_header. +func TestComplianceReportContainsHeader(t *testing.T) { + report := complianceReport(t) + for _, want := range []string{"# SEC-AF Compliance Report", "https://github.com/test/repo"} { + if !strings.Contains(report, want) { + t.Errorf("report is missing %q", want) + } + } +} + +// TestComplianceReportContainsExecutiveSummary ports +// test_compliance_report_contains_executive_summary. +func TestComplianceReportContainsExecutiveSummary(t *testing.T) { + report := complianceReport(t) + for _, want := range []string{"Executive Summary", "Confirmed"} { + if !strings.Contains(report, want) { + t.Errorf("report is missing %q", want) + } + } +} + +// TestComplianceReportContainsComplianceGaps ports +// test_compliance_report_contains_compliance_gaps. +func TestComplianceReportContainsComplianceGaps(t *testing.T) { + report := complianceReport(t) + for _, want := range []string{"Compliance Gap Analysis", "OWASP", "A03:2021"} { + if !strings.Contains(report, want) { + t.Errorf("report is missing %q", want) + } + } +} + +// TestComplianceReportContainsFindings ports +// test_compliance_report_contains_findings. +func TestComplianceReportContainsFindings(t *testing.T) { + report := complianceReport(t) + for _, want := range []string{"Test Finding", "CWE-89"} { + if !strings.Contains(report, want) { + t.Errorf("report is missing %q", want) + } + } +} + +// TestComplianceReportEmptyFindings ports +// test_compliance_report_empty_findings. +func TestComplianceReportEmptyFindings(t *testing.T) { + report := GenerateComplianceReportAt(loadFixture(t, "audit_result_empty"), goldenComplianceReportAt) + if !strings.Contains(report, "No findings to report") { + t.Error("report is missing the empty-findings notice") + } + // The empty fixture also has no gaps, so the OTHER empty branch fires. + if !strings.Contains(report, "No compliance gaps identified.") { + t.Error("report is missing the empty-gaps notice") + } +} + +// TestComplianceReportContainsMetadata ports +// test_compliance_report_contains_metadata. +func TestComplianceReportContainsMetadata(t *testing.T) { + report := complianceReport(t) + for _, want := range []string{"Audit Metadata", "45.2s"} { + if !strings.Contains(report, want) { + t.Errorf("report is missing %q", want) + } + } +} + +// --------------------------------------------------------------------------- +// behaviours with no Python test +// --------------------------------------------------------------------------- + +// TestGenerateComplianceReportStampsTheClock proves the exported +// GenerateComplianceReport reads the real clock (Python's +// `datetime.now(UTC)`), and that only the header line differs from the pinned +// rendering. +func TestGenerateComplianceReportStampsTheClock(t *testing.T) { + result := loadFixture(t, "audit_result_report") + before := time.Now().UTC() + live := GenerateComplianceReport(result) + after := time.Now().UTC() + + pinned := GenerateComplianceReportAt(result, goldenComplianceReportAt) + liveLines := strings.Split(live, "\n") + pinnedLines := strings.Split(pinned, "\n") + if len(liveLines) != len(pinnedLines) { + t.Fatalf("line counts differ: %d vs %d", len(liveLines), len(pinnedLines)) + } + for i := range liveLines { + if i == 2 { + continue // the "**Generated:**" line + } + if liveLines[i] != pinnedLines[i] { + t.Fatalf("line %d differs outside the header: %q vs %q", i+1, liveLines[i], pinnedLines[i]) + } + } + + // The stamped minute must be one of the minutes the call spanned. + acceptable := map[string]bool{ + "**Generated:** " + before.Format("2006-01-02 15:04") + " UTC": true, + "**Generated:** " + after.Format("2006-01-02 15:04") + " UTC": true, + } + if !acceptable[liveLines[2]] { + t.Errorf("header = %q, want a stamp between %v and %v", liveLines[2], before, after) + } +} + +// TestSeverityIcon covers _severity_icon, whose default is "INFO" — so "info" +// and any unrecognised severity share a label. +func TestSeverityIcon(t *testing.T) { + cases := map[string]string{ + "critical": "CRITICAL", + "CRITICAL": "CRITICAL", + "high": "HIGH", + "medium": "MEDIUM", + "low": "LOW", + "info": "INFO", + "unknown-severity": "INFO", + "": "INFO", + } + for input, want := range cases { + if got := severityIcon(input); got != want { + t.Errorf("severityIcon(%q) = %q, want %q", input, got, want) + } + } +} + +// TestVerdictLabel covers _verdict_label, whose default echoes the input. +func TestVerdictLabel(t *testing.T) { + cases := map[string]string{ + "confirmed": "Confirmed", + "likely": "Likely", + "inconclusive": "Inconclusive", + "not_exploitable": "Not Exploitable", + "weird": "weird", + } + for input, want := range cases { + if got := verdictLabel(input); got != want { + t.Errorf("verdictLabel(%q) = %q, want %q", input, got, want) + } + } +} + +// TestHeadRunes covers Python's `s[:n]` slice, which never panics on a short +// string and counts characters rather than bytes. +func TestHeadRunes(t *testing.T) { + cases := []struct { + in string + n int + want string + }{ + {"abc123def456", 8, "abc123de"}, + {"abc123", 8, "abc123"}, + {"", 8, ""}, + {"naïve-sha", 4, "naïv"}, + {"abc", 0, ""}, + } + for _, tc := range cases { + if got := headRunes(tc.in, tc.n); got != tc.want { + t.Errorf("headRunes(%q, %d) = %q, want %q", tc.in, tc.n, got, tc.want) + } + } +} + +// TestComplianceReportFindingListedOncePerFramework pins the seen_frameworks +// de-duplication: the edge fixture's dup-a carries TWO OWASP mappings and one +// PCI-DSS mapping, so it appears once under each framework heading, not twice +// under OWASP. +func TestComplianceReportFindingListedOncePerFramework(t *testing.T) { + report := GenerateComplianceReportAt(loadFixture(t, "audit_result_edge"), goldenComplianceReportAt) + title := "#### Naïve \"quote\" & handling" + if got := strings.Count(report, title); got != 2 { + t.Errorf("dup-a appears %d times, want 2 (once for OWASP, once for PCI-DSS)", got) + } + if !strings.Contains(report, "### Uncategorized Findings") { + t.Error("findings without compliance mappings must land in Uncategorized Findings") + } +} + +// TestComplianceReportTruncatesCweList pins the "(+N more)" suffix. +func TestComplianceReportTruncatesCweList(t *testing.T) { + report := GenerateComplianceReportAt(loadFixture(t, "audit_result_edge"), goldenComplianceReportAt) + want := "CWE-78, CWE-79, CWE-89, CWE-90, CWE-91 (+2 more)" + if !strings.Contains(report, want) { + t.Errorf("report is missing %q", want) + } +} diff --git a/go/internal/output/golden_test.go b/go/internal/output/golden_test.go new file mode 100644 index 0000000..ce7814b --- /dev/null +++ b/go/internal/output/golden_test.go @@ -0,0 +1,181 @@ +package output + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// This file is the byte-for-byte parity gate for internal/output. +// +// scripts/gen_golden_output.py builds four SecurityAuditResult fixtures in +// Python, writes each one to testdata/.json, re-reads it, and writes the +// five artifacts the Python generators produce from it under testdata/golden/. +// The tests below load the SAME fixture files into the Go structs and diff their +// own output against those bytes. +// +// Regenerate after any change to src/sec_af/output/** (the umbrella +// scripts/gen_golden.py calls it too): +// +// PYTHONPATH=src ~/.agentfield/packages/sec-af/venv/bin/python go/scripts/gen_golden_output.py +// +// The fixtures are, in increasing nastiness: +// +// audit_result tests/conftest.py's sample_security_audit_result +// audit_result_empty every "nothing to report" branch at once +// audit_result_edge escaping, float ties, and every truthiness guard +// audit_result_report tests/test_compliance_report.py::_make_result + +// goldenFixtures names the fixtures every generator is checked against. +var goldenFixtures = []string{ + "audit_result", "audit_result_empty", "audit_result_edge", "audit_result_report", + // audit_result_floats is the FLOAT/INT-spelling fixture: it is the only one + // carrying a magnitude outside plain decimal range (1e-7, 8e-05, 1e-5, + // 5e-324, 1e16) and the only one whose `metadata` holds the wire-decoded + // prove_phase drop_summary, whose counts are Python ints. Between them the + // full.json and full_compact.json goldens pin BOTH float rules at once: + // `generate_json(result, pretty=False)` returns model_dump_json() verbatim + // (pydantic-core spells 1e-7 as "1e-7" and 8e-05 as "0.00008") while + // `pretty=True` re-serialises through json.dumps and therefore wants + // repr()'s "1e-07" / "8e-05". + "audit_result_floats", +} + +// goldenComplianceReportAt is the instant scripts/gen_golden.py freezes +// `datetime.now(UTC)` to while rendering the compliance report. +var goldenComplianceReportAt = time.Date(2026, 5, 6, 7, 8, 9, 0, time.UTC) + +// loadFixture reads testdata/.json into the Go model the way CPython +// reads it — which is what the generator does on the Python side +// (`SecurityAuditResult.model_validate(json.loads(payload))`). +// +// The int-vs-float distinction CPython's json.loads makes survives into +// `metadata` (`dict[str, object]`, which keeps whatever the decoder produced), +// so a metadata `2` must not become "2.0" on the way out. +// SecurityAuditResult.UnmarshalJSON decodes with UseNumber for that reason; +// nothing extra is needed here. +func loadFixture(t *testing.T, name string) schemas.SecurityAuditResult { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", name+".json")) + if err != nil { + t.Fatalf("read fixture %s: %v", name, err) + } + var result schemas.SecurityAuditResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("decode fixture %s: %v", name, err) + } + return result +} + +// readGolden reads testdata/golden/ verbatim. +func readGolden(t *testing.T, name string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", "golden", name)) + if err != nil { + t.Fatalf("read golden %s: %v", name, err) + } + return string(raw) +} + +// assertGolden diffs got against the golden and, on failure, prints the first +// differing line with its neighbours — a 7KB SARIF document is unreadable as a +// whole-string diff. +func assertGolden(t *testing.T, goldenName, got string) { + t.Helper() + want := readGolden(t, goldenName) + if got == want { + return + } + gotLines := strings.Split(got, "\n") + wantLines := strings.Split(want, "\n") + for i := 0; i < len(gotLines) || i < len(wantLines); i++ { + var gotLine, wantLine string + if i < len(gotLines) { + gotLine = gotLines[i] + } + if i < len(wantLines) { + wantLine = wantLines[i] + } + if gotLine == wantLine { + continue + } + t.Fatalf("%s: first difference at line %d\n go: %q\n python: %q\n(go has %d lines, python %d)", + goldenName, i+1, gotLine, wantLine, len(gotLines), len(wantLines)) + } + t.Fatalf("%s: documents differ only in trailing bytes (go %d bytes, python %d)", + goldenName, len(got), len(want)) +} + +// TestGoldenSarif diffs GenerateSarif against generate_sarif. +func TestGoldenSarif(t *testing.T) { + for _, name := range goldenFixtures { + t.Run(name, func(t *testing.T) { + result := loadFixture(t, name) + assertGolden(t, name+".sarif.json", GenerateSarif(result)) + // render_sarif is generate_sarif under another name. + assertGolden(t, name+".sarif.json", RenderSarif(result)) + }) + } +} + +// TestGoldenGenerateJSON diffs GenerateJSON in both modes against +// generate_json, which is where pydantic's model_dump_json() spelling (and its +// "...Z" datetime) has to be reproduced exactly. +func TestGoldenGenerateJSON(t *testing.T) { + for _, name := range goldenFixtures { + t.Run(name, func(t *testing.T) { + result := loadFixture(t, name) + assertGolden(t, name+".full.json", GenerateJSON(result, true)) + assertGolden(t, name+".full_compact.json", GenerateJSON(result, false)) + }) + } +} + +// TestGoldenSummaryJSON diffs GenerateSummaryJSON against +// generate_summary_json. +func TestGoldenSummaryJSON(t *testing.T) { + for _, name := range goldenFixtures { + t.Run(name, func(t *testing.T) { + assertGolden(t, name+".summary.json", GenerateSummaryJSON(loadFixture(t, name))) + }) + } +} + +// TestGoldenReport diffs GenerateReport against generate_report. +func TestGoldenReport(t *testing.T) { + for _, name := range goldenFixtures { + t.Run(name, func(t *testing.T) { + result := loadFixture(t, name) + assertGolden(t, name+".report.md", GenerateReport(result)) + assertGolden(t, name+".report.md", RenderReport(result)) + }) + } +} + +// TestGoldenComplianceReport diffs GenerateComplianceReportAt against +// generate_compliance_report rendered with the same frozen clock. +func TestGoldenComplianceReport(t *testing.T) { + for _, name := range goldenFixtures { + t.Run(name, func(t *testing.T) { + got := GenerateComplianceReportAt(loadFixture(t, name), goldenComplianceReportAt) + assertGolden(t, name+".compliance_report.md", got) + }) + } +} + +// TestFixtureRoundTrip proves the Go structs lose nothing the fixture carries: +// re-serialising a loaded fixture with pydantic's own spelling reproduces the +// golden full-JSON dump. If a schemas field were missing or mistyped, every +// other golden here would fail with a confusing diff — this one names it. +func TestFixtureRoundTrip(t *testing.T) { + for _, name := range goldenFixtures { + t.Run(name, func(t *testing.T) { + assertGolden(t, name+".full.json", GenerateJSON(loadFixture(t, name), true)) + }) + } +} diff --git a/go/internal/output/json_output.go b/go/internal/output/json_output.go new file mode 100644 index 0000000..cea450f --- /dev/null +++ b/go/internal/output/json_output.go @@ -0,0 +1,213 @@ +package output + +import ( + "encoding/json" + "fmt" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// This file ports src/sec_af/output/json_output.py. + +// GenerateJSON ports generate_json: +// +// full_json = result.model_dump_json() +// if not pretty: return full_json +// return json.dumps(json.loads(full_json), indent=2) +// +// so pretty=false yields pydantic's whitespace-free dump and pretty=true the +// two-space-indented re-serialisation of the same document. Both are produced +// here by walking the Go struct directly. +// +// THE TWO BRANCHES DO NOT SPELL FLOATS THE SAME WAY, and that is not a +// formatting detail — it is why each branch configures its own encoder. +// `model_dump_json()` is pydantic-core's Rust serializer; `json.dumps` is +// CPython's, which renders through repr(). They agree on ordinary magnitudes +// and disagree below 1e-4 (VERIFIED on the pinned interpreter): +// +// value pretty=False (pydantic) pretty=True (repr) +// 1e-7 1e-7 1e-07 +// 8e-05 0.00008 8e-05 +// 1.23e-05 0.0000123 1.23e-05 +// +// So Python's `json.loads` round trip is NOT value-preserving in its printed +// form, and a Go port that used one spelling for both branches is wrong for +// one of them. See the pydanticFloat flag in pyjson_local.go, the shared rule +// in pyfmt.PydanticFloat, and the audit_result_floats golden. +// +// See pyjson_local.go for the rest of why encoding/json is not used: the +// ensure_ascii escaping, key order, and the "...Z" datetime form that only +// `model_dump_json()` produces. +func GenerateJSON(result schemas.SecurityAuditResult, pretty bool) string { + if !pretty { + return dumpsPydantic(result) + } + e := &jsonEncoder{indent: 2, keySep: ": ", pydanticTime: true, ensureASCII: true} + e.encode(result, 0) + return e.buf.String() +} + +// RenderJSON ports render_json: the pretty document parsed back into a map. +// +// Python returns `json.loads(generate_json(result, pretty=True))`, a plain +// dict; the Go equivalent is map[string]any with the same JSON-decoded value +// kinds (every number becomes a float64, as it does in Go generally). An +// error is impossible for a document this package just generated, so it is +// reported as a nil map plus the error rather than being swallowed. +func RenderJSON(auditResult schemas.SecurityAuditResult) (map[string]any, error) { + var payload map[string]any + if err := json.Unmarshal([]byte(GenerateJSON(auditResult, true)), &payload); err != nil { + return nil, fmt.Errorf("output.RenderJSON: %w", err) + } + return payload, nil +} + +// buildSummaryStatistics ports _build_summary_statistics. +func buildSummaryStatistics(result schemas.SecurityAuditResult) obj { + return obj{ + {"total_findings", len(result.Findings)}, + {"confirmed", result.Confirmed}, + {"likely", result.Likely}, + {"inconclusive", result.Inconclusive}, + {"not_exploitable", result.NotExploitable}, + {"noise_reduction_pct", result.NoiseReductionPct}, + {"by_severity", result.BySeverity}, + } +} + +// buildSummaryFindings ports _build_summary_findings: the finding view that +// drops proof, tags, compliance and remediation — everything heavy. +func buildSummaryFindings(result schemas.SecurityAuditResult) []any { + out := make([]any, 0, len(result.Findings)) + for _, finding := range result.Findings { + out = append(out, obj{ + {"id", finding.ID}, + {"title", finding.Title}, + {"severity", string(finding.Severity)}, + {"verdict", string(finding.Verdict)}, + {"evidence_level", int(finding.EvidenceLevel)}, + {"exploitability_score", finding.ExploitabilityScore}, + {"cwe_id", finding.CweID}, + {"location", obj{ + {"file", finding.Location.FilePath}, + {"line", finding.Location.StartLine}, + }}, + {"chain_id", finding.ChainID}, + }) + } + return out +} + +// findingsByID ports _findings_by_id. +// +// Python parity: a later finding with a duplicate id overwrites an earlier one +// (dict comprehension semantics), which the map assignment reproduces. +func findingsByID(result schemas.SecurityAuditResult) map[string]schemas.VerifiedFinding { + out := make(map[string]schemas.VerifiedFinding, len(result.Findings)) + for _, finding := range result.Findings { + out[finding.ID] = finding + } + return out +} + +// buildChainSteps ports _build_chain_steps. +// +// Python parity, two quirks preserved: +// - `finding.chain_step or index` is Python truthiness, so a chain_step of 0 +// falls back to the 1-based enumeration index just like a null one does; +// - a chain naming a finding id the result does not contain still produces a +// step, with every finding-derived field null. +func buildChainSteps(chain schemas.AttackChain, findings map[string]schemas.VerifiedFinding) []any { + steps := make([]any, 0, len(chain.Findings)) + for i, findingID := range chain.Findings { + index := i + 1 + finding, ok := findings[findingID] + if !ok { + steps = append(steps, obj{ + {"step", index}, + {"finding_id", findingID}, + {"title", nil}, + {"verdict", nil}, + {"severity", nil}, + {"location", nil}, + }) + continue + } + step := index + if finding.ChainStep != nil && *finding.ChainStep != 0 { + step = *finding.ChainStep + } + steps = append(steps, obj{ + {"step", step}, + {"finding_id", findingID}, + {"title", finding.Title}, + {"verdict", string(finding.Verdict)}, + {"severity", string(finding.Severity)}, + {"location", obj{ + {"file", finding.Location.FilePath}, + {"line", finding.Location.StartLine}, + }}, + }) + } + return steps +} + +// buildAttackChains ports _build_attack_chains. +// +// Python parity: `chain.mitre_attack_mapping or []` turns a null mapping list +// into an empty JSON array rather than null. +func buildAttackChains(result schemas.SecurityAuditResult) []any { + findings := findingsByID(result) + out := make([]any, 0, len(result.AttackChains)) + for _, chain := range result.AttackChains { + mitre := make([]any, 0, len(chain.MitreAttackMapping)) + for _, mapping := range chain.MitreAttackMapping { + mitre = append(mitre, obj{ + {"tactic", mapping.Tactic}, + {"technique_id", mapping.TechniqueID}, + {"technique_name", mapping.TechniqueName}, + }) + } + out = append(out, obj{ + {"chain_id", chain.ChainID}, + {"title", chain.Title}, + {"description", chain.Description}, + {"combined_severity", string(chain.CombinedSeverity)}, + {"combined_impact", chain.CombinedImpact}, + {"findings", chain.Findings}, + {"steps", buildChainSteps(chain, findings)}, + {"mitre_attack_mapping", mitre}, + }) + } + return out +} + +// GenerateSummaryJSON ports generate_summary_json: the compact, dashboard-shaped +// view of a result, serialised with `json.dumps(summary, indent=2)`. +// +// The timestamp here is `result.timestamp.isoformat()` — the "+00:00" spelling, +// NOT the "...Z" one GenerateJSON produces. +func GenerateSummaryJSON(result schemas.SecurityAuditResult) string { + gaps := make([]any, 0, len(result.ComplianceGaps)) + for _, gap := range result.ComplianceGaps { + gaps = append(gaps, gap) + } + + summary := obj{ + {"repository", result.Repository}, + {"commit_sha", result.CommitSha}, + {"timestamp", result.Timestamp.String()}, + {"depth_profile", result.DepthProfile}, + {"summary", buildSummaryStatistics(result)}, + {"findings", buildSummaryFindings(result)}, + {"attack_chains", buildAttackChains(result)}, + {"compliance_gaps", gaps}, + {"performance", obj{ + {"duration_seconds", result.DurationSeconds}, + {"cost_usd", result.CostUsd}, + {"cost_breakdown", result.CostBreakdown}, + {"agent_invocations", result.AgentInvocations}, + }}, + } + return dumpsIndent(summary, 2) +} diff --git a/go/internal/output/json_output_test.go b/go/internal/output/json_output_test.go new file mode 100644 index 0000000..e2c611a --- /dev/null +++ b/go/internal/output/json_output_test.go @@ -0,0 +1,315 @@ +package output + +import ( + "encoding/json" + "math" + "strconv" + "strings" + "testing" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// This file ports tests/test_json_output.py — which also carries the one +// generate_report test the Python suite has. Extra report coverage lives in +// report_test.go. + +// jsonDoc parses a generated JSON document into an untyped tree. +func jsonDoc(t *testing.T, raw string) map[string]any { + t.Helper() + var payload map[string]any + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + t.Fatalf("generated JSON is invalid: %v", err) + } + return payload +} + +// TestGenerateJSONPrettyContainsFullFindingPayload ports +// test_generate_json_pretty_contains_full_finding_payload. +func TestGenerateJSONPrettyContainsFullFindingPayload(t *testing.T) { + result := loadFixture(t, "audit_result") + payload := jsonDoc(t, GenerateJSON(result, true)) + findings := sliceAt(t, payload["findings"], "findings") + chains := sliceAt(t, payload["attack_chains"], "attack_chains") + + if len(findings) != 3 { + t.Fatalf("want 3 findings, got %d", len(findings)) + } + if got := mapAt(t, findings[0], "findings[0]")["id"]; got != "finding-confirmed" { + t.Errorf("findings[0].id = %v", got) + } + if got := mapAt(t, findings[1], "findings[1]")["verdict"]; got != "likely" { + t.Errorf("findings[1].verdict = %v", got) + } + if got := mapAt(t, findings[2], "findings[2]")["verdict"]; got != "not_exploitable" { + t.Errorf("findings[2].verdict = %v", got) + } + if got := mapAt(t, chains[0], "attack_chains[0]")["chain_id"]; got != "chain-1" { + t.Errorf("attack_chains[0].chain_id = %v", got) + } + // The "full" payload keeps the heavy sub-objects the summary drops. + if _, ok := mapAt(t, findings[0], "findings[0]")["proof"].(map[string]any); !ok { + t.Error("findings[0].proof is missing from the full payload") + } +} + +// TestGenerateJSONCompactHasNoWhitespaceNewlines ports +// test_generate_json_compact_has_no_whitespace_newlines. +func TestGenerateJSONCompactHasNoWhitespaceNewlines(t *testing.T) { + output := GenerateJSON(loadFixture(t, "audit_result"), false) + + if strings.Contains(output, "\n") { + t.Error("compact output contains a newline") + } + if !strings.HasPrefix(output, "{") { + t.Error("compact output does not start with {") + } + if !strings.HasSuffix(output, "}") { + t.Error("compact output does not end with }") + } + // Python parity: pydantic's compact separators carry no space at all, + // unlike json.dumps' default ", " / ": ". + if strings.Contains(output, `", "`) || strings.Contains(output, `": "`) { + t.Error("compact output uses json.dumps separators, not pydantic's") + } +} + +// TestGenerateSummaryJSONOmitsProofAndContainsStatistics ports +// test_generate_summary_json_omits_proof_and_contains_statistics. +func TestGenerateSummaryJSONOmitsProofAndContainsStatistics(t *testing.T) { + result := loadFixture(t, "audit_result") + payload := jsonDoc(t, GenerateSummaryJSON(result)) + summary := mapAt(t, payload["summary"], "summary") + findings := sliceAt(t, payload["findings"], "findings") + chains := sliceAt(t, payload["attack_chains"], "attack_chains") + performance := mapAt(t, payload["performance"], "performance") + + if summary["total_findings"] != float64(3) { + t.Errorf("summary.total_findings = %v", summary["total_findings"]) + } + if summary["confirmed"] != float64(1) { + t.Errorf("summary.confirmed = %v", summary["confirmed"]) + } + if summary["likely"] != float64(1) { + t.Errorf("summary.likely = %v", summary["likely"]) + } + if summary["not_exploitable"] != float64(1) { + t.Errorf("summary.not_exploitable = %v", summary["not_exploitable"]) + } + if _, present := mapAt(t, findings[0], "findings[0]")["proof"]; present { + t.Error("the summary view leaked the proof object") + } + firstSteps := sliceAt(t, mapAt(t, chains[0], "attack_chains[0]")["steps"], "steps") + if got := mapAt(t, firstSteps[0], "steps[0]")["step"]; got != float64(1) { + t.Errorf("steps[0].step = %v, want 1", got) + } + if performance["cost_usd"] != 3.21 { + t.Errorf("performance.cost_usd = %v, want 3.21", performance["cost_usd"]) + } +} + +// TestRenderJSONReturnsDecodedDictionary ports +// test_render_json_returns_decoded_dictionary. +func TestRenderJSONReturnsDecodedDictionary(t *testing.T) { + payload, err := RenderJSON(loadFixture(t, "audit_result")) + if err != nil { + t.Fatalf("RenderJSON: %v", err) + } + if payload["repository"] != "Agent-Field/sec-af" { + t.Errorf("repository = %v", payload["repository"]) + } + if got := sliceAt(t, payload["findings"], "findings"); len(got) != 3 { + t.Errorf("want 3 findings, got %d", len(got)) + } +} + +// TestGenerateReportIncludesFindingsChainsComplianceAndCost ports +// test_generate_report_includes_findings_chains_compliance_and_cost (which +// lives in tests/test_json_output.py). +func TestGenerateReportIncludesFindingsChainsComplianceAndCost(t *testing.T) { + report := GenerateReport(loadFixture(t, "audit_result")) + + for _, want := range []string{ + "# SEC-AF Security Audit Report", + "## Summary", + "## Findings", + "SQL Injection", + "Missing Authentication", + "## Attack Chains", + "Input to DB read", + "## Compliance Gaps", + "PCI-DSS Req 6.2.4", + "## Performance & Cost", + } { + if !strings.Contains(report, want) { + t.Errorf("report is missing %q", want) + } + } +} + +// --------------------------------------------------------------------------- +// behaviours the Python suite does not cover but the port must not drift on +// --------------------------------------------------------------------------- + +// TestBuildChainStepsFallbacks pins _build_chain_steps' two quirks: a chain +// step of 0 is falsy and falls back to the enumeration index, and a chain +// naming an unknown finding id still yields a step with null detail fields. +func TestBuildChainStepsFallbacks(t *testing.T) { + payload := jsonDoc(t, GenerateSummaryJSON(loadFixture(t, "audit_result_edge"))) + chains := sliceAt(t, payload["attack_chains"], "attack_chains") + steps := sliceAt(t, mapAt(t, chains[0], "attack_chains[0]")["steps"], "steps") + + if len(steps) != 2 { + t.Fatalf("want 2 steps, got %d", len(steps)) + } + // dup-a carries chain_step 0 -> falls back to index 1. + first := mapAt(t, steps[0], "steps[0]") + if first["step"] != float64(1) { + t.Errorf("steps[0].step = %v, want the 1-based index", first["step"]) + } + if first["finding_id"] != "dup-a" { + t.Errorf("steps[0].finding_id = %v", first["finding_id"]) + } + // "not-in-result" is not among the findings -> index and null details. + second := mapAt(t, steps[1], "steps[1]") + if second["step"] != float64(2) { + t.Errorf("steps[1].step = %v, want 2", second["step"]) + } + for _, key := range []string{"title", "verdict", "severity", "location"} { + value, present := second[key] + if !present { + t.Errorf("steps[1].%s is absent; Python emits it as null", key) + } + if value != nil { + t.Errorf("steps[1].%s = %v, want null", key, value) + } + } +} + +// TestBuildAttackChainsNullMitreBecomesEmptyList pins +// `chain.mitre_attack_mapping or []`. +func TestBuildAttackChainsNullMitreBecomesEmptyList(t *testing.T) { + payload := jsonDoc(t, GenerateSummaryJSON(loadFixture(t, "audit_result_edge"))) + chains := sliceAt(t, payload["attack_chains"], "attack_chains") + mitre, present := mapAt(t, chains[0], "attack_chains[0]")["mitre_attack_mapping"] + if !present { + t.Fatal("mitre_attack_mapping is absent") + } + list, ok := mitre.([]any) + if !ok { + t.Fatalf("mitre_attack_mapping = %v (%T), want an empty array", mitre, mitre) + } + if len(list) != 0 { + t.Errorf("mitre_attack_mapping = %v, want []", list) + } +} + +// TestGenerateJSONTimestampSpellings pins the one place the two datetime +// spellings meet: the pydantic dump says "Z", the summary says "+00:00". +func TestGenerateJSONTimestampSpellings(t *testing.T) { + result := loadFixture(t, "audit_result") + + full := jsonDoc(t, GenerateJSON(result, true)) + if full["timestamp"] != "2026-03-04T10:30:00Z" { + t.Errorf("generate_json timestamp = %v, want the pydantic \"Z\" form", full["timestamp"]) + } + summary := jsonDoc(t, GenerateSummaryJSON(result)) + if summary["timestamp"] != "2026-03-04T10:30:00+00:00" { + t.Errorf("generate_summary_json timestamp = %v, want the isoformat form", summary["timestamp"]) + } + + edge := jsonDoc(t, GenerateJSON(loadFixture(t, "audit_result_edge"), true)) + if edge["timestamp"] != "2026-03-04T10:30:00.123456Z" { + t.Errorf("microsecond timestamp = %v", edge["timestamp"]) + } +} + +// TestGenerateJSONFloatSpelling pins the rule the two branches of generate_json +// obey, which is NOT the same rule. +// +// Validation contract (behaviour, measured on the pinned interpreter by running +// the real `generate_json` over a real SecurityAuditResult, one value at a +// time): +// +// - pretty=false is `result.model_dump_json()` VERBATIM — pydantic-core's +// Rust serializer. It writes a DECIMAL form for every magnitude in +// [1e-5, 1e16) (so 8e-05 is "0.00008" and 1e-5 is "0.00001") and an +// exponent form otherwise with UNPADDED exponent digits ("1e-7", not +// "1e-07"); +// - pretty=true is `json.dumps(json.loads(...))`, i.e. CPython repr(), which +// writes "8e-05" and "1e-07" for the same values; +// - the two agree everywhere else, including the 0.30000000000000004 repr +// tie, -0.0, 1e15 (decimal on both), 1e+16, 1e+21 and the 5e-324 denormal. +// +// Before this, both branches used repr() and only the pretty one was right — +// undetected because no committed golden carried a float below 1e-4. +func TestGenerateJSONFloatSpelling(t *testing.T) { + for _, tc := range []struct { + value float64 + compact, pretty string + }{ + {1e-07, "1e-7", "1e-07"}, + {1e-08, "1e-8", "1e-08"}, + {1.5e-07, "1.5e-7", "1.5e-07"}, + {-1e-07, "-1e-7", "-1e-07"}, + {1e-06, "1e-6", "1e-06"}, + {1e-05, "0.00001", "1e-05"}, + {8e-05, "0.00008", "8e-05"}, + {1.23e-05, "0.0000123", "1.23e-05"}, + {9.99e-05, "0.0000999", "9.99e-05"}, + {0.0001, "0.0001", "0.0001"}, + {0.30000000000000004, "0.30000000000000004", "0.30000000000000004"}, + {0.0, "0.0", "0.0"}, + {math.Copysign(0, -1), "-0.0", "-0.0"}, + {10.0, "10.0", "10.0"}, + {1e15, "1000000000000000.0", "1000000000000000.0"}, + {1e16, "1e+16", "1e+16"}, + {1e21, "1e+21", "1e+21"}, + {5e-324, "5e-324", "5e-324"}, + {1234.5, "1234.5", "1234.5"}, + } { + t.Run(strconv.FormatFloat(tc.value, 'g', -1, 64), func(t *testing.T) { + result := schemas.NewSecurityAuditResult() + result.DurationSeconds = tc.value + + if want := `"duration_seconds":` + tc.compact; !strings.Contains(GenerateJSON(result, false), want) { + t.Errorf("compact form does not contain %s\n%s", want, GenerateJSON(result, false)) + } + if want := `"duration_seconds": ` + tc.pretty; !strings.Contains(GenerateJSON(result, true), want) { + t.Errorf("pretty form does not contain %s", want) + } + }) + } +} + +// TestGenerateJSONMetadataKeepsWireIntegers pins the OTHER number rule: the +// untyped `metadata` map (pydantic `dict[str, object]`) keeps whatever the JSON +// decoder produced, and CPython's json.loads produces an `int` for an integer +// literal. Both branches of generate_json therefore print "2", not "2.0". +// +// The live path that puts a value there is app.py:205-208, which copies the +// prove_phase `drop_summary` payload verbatim; on the Go side that payload +// arrives from the SDK's own decoder, where every number is a float64, so the +// int-ness is restored by afx.WireNumbers at the boundary and by +// SecurityAuditResult.UnmarshalJSON's UseNumber when a document is read back. +func TestGenerateJSONMetadataKeepsWireIntegers(t *testing.T) { + var result schemas.SecurityAuditResult + document := `{"metadata":{"findings_not_verified":3,` + + `"prove_drop_summary":{"by_reason":{"verifier_error":2},"demoted_total":2,"findings":[]},` + + `"a_real_float":2.5}}` + if err := json.Unmarshal([]byte(document), &result); err != nil { + t.Fatalf("decode: %v", err) + } + + wantCompact := `"metadata":{"a_real_float":2.5,"findings_not_verified":3,` + + `"prove_drop_summary":{"by_reason":{"verifier_error":2},"demoted_total":2,"findings":[]}}` + if got := GenerateJSON(result, false); !strings.Contains(got, wantCompact) { + t.Errorf("compact metadata\n got: %s\nwant it to contain: %s", got, wantCompact) + } + if got := GenerateJSON(result, true); !strings.Contains(got, `"findings_not_verified": 3,`) { + t.Errorf("pretty metadata spells the wire integer as a float:\n%s", got) + } + if got := GenerateJSON(result, true); !strings.Contains(got, `"a_real_float": 2.5`) { + t.Errorf("pretty metadata lost a genuine float:\n%s", got) + } +} diff --git a/go/internal/output/pyjson_equivalence_test.go b/go/internal/output/pyjson_equivalence_test.go new file mode 100644 index 0000000..1f30aaa --- /dev/null +++ b/go/internal/output/pyjson_equivalence_test.go @@ -0,0 +1,100 @@ +package output + +import ( + "testing" + "time" + + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// --------------------------------------------------------------------------- +// dumpsIndent == pyfmt.Dumps (anti-drift gate) +// +// This package keeps its own JSON encoder (pyjson_local.go) because it needs +// the PYDANTIC mode — `model_dump_json()`: no whitespace, raw non-ASCII, a +// "...Z" datetime — which pyfmt.Dumps deliberately does not implement, and +// because the artifact builders spell their documents as unkeyed `obj{{...}}` +// literals that go vet's composites check forbids for pyfmt's KV. +// +// Its OTHER mode, dumpsIndent, is the same function pyfmt.Dumps is: +// `json.dumps(x, indent=n)`. Nothing forces the two to stay equal, so this test +// does — over every value shape the SARIF and summary-JSON documents contain. +// If one encoder is fixed and the other is not, this fails instead of a +// committed artifact silently changing shape. +// --------------------------------------------------------------------------- + +func TestDumpsIndentAgreesWithPyfmtDumps(t *testing.T) { + ts := schemas.Timestamp{Time: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC)} + nilStrings := []string(nil) + score := 9.25 + name := "run" + + // Every branch of jsonEncoder.encode/encodeValue that an artifact document + // can reach, in one document. + local := obj{ + {"string", "plain"}, + {"quotes_and_backslash", `say "hi" \ bye`}, + {"html_chars", " & "}, // json.dumps leaves these alone + {"non_ascii", "café — HIPAA §164"}, // ensure_ascii=True escapes these + {"astral", "😀"}, // surrogate pair + {"controls", "\x00\x01\x1f\x7f\n\t"}, // \uXXXX / short escapes + {"empty_string", ""}, + {"true", true}, + {"false", false}, + {"int", 42}, + {"negative_int", -7}, + {"int64", int64(1 << 40)}, + {"float_integral", 10.0}, // "10.0", not "10" + {"float_fraction", 9.25}, + {"float_ptr", &score}, + {"string_ptr", &name}, + {"nil_ptr", (*string)(nil)}, + {"nil_any", nil}, + {"timestamp", ts}, // isoformat "+00:00" spelling in json.dumps mode + {"empty_obj", obj{}}, + {"empty_list", []any{}}, + {"nil_slice", nilStrings}, // null on both sides + {"string_slice", []string{"a", "b"}}, + {"list_of_obj", []any{ + obj{{"i", 1}, {"j", 2}}, + "scalar", + []any{obj{{"deep", true}}}, + }}, + {"nested", obj{ + {"level2", obj{ + {"level3", obj{{"leaf", "x"}}}, + }}, + }}, + } + + for _, indent := range []int{2, 4} { + got := dumpsIndent(local, indent) + want := pyfmt.Dumps(toPyfmtValue(local), indent) + if got != want { + t.Errorf("indent=%d: dumpsIndent and pyfmt.Dumps disagree\n--- dumpsIndent ---\n%s\n--- pyfmt.Dumps ---\n%s", + indent, got, want) + } + } +} + +// toPyfmtValue re-types this package's ordered object as pyfmt's. The two carry +// identical information; only the Go type differs. +func toPyfmtValue(v any) any { + switch x := v.(type) { + case obj: + out := make(pyfmt.Ordered, 0, len(x)) + for _, e := range x { + out = append(out, pyfmt.KV{Key: e.Key, Value: toPyfmtValue(e.Value)}) + } + return out + case []any: + out := make([]any, 0, len(x)) + for _, e := range x { + out = append(out, toPyfmtValue(e)) + } + return out + default: + return v + } +} diff --git a/go/internal/output/pyjson_local.go b/go/internal/output/pyjson_local.go new file mode 100644 index 0000000..99bae3c --- /dev/null +++ b/go/internal/output/pyjson_local.go @@ -0,0 +1,481 @@ +package output + +import ( + "encoding/json" + "reflect" + "sort" + "strconv" + "strings" + "time" + "unicode/utf16" + "unicode/utf8" + + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// This file is the package-local JSON writer the four output generators share. +// It exists because Go's encoding/json and CPython's json module disagree in +// four ways that are all observable in SEC-AF's committed output: +// +// 1. FLOAT SPELLING. Python renders a float with repr(): 10.0 stays "10.0", +// where json.Marshal emits "10". Every exploitability_score, cost and +// duration in the SARIF/JSON artifacts is a float. +// 2. STRING ESCAPING. json.dumps defaults to ensure_ascii=True, escaping every +// non-ASCII rune as \uXXXX (so the HIPAA "§" control ids become §); +// encoding/json emits them raw and instead escapes <, > and & — which +// Python does not touch. +// 3. KEY ORDER. json.dumps walks a dict in insertion order. encoding/json +// sorts map keys, and for a struct follows field order. The SARIF document +// is a hand-built dict literal whose order is part of the artifact. +// 4. DATETIME. `model_dump_json()` renders a UTC datetime as "...Z", while +// `datetime.isoformat()` (used everywhere else in this package) renders +// "...+00:00". schemas.Timestamp's MarshalJSON implements the isoformat +// spelling, so generate_json needs the other one - see pydanticISO. +// 5. PYDANTIC'S OWN FLOAT SPELLING. `model_dump_json()` does not go through +// CPython's repr() at all — it serializes in Rust, which spells 1e-7 as +// "1e-7" (no zero-padded exponent) and 8e-05 as "0.00008". That is a +// DIFFERENT rule from item 1, and it applies only to the compact/pydantic +// branch: `generate_json(result, pretty=True)` re-serialises through +// `json.dumps(json.loads(...))` (json_output.py:12-16) and therefore does +// want repr()'s "1e-07". See pyfmt.PydanticFloat and the pydanticFloat flag. +// 6. WIRE INTEGERS. CPython's json.loads turns an integer literal into an +// `int`, so `str`/`json.dumps` of a decoded payload spells it "2". +// encoding/json decodes EVERY number to float64, which this writer would +// spell "2.0". The compensation is afx.WireNumbers at the boundary where +// such a payload is stored untyped (see internal/node/audit.go), which +// leaves json.Number values behind; this writer emits their literal +// spelling verbatim. +// +// The json.dumps half of that list is also pyfmt.Dumps' subject (DESIGN.md +// §2b) and the two agree byte for byte — see dumpsIndent. The pydantic half is +// only here: `model_dump_json()` uses different separators, does NOT escape +// non-ASCII, and spells a UTC datetime "...Z" rather than the "+00:00" +// schemas.Timestamp's MarshalJSON (which pyfmt.Dumps honours) produces. + +// --------------------------------------------------------------------------- +// ordered objects +// --------------------------------------------------------------------------- + +// kv is one entry of an ordered JSON object. +type kv struct { + Key string + Value any +} + +// obj is an insertion-ordered JSON object: the Go stand-in for the Python dict +// literals sarif.py and json_output.py build, whose key order is part of the +// artifact (a map[string]any carries none). +// +// It is a package-local type rather than an alias for pyfmt.Ordered so the +// artifact builders can use unkeyed literals — `obj{{"id", x}, {"level", y}}` +// reads like the Python dict it ports, and go vet's composites check forbids +// unkeyed literals of another package's struct. +type obj []kv + +// --------------------------------------------------------------------------- +// entry points +// --------------------------------------------------------------------------- + +// dumpsIndent reproduces `json.dumps(value, indent=n)`: two-space-per-level +// (or n-space) pretty printing, ", " between object keys and their values, no +// trailing newline, and empty containers collapsed to "[]" / "{}". +// +// It shares its semantics with pyfmt.Dumps, the port's canonical json.dumps +// (DESIGN.md §2b), and TestDumpsIndentAgreesWithPyfmtDumps holds the two to +// byte equality over every value shape an artifact document contains — so the +// duplication cannot become a divergence. +// +// The encoder still lives here, rather than delegating, for two reasons that do +// not go away: this package needs the pydantic variant below regardless (there +// is no pyfmt equivalent for model_dump_json), and pyfmt.Dumps takes a +// pyfmt.Ordered whose entries go vet will not let another package build with +// unkeyed literals — which is what makes the artifact builders readable next to +// the Python dicts they port. +func dumpsIndent(value any, indent int) string { + e := &jsonEncoder{indent: indent, keySep: ": ", ensureASCII: true} + e.encode(value, 0) + return e.buf.String() +} + +// dumpsPydantic reproduces pydantic's `BaseModel.model_dump_json()`, which is +// NOT json.dumps and so cannot go through pyfmt.Dumps: +// +// - no whitespace at all, neither after ":" nor after "," (json.dumps' own +// compact form uses ", " and ": "); +// - non-ASCII and DEL emitted raw, where json.dumps escapes them; +// - a UTC datetime spelled "...Z", where schemas.Timestamp's MarshalJSON — +// which pyfmt.Dumps would call — spells it "...+00:00"; +// - floats spelled by pydantic-core's Rust serializer rather than by +// CPython's repr(): 1e-7 is "1e-7" (not "1e-07") and 8e-05 is "0.00008" +// (not "8e-05"). VERIFIED on the pinned interpreter against the real +// SecurityAuditResult. The rule lives in pyfmt.PydanticFloat, which +// pyfmt.DumpsModelJSON already uses, so the port's two model_dump_json +// writers cannot disagree. +func dumpsPydantic(value any) string { + e := &jsonEncoder{indent: 0, keySep: ":", pydanticTime: true, pydanticFloat: true} + e.encode(value, 0) + return e.buf.String() +} + +// jsonEncoder holds one encoding run. +type jsonEncoder struct { + buf strings.Builder + indent int + keySep string + // pydanticTime selects the "...Z" datetime spelling `model_dump_json()` + // produces instead of `datetime.isoformat()`'s "...+00:00". + pydanticTime bool + // pydanticFloat selects pydantic-core's float spelling over CPython's + // repr(). Both branches are reachable and both are correct for their + // caller: `generate_json(result, pretty=False)` returns + // `model_dump_json()` verbatim (pydantic spelling), while + // `generate_json(result, pretty=True)` and every other generator in this + // package round-trip through `json.dumps` (repr spelling). + pydanticFloat bool + // ensureASCII selects json.dumps' default escaping (every rune outside + // printable ASCII becomes \uXXXX). pydantic's serializer does NOT do this: + // it emits non-ASCII and DEL raw, and escapes only C0 control characters. + // VERIFIED against pydantic 2.x + CPython 3.11 in the sec-af venv. + ensureASCII bool +} + +// --------------------------------------------------------------------------- +// encoding +// --------------------------------------------------------------------------- + +func (e *jsonEncoder) encode(value any, depth int) { + switch v := value.(type) { + case nil: + e.buf.WriteString("null") + return + case obj: + e.encodeObject(v, depth) + return + case schemas.Timestamp: + // Handled before the generic struct walk: a Timestamp is a struct, but + // its JSON form is a string. + if e.pydanticTime { + e.writeString(pydanticISO(v)) + } else { + e.writeString(v.String()) + } + return + case string: + e.writeString(v) + return + case bool: + e.writeBool(v) + return + case int: + e.buf.WriteString(strconv.Itoa(v)) + return + case float64: + e.writeFloat(v) + return + case json.Number: + // A number recovered from the wire with its literal spelling intact + // (afx.WireNumbers). It is already valid JSON — and already carries the + // int-vs-float distinction CPython's json.loads makes and Go's + // float64-everything decode destroys — so it is emitted verbatim. + e.buf.WriteString(string(v)) + return + } + + rv := reflect.ValueOf(value) + e.encodeValue(rv, depth) +} + +func (e *jsonEncoder) encodeValue(rv reflect.Value, depth int) { + if !rv.IsValid() { + e.buf.WriteString("null") + return + } + switch rv.Kind() { + case reflect.Pointer, reflect.Interface: + if rv.IsNil() { + e.buf.WriteString("null") + return + } + e.encode(rv.Elem().Interface(), depth) + case reflect.Bool: + e.writeBool(rv.Bool()) + case reflect.String: + e.writeString(rv.String()) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + e.buf.WriteString(strconv.FormatInt(rv.Int(), 10)) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + e.buf.WriteString(strconv.FormatUint(rv.Uint(), 10)) + case reflect.Float32, reflect.Float64: + e.writeFloat(rv.Float()) + case reflect.Slice: + if rv.IsNil() { + // Python parity: a pydantic `list | None = None` field dumps as + // null, and that is the only way a nil slice reaches here — the + // schemas package seeds every default_factory list to []. + e.buf.WriteString("null") + return + } + e.encodeArray(rv, depth) + case reflect.Array: + e.encodeArray(rv, depth) + case reflect.Map: + if rv.IsNil() { + e.buf.WriteString("null") + return + } + e.encodeMap(rv, depth) + case reflect.Struct: + e.encodeStruct(rv, depth) + default: + e.buf.WriteString("null") + } +} + +// writeFloat renders a float with the spelling this encoding run's caller +// needs: pydantic-core's for `model_dump_json()`, CPython's repr() for +// `json.dumps`. See the pydanticFloat field. +func (e *jsonEncoder) writeFloat(f float64) { + if e.pydanticFloat { + e.buf.WriteString(pyfmt.PydanticFloat(f)) + return + } + e.buf.WriteString(pyfmt.FormatFloat(f)) +} + +func (e *jsonEncoder) encodeObject(entries obj, depth int) { + if len(entries) == 0 { + e.buf.WriteString("{}") + return + } + e.buf.WriteByte('{') + for i, entry := range entries { + if i > 0 { + e.buf.WriteByte(',') + } + e.newlineIndent(depth + 1) + e.writeString(entry.Key) + e.buf.WriteString(e.keySep) + e.encode(entry.Value, depth+1) + } + e.newlineIndent(depth) + e.buf.WriteByte('}') +} + +func (e *jsonEncoder) encodeArray(rv reflect.Value, depth int) { + n := rv.Len() + if n == 0 { + e.buf.WriteString("[]") + return + } + e.buf.WriteByte('[') + for i := 0; i < n; i++ { + if i > 0 { + e.buf.WriteByte(',') + } + e.newlineIndent(depth + 1) + e.encode(rv.Index(i).Interface(), depth+1) + } + e.newlineIndent(depth) + e.buf.WriteByte(']') +} + +// encodeMap writes a Go map with its keys SORTED. +// +// Python parity divergence (documented in DESIGN.md §2b): CPython walks a dict +// in insertion order, which a Go map does not carry. Sorting is the +// deterministic alternative. It is only observable for the three +// `dict[...]`-typed fields of SecurityAuditResult (by_severity, cost_breakdown, +// metadata); the golden fixtures list those keys in sorted order so the two +// implementations agree byte-for-byte. +func (e *jsonEncoder) encodeMap(rv reflect.Value, depth int) { + if rv.Len() == 0 { + e.buf.WriteString("{}") + return + } + keys := make([]string, 0, rv.Len()) + byKey := make(map[string]reflect.Value, rv.Len()) + for _, key := range rv.MapKeys() { + name := key.String() + keys = append(keys, name) + byKey[name] = rv.MapIndex(key) + } + sort.Strings(keys) + + e.buf.WriteByte('{') + for i, key := range keys { + if i > 0 { + e.buf.WriteByte(',') + } + e.newlineIndent(depth + 1) + e.writeString(key) + e.buf.WriteString(e.keySep) + e.encode(byKey[key].Interface(), depth+1) + } + e.newlineIndent(depth) + e.buf.WriteByte('}') +} + +// encodeStruct writes a struct's exported fields in DECLARATION order, keyed by +// their json tag — which is exactly how pydantic dumps a model, because the Go +// structs in internal/schemas are declared in pydantic field order and tagged +// with the pydantic field names. `json:"-"` fields are skipped; `omitempty` is +// ignored (the schemas package uses none, by design: model_dump emits every +// field). Anonymous exported struct fields without a tag are flattened the way +// encoding/json flattens them. +func (e *jsonEncoder) encodeStruct(rv reflect.Value, depth int) { + entries := make(obj, 0, rv.NumField()) + collectStructFields(&entries, rv) + e.encodeObject(entries, depth) +} + +func collectStructFields(entries *obj, rv reflect.Value) { + rt := rv.Type() + for i := 0; i < rt.NumField(); i++ { + field := rt.Field(i) + if !field.IsExported() { + continue + } + name, _, _ := strings.Cut(field.Tag.Get("json"), ",") + if name == "-" { + continue + } + if name == "" { + if field.Anonymous { + fv := rv.Field(i) + for fv.Kind() == reflect.Pointer && !fv.IsNil() { + fv = fv.Elem() + } + if fv.Kind() == reflect.Struct { + collectStructFields(entries, fv) + continue + } + } + name = field.Name + } + *entries = append(*entries, kv{Key: name, Value: rv.Field(i).Interface()}) + } +} + +// newlineIndent writes the newline + leading spaces json.dumps emits before +// each element when indent is set. With indent == 0 it writes nothing, which +// gives the compact form. +func (e *jsonEncoder) newlineIndent(depth int) { + if e.indent <= 0 { + return + } + e.buf.WriteByte('\n') + e.buf.WriteString(strings.Repeat(" ", e.indent*depth)) +} + +func (e *jsonEncoder) writeBool(b bool) { + if b { + e.buf.WriteString("true") + return + } + e.buf.WriteString("false") +} + +// writeString escapes a JSON string the way whichever Python serializer is +// being reproduced does. +// +// With ensureASCII (json.dumps' default, so every indented artifact) it ports +// CPython's py_encode_basestring_ascii (json/encoder.py): +// +// - `\` and `"` get their short escapes; +// - \b \f \n \r \t get theirs; +// - every other rune outside the printable ASCII range 0x20..0x7E — so +// including DEL — becomes \uXXXX, with a surrogate PAIR above the BMP. +// +// Without it (pydantic's `model_dump_json()`, i.e. GenerateJSON's compact form) +// only `\`, `"` and the C0 control characters are escaped; DEL and every +// non-ASCII rune are emitted raw as UTF-8. VERIFIED: pydantic renders +// "a\x00b\x7fdée😀" as `a\u0000bdée😀`. +// +// Deliberately absent from BOTH modes: Go's default escaping of <, > and & — +// neither Python serializer touches them. +func (e *jsonEncoder) writeString(s string) { + e.buf.WriteByte('"') + for _, r := range s { + switch r { + case '\\': + e.buf.WriteString(`\\`) + case '"': + e.buf.WriteString(`\"`) + case '\b': + e.buf.WriteString(`\b`) + case '\f': + e.buf.WriteString(`\f`) + case '\n': + e.buf.WriteString(`\n`) + case '\r': + e.buf.WriteString(`\r`) + case '\t': + e.buf.WriteString(`\t`) + default: + switch { + case r < 0x20: + e.writeUnicodeEscape(r) + case !e.ensureASCII: + if r == utf8.RuneError { + // An invalid UTF-8 byte; neither Python serializer can + // produce one. Emit the replacement character so the + // document stays valid UTF-8 JSON. + e.buf.WriteRune(utf8.RuneError) + continue + } + e.buf.WriteRune(r) + case r <= 0x7E: + e.buf.WriteByte(byte(r)) + case r == utf8.RuneError: + e.writeUnicodeEscape(0xFFFD) + case r > 0xFFFF: + high, low := utf16.EncodeRune(r) + e.writeUnicodeEscape(high) + e.writeUnicodeEscape(low) + default: + e.writeUnicodeEscape(r) + } + } + } + e.buf.WriteByte('"') +} + +func (e *jsonEncoder) writeUnicodeEscape(r rune) { + const hexDigits = "0123456789abcdef" + e.buf.WriteString(`\u`) + e.buf.WriteByte(hexDigits[(r>>12)&0xF]) + e.buf.WriteByte(hexDigits[(r>>8)&0xF]) + e.buf.WriteByte(hexDigits[(r>>4)&0xF]) + e.buf.WriteByte(hexDigits[r&0xF]) +} + +// pydanticISO renders a datetime the way pydantic-core's JSON serializer does, +// which is NOT `datetime.isoformat()`: +// +// UTC 2026-03-04T10:30:00Z (isoformat: ...+00:00) +// other offset 2026-03-04T10:30:00+05:30 (same as isoformat) +// microseconds 2026-03-04T10:30:00.123456Z (always six digits) +// +// VERIFIED against pydantic 2.x under ~/.agentfield/packages/sec-af/venv. +// Only generate_json needs this spelling, because it is the one function that +// serialises the model with `model_dump_json()` rather than reaching for +// `result.timestamp.isoformat()` itself. +// +// Two Go-specific notes. A Python datetime carries microseconds, so the +// fraction is decided by the microsecond value (a stray sub-microsecond +// nanosecond is truncated away and produces no fraction, as it would in +// Python). And Go has no naive datetime: a zero UTC offset always renders "Z", +// where pydantic would emit no suffix at all for a tz-less datetime — SEC-AF +// only ever builds `datetime.now(UTC)` values, so that case cannot arise. +func pydanticISO(t schemas.Timestamp) string { + micros := t.Nanosecond() / 1000 + rendered := t.Format("2006-01-02T15:04:05") + if micros != 0 { + rendered = t.Truncate(time.Microsecond).Format("2006-01-02T15:04:05.000000") + } + if _, offset := t.Zone(); offset == 0 { + return rendered + "Z" + } + return rendered + t.Format("-07:00") +} diff --git a/go/internal/output/pyjson_local_test.go b/go/internal/output/pyjson_local_test.go new file mode 100644 index 0000000..723c3a7 --- /dev/null +++ b/go/internal/output/pyjson_local_test.go @@ -0,0 +1,233 @@ +package output + +import ( + "testing" + "time" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// This file pins the two JSON writers this package uses against CPython and +// pydantic directly, so a divergence points at the writer rather than surfacing +// as a mystery diff in a 7KB golden. Every `want` below was produced by running +// the quoted expression under ~/.agentfield/packages/sec-af/venv/bin/python. +// +// dumpsIndent shares its contract with pyfmt.Dumps (DESIGN.md §2b); see that +// function's comment for why this package keeps its own encoder. + +// TestDumpsIndentMatchesJSONDumps covers json.dumps(x, indent=2). +func TestDumpsIndentMatchesJSONDumps(t *testing.T) { + cases := []struct { + name string + in any + want string + }{ + { + // json.dumps({"a": 1, "b": [], "c": {}}, indent=2) + name: "empty containers stay on one line", + in: obj{{"a", 1}, {"b", []any{}}, {"c", obj{}}}, + want: "{\n \"a\": 1,\n \"b\": [],\n \"c\": {}\n}", + }, + { + // json.dumps({"f": 1.0, "g": 0.5, "h": -0.0, "i": 1e22, + // "j": 1234567890123456789}, indent=2) + name: "floats use Python repr, ints stay ints", + in: obj{ + {"f", 1.0}, {"g", 0.5}, {"h", negZero()}, {"i", 1e22}, + {"j", int64(1234567890123456789)}, + }, + want: "{\n \"f\": 1.0,\n \"g\": 0.5,\n \"h\": -0.0,\n \"i\": 1e+22,\n \"j\": 1234567890123456789\n}", + }, + { + // json.dumps({"s": 'sec§ naïve — 😀 \x7f \x01 "q" \\ &'}, indent=2) + name: "ensure_ascii escaping, and no escaping of < > &", + in: obj{{"s", "sec§ naïve — 😀 \x7f \x01 \"q\" \\ &"}}, + want: "{\n \"s\": \"sec\\u00a7 na\\u00efve \\u2014 \\ud83d\\ude00 \\u007f \\u0001 \\\"q\\\" \\\\ &\"\n}", + }, + { + // json.dumps([1, [2, [3]]], indent=2) + name: "nested arrays indent per level", + in: []any{1, []any{2, []any{3}}}, + want: "[\n 1,\n [\n 2,\n [\n 3\n ]\n ]\n]", + }, + { + // json.dumps({"n": None, "t": True, "f": False}, indent=2) + name: "null and booleans", + in: obj{{"n", nil}, {"t", true}, {"f", false}}, + want: "{\n \"n\": null,\n \"t\": true,\n \"f\": false\n}", + }, + { + // json.dumps({"x": [{"y": 1}]}, indent=2) + name: "object inside array", + in: obj{{"x", []any{obj{{"y", 1}}}}}, + want: "{\n \"x\": [\n {\n \"y\": 1\n }\n ]\n}", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := dumpsIndent(tc.in, 2); got != tc.want { + t.Errorf("dumpsIndent =\n%q\nwant\n%q", got, tc.want) + } + }) + } +} + +// TestDumpsIndentSortsMapKeys documents the one deliberate deviation from +// CPython: a Go map carries no insertion order, so its keys are emitted sorted. +func TestDumpsIndentSortsMapKeys(t *testing.T) { + got := dumpsIndent(map[string]int{"zeta": 1, "alpha": 2, "mid": 3}, 2) + want := "{\n \"alpha\": 2,\n \"mid\": 3,\n \"zeta\": 1\n}" + if got != want { + t.Errorf("dumpsIndent =\n%q\nwant\n%q", got, want) + } + // Stability is the point: repeat it enough that Go's randomized map + // iteration would show up. + for i := 0; i < 50; i++ { + if dumpsIndent(map[string]int{"zeta": 1, "alpha": 2, "mid": 3}, 2) != want { + t.Fatalf("map key order is not stable (iteration %d)", i) + } + } +} + +// TestDumpsPydanticEscaping pins pydantic's serializer, which — unlike +// json.dumps — leaves non-ASCII and DEL raw and escapes only C0 controls. +// +// VERIFIED: a pydantic model with s='a\x00b\x01c\x7fdée—f😀g<>&' dumps as +// {"s":"a\x00b\x01cdée—f😀g<>&"}. +func TestDumpsPydanticEscaping(t *testing.T) { + got := dumpsPydantic(obj{{"s", "a\x00b\x01c\x7fdée—f😀g<>&"}}) + want := "{\"s\":\"a\\u0000b\\u0001c\x7fdée—f😀g<>&\"}" + if got != want { + t.Errorf("dumpsPydantic =\n%q\nwant\n%q", got, want) + } + + // Short escapes are shared with json.dumps. + // VERIFIED: model_dump_json of 'a\nb\tc\rd\be\ff/g' -> "a\nb\tc\rd\be\ff/g". + if got := dumpsPydantic(obj{{"s", "a\nb\tc\rd\be\ff/g"}}); got != `{"s":"a\nb\tc\rd\be\ff/g"}` { + t.Errorf("dumpsPydantic short escapes = %q", got) + } +} + +// TestDumpsPydanticHasNoWhitespace pins the compact separators. +func TestDumpsPydanticHasNoWhitespace(t *testing.T) { + got := dumpsPydantic(obj{{"a", 1}, {"b", []any{1, 2}}, {"c", obj{{"d", nil}}}}) + want := `{"a":1,"b":[1,2],"c":{"d":null}}` + if got != want { + t.Errorf("dumpsPydantic = %q, want %q", got, want) + } +} + +// TestEncodeStructFollowsDeclarationOrder proves the struct walk keys by json +// tag and preserves pydantic field order — the property GenerateJSON leans on. +func TestEncodeStructFollowsDeclarationOrder(t *testing.T) { + got := dumpsPydantic(schemas.ComplianceGap{ + Framework: "OWASP", + ControlID: "A03:2021", + ControlName: "Injection", + FindingCount: 2, + MaxSeverity: "high", + CweIDs: []string{"CWE-89"}, + }) + want := `{"framework":"OWASP","control_id":"A03:2021","control_name":"Injection",` + + `"finding_count":2,"max_severity":"high","cwe_ids":["CWE-89"]}` + if got != want { + t.Errorf("struct dump =\n%q\nwant\n%q", got, want) + } +} + +// TestEncodeNilSliceAndMap pins the null-vs-empty distinction: a nil slice or +// map is a pydantic `X | None = None` field and dumps as null, while an +// allocated empty one dumps as [] / {}. +func TestEncodeNilSliceAndMap(t *testing.T) { + var nilSlice []string + var nilMap map[string]int + got := dumpsPydantic(obj{ + {"nil_slice", nilSlice}, + {"empty_slice", []string{}}, + {"nil_map", nilMap}, + {"empty_map", map[string]int{}}, + }) + want := `{"nil_slice":null,"empty_slice":[],"nil_map":null,"empty_map":{}}` + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// TestEncodePointers covers the *string / *int fields the schemas use for +// Optional. +func TestEncodePointers(t *testing.T) { + s := "value" + n := 7 + var nilStr *string + got := dumpsPydantic(obj{{"set", &s}, {"num", &n}, {"unset", nilStr}}) + if want := `{"set":"value","num":7,"unset":null}`; got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// TestPydanticISO pins the "...Z" datetime spelling against the isoformat one +// schemas.Timestamp.String() produces. +// +// VERIFIED against pydantic 2.x: the same instant renders as +// "2026-03-04T10:30:00Z" through model_dump_json and +// "2026-03-04T10:30:00+00:00" through datetime.isoformat. +func TestPydanticISO(t *testing.T) { + cases := []struct { + name string + in time.Time + want string + wantISO string + skipISO bool + }{ + { + name: "utc, no fraction", + in: time.Date(2026, 3, 4, 10, 30, 0, 0, time.UTC), + want: "2026-03-04T10:30:00Z", + wantISO: "2026-03-04T10:30:00+00:00", + }, + { + name: "utc, microseconds", + in: time.Date(2026, 3, 4, 10, 30, 0, 123456000, time.UTC), + want: "2026-03-04T10:30:00.123456Z", + wantISO: "2026-03-04T10:30:00.123456+00:00", + }, + { + name: "utc, trailing zeros kept", + in: time.Date(2026, 3, 4, 10, 30, 0, 120000000, time.UTC), + want: "2026-03-04T10:30:00.120000Z", + wantISO: "2026-03-04T10:30:00.120000+00:00", + }, + { + name: "non-utc offset", + in: time.Date(2026, 3, 4, 10, 30, 0, 0, time.FixedZone("IST", 5*3600+1800)), + want: "2026-03-04T10:30:00+05:30", + wantISO: "2026-03-04T10:30:00+05:30", + }, + { + name: "sub-microsecond nanoseconds are truncated away", + in: time.Date(2026, 3, 4, 10, 30, 0, 500, time.UTC), + want: "2026-03-04T10:30:00Z", + skipISO: true, // schemas.Timestamp.String() keeps its own rounding + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ts := schemas.NewTimestamp(tc.in) + if got := pydanticISO(ts); got != tc.want { + t.Errorf("pydanticISO = %q, want %q", got, tc.want) + } + if !tc.skipISO { + if got := ts.String(); got != tc.wantISO { + t.Errorf("Timestamp.String = %q, want %q", got, tc.wantISO) + } + } + }) + } +} + +// negZero returns -0.0 without tripping the compiler's constant folding, so the +// "-0.0" rendering is genuinely exercised. +func negZero() float64 { + zero := 0.0 + return -zero +} diff --git a/go/internal/output/report.go b/go/internal/output/report.go new file mode 100644 index 0000000..b0ebd16 --- /dev/null +++ b/go/internal/output/report.go @@ -0,0 +1,169 @@ +package output + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// This file ports src/sec_af/output/report.py — the human-readable Markdown +// audit report. Every line is byte-identical to the Python one; the golden test +// diffs the whole document. + +// renderSummary ports _render_summary. +// +// Python parity: the branch line is chosen by TRUTHINESS, so an empty branch +// string renders "- Branch: n/a" exactly like a null one. +func renderSummary(result schemas.SecurityAuditResult) []string { + branchLine := "- Branch: n/a" + if result.Branch != nil && *result.Branch != "" { + branchLine = fmt.Sprintf("- Branch: `%s`", *result.Branch) + } + return []string{ + "## Summary", + "", + fmt.Sprintf("- Repository: `%s`", result.Repository), + fmt.Sprintf("- Commit: `%s`", result.CommitSha), + branchLine, + fmt.Sprintf("- Timestamp: `%s`", result.Timestamp.String()), + fmt.Sprintf("- Depth profile: `%s`", result.DepthProfile), + fmt.Sprintf("- Provider: `%s`", result.Provider), + fmt.Sprintf("- Findings: **%d** (confirmed: %d, likely: %d, inconclusive: %d, not exploitable: %d)", + len(result.Findings), result.Confirmed, result.Likely, result.Inconclusive, result.NotExploitable), + fmt.Sprintf("- Noise reduction: **%.1f%%**", result.NoiseReductionPct), + "", + } +} + +// renderFinding ports _render_finding. +func renderFinding(finding schemas.VerifiedFinding) []string { + lines := []string{ + fmt.Sprintf("### %s", finding.Title), + "", + fmt.Sprintf("- ID: `%s`", finding.ID), + fmt.Sprintf("- Verdict: `%s` (evidence level %d)", string(finding.Verdict), int(finding.EvidenceLevel)), + fmt.Sprintf("- Severity: `%s` | Exploitability: **%.1f/10**", string(finding.Severity), finding.ExploitabilityScore), + fmt.Sprintf("- CWE: `%s` (%s)", finding.CweID, finding.CweName), + fmt.Sprintf("- Location: `%s:%d`", finding.Location.FilePath, finding.Location.StartLine), + } + if finding.ChainID != nil && *finding.ChainID != "" { + lines = append(lines, fmt.Sprintf("- Chain: `%s` step %s", *finding.ChainID, chainStepLabel(finding.ChainStep))) + } + if finding.Proof != nil && len(finding.Proof.DataFlowTrace) > 0 { + lines = append(lines, "- Data flow trace:") + for _, step := range finding.Proof.DataFlowTrace { + lines = append(lines, fmt.Sprintf(" - `%s:%d` - %s", step.File, step.Line, step.Description)) + } + } + if finding.Rationale != "" { + lines = append(lines, fmt.Sprintf("- Rationale: %s", finding.Rationale)) + } + return append(lines, "") +} + +// chainStepLabel ports `finding.chain_step or '?'`: Python truthiness, so both +// a null step and a step of 0 render as "?". +func chainStepLabel(step *int) string { + if step == nil || *step == 0 { + return "?" + } + return strconv.Itoa(*step) +} + +// renderAttackChain ports _render_attack_chain. +func renderAttackChain(chain schemas.AttackChain) []string { + quoted := make([]string, 0, len(chain.Findings)) + for _, findingID := range chain.Findings { + quoted = append(quoted, "`"+findingID+"`") + } + lines := []string{ + fmt.Sprintf("### %s", chain.Title), + "", + fmt.Sprintf("- Chain ID: `%s`", chain.ChainID), + fmt.Sprintf("- Combined severity: `%s`", string(chain.CombinedSeverity)), + fmt.Sprintf("- Combined impact: %s", chain.CombinedImpact), + fmt.Sprintf("- Findings: %s", strings.Join(quoted, ", ")), + } + if len(chain.MitreAttackMapping) > 0 { + lines = append(lines, "- MITRE ATT&CK:") + for _, mapping := range chain.MitreAttackMapping { + lines = append(lines, fmt.Sprintf(" - %s (%s): %s", mapping.TechniqueID, mapping.Tactic, mapping.TechniqueName)) + } + } + return append(lines, "") +} + +// GenerateReport ports generate_report: the full Markdown audit report, +// joined with "\n" and carrying NO trailing newline (Python's `"\n".join`). +// +// Python parity divergence: the cost-breakdown section iterates a dict, whose +// order in CPython is insertion order; a Go map carries none, so the phases are +// listed in SORTED key order. Same deviation as the JSON writer's — see +// pyjson_local.go encodeMap. +func GenerateReport(result schemas.SecurityAuditResult) string { + lines := []string{ + "# SEC-AF Security Audit Report", + "", + } + lines = append(lines, renderSummary(result)...) + lines = append(lines, "## Findings", "") + + if len(result.Findings) > 0 { + for _, finding := range result.Findings { + lines = append(lines, renderFinding(finding)...) + } + } else { + lines = append(lines, "No findings.", "") + } + + lines = append(lines, "## Attack Chains", "") + if len(result.AttackChains) > 0 { + for _, chain := range result.AttackChains { + lines = append(lines, renderAttackChain(chain)...) + } + } else { + lines = append(lines, "No attack chains.", "") + } + + lines = append(lines, "## Compliance Gaps", "") + if len(result.ComplianceGaps) > 0 { + for _, gap := range result.ComplianceGaps { + lines = append(lines, fmt.Sprintf("- %s %s: %s (findings: %d, max severity: %s)", + gap.Framework, gap.ControlID, gap.ControlName, gap.FindingCount, gap.MaxSeverity)) + } + lines = append(lines, "") + } else { + lines = append(lines, "No compliance gaps.", "") + } + + lines = append(lines, + "## Performance & Cost", + "", + fmt.Sprintf("- Duration: %.1fs", result.DurationSeconds), + fmt.Sprintf("- Agent invocations: %d", result.AgentInvocations), + fmt.Sprintf("- Cost: $%.2f", result.CostUsd), + "- Cost breakdown:", + ) + if len(result.CostBreakdown) > 0 { + phases := make([]string, 0, len(result.CostBreakdown)) + for phase := range result.CostBreakdown { + phases = append(phases, phase) + } + sort.Strings(phases) + for _, phase := range phases { + lines = append(lines, fmt.Sprintf(" - %s: $%.2f", phase, result.CostBreakdown[phase])) + } + } else { + lines = append(lines, " - n/a") + } + + return strings.Join(lines, "\n") +} + +// RenderReport ports render_report, the alias generate_report is exported under. +func RenderReport(auditResult schemas.SecurityAuditResult) string { + return GenerateReport(auditResult) +} diff --git a/go/internal/output/report_test.go b/go/internal/output/report_test.go new file mode 100644 index 0000000..0e198b9 --- /dev/null +++ b/go/internal/output/report_test.go @@ -0,0 +1,119 @@ +package output + +import ( + "strings" + "testing" +) + +// This file covers output/report.py behaviours the Python suite's single +// substring test (ported in json_output_test.go) does not reach. The +// whole-document parity check lives in golden_test.go. + +// TestReportEmptySections covers every "nothing to report" branch of +// generate_report at once, via the empty fixture. +func TestReportEmptySections(t *testing.T) { + report := GenerateReport(loadFixture(t, "audit_result_empty")) + for _, want := range []string{ + "- Branch: n/a", + "No findings.", + "No attack chains.", + "No compliance gaps.", + " - n/a", + } { + if !strings.Contains(report, want) { + t.Errorf("report is missing %q", want) + } + } +} + +// TestReportBranchTruthiness pins that an EMPTY branch renders "n/a" like a +// null one — Python tests `if result.branch`, not `is not None`. The edge +// fixture carries branch="". +func TestReportBranchTruthiness(t *testing.T) { + report := GenerateReport(loadFixture(t, "audit_result_edge")) + if !strings.Contains(report, "- Branch: n/a") { + t.Error("an empty branch must render \"- Branch: n/a\"") + } + if strings.Contains(report, "- Branch: ``") { + t.Error("an empty branch was rendered as an empty code span") + } +} + +// TestReportChainStepFallback pins `finding.chain_step or '?'`: the edge +// fixture's dup-a has chain_step 0, which is falsy. +func TestReportChainStepFallback(t *testing.T) { + report := GenerateReport(loadFixture(t, "audit_result_edge")) + if !strings.Contains(report, "- Chain: `chain-x` step ?") { + t.Error("a chain_step of 0 must render as \"?\"") + } +} + +// TestChainStepLabel covers the helper directly. +func TestChainStepLabel(t *testing.T) { + zero := 0 + one := 1 + cases := []struct { + in *int + want string + }{ + {nil, "?"}, + {&zero, "?"}, + {&one, "1"}, + } + for _, tc := range cases { + if got := chainStepLabel(tc.in); got != tc.want { + t.Errorf("chainStepLabel(%v) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// TestReportOmitsEmptyRationale pins the `if finding.rationale:` guard: the +// edge fixture's dup-a has an empty rationale and must not get the line. +func TestReportOmitsEmptyRationale(t *testing.T) { + report := GenerateReport(loadFixture(t, "audit_result_edge")) + if strings.Contains(report, "- Rationale: \n") || strings.HasSuffix(report, "- Rationale: ") { + t.Error("an empty rationale produced a bare \"- Rationale:\" line") + } + // The finding that DOES have one still gets it. + if !strings.Contains(report, "- Rationale: Confirmed by trace.") { + t.Error("a non-empty rationale is missing") + } +} + +// TestReportHasNoTrailingNewline pins Python's `"\n".join(lines)`, which ends +// the document on the last content line. +func TestReportHasNoTrailingNewline(t *testing.T) { + for _, name := range goldenFixtures { + report := GenerateReport(loadFixture(t, name)) + if strings.HasSuffix(report, "\n") { + t.Errorf("%s: report ends with a newline", name) + } + } +} + +// TestComplianceReportHasNoTrailingNewline pins the same for the compliance +// report, whose last line is the generator credit. +func TestComplianceReportHasNoTrailingNewline(t *testing.T) { + for _, name := range goldenFixtures { + report := GenerateComplianceReportAt(loadFixture(t, name), goldenComplianceReportAt) + if strings.HasSuffix(report, "\n") { + t.Errorf("%s: compliance report ends with a newline", name) + } + if !strings.HasSuffix(report, "*Report generated by SEC-AF -- Composite Intelligence Security Auditor*") { + t.Errorf("%s: compliance report does not end with the credit line", name) + } + } +} + +// TestReportDataFlowTrace pins the trace block, which only appears when the +// proof carries steps. +func TestReportDataFlowTrace(t *testing.T) { + withTrace := GenerateReport(loadFixture(t, "audit_result")) + if !strings.Contains(withTrace, "- Data flow trace:\n - `src/routes.py:15` - Input source") { + t.Error("the data flow trace block is missing or malformed") + } + withoutTrace := GenerateReport(loadFixture(t, "audit_result_report")) + if strings.Contains(withoutTrace, "- Data flow trace:") { + t.Error("a finding with no proof produced a data flow trace block") + } +} diff --git a/go/internal/output/sarif.go b/go/internal/output/sarif.go new file mode 100644 index 0000000..a051e4b --- /dev/null +++ b/go/internal/output/sarif.go @@ -0,0 +1,459 @@ +// Package output ports src/sec_af/output: the four artifact generators the +// orchestrator writes at the end of an audit — SARIF, the full/summary JSON, +// the Markdown report and the compliance report. +// +// Every generator's output is compared byte-for-byte against the Python one by +// golden_test.go, which feeds both implementations the same +// testdata/audit_result.json fixture. That is why this package carries its own +// JSON writer (pyjson_local.go) instead of using encoding/json, and why every +// number and string below is formatted through pyfmt. +package output + +import ( + "fmt" + "sort" + "strings" + "unicode" + + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// pythonPackageVersion mirrors `src/sec_af/__init__.py::__version__`, which +// sarif.py stamps into the SARIF driver as `semanticVersion`. It is duplicated +// rather than derived because the Go binary has no import of the Python +// package; bump it together with the Python one. +const pythonPackageVersion = "0.1.0" + +// severityToLevel ports _SEVERITY_TO_LEVEL. +var severityToLevel = map[string]string{ + "critical": "error", + "high": "error", + "medium": "warning", + "low": "note", + "info": "note", +} + +// levelRank ports _LEVEL_RANK. +var levelRank = map[string]int{"error": 3, "warning": 2, "note": 1} + +// verdictToPrecision ports _VERDICT_TO_PRECISION. +var verdictToPrecision = map[string]string{ + "confirmed": "very-high", + "likely": "high", + "inconclusive": "medium", + "not_exploitable": "low", +} + +// precisionRank ports _PRECISION_RANK. +var precisionRank = map[string]int{"very-high": 4, "high": 3, "medium": 2, "low": 1} + +// GenerateSarif ports output/sarif.py generate_sarif: the SARIF 2.1.0 document +// for one audit result, serialised with `json.dumps(sarif, indent=2)`. +// +// Findings whose verdict is "not_exploitable" are dropped entirely — they +// appear in neither the results nor the rules — which is what makes the SARIF +// artifact the "signal only" view of an audit. +// +// The key order below is the Python dict literal's order and is part of the +// artifact (see pyjson_local.go). +func GenerateSarif(result schemas.SecurityAuditResult) string { + included := make([]schemas.VerifiedFinding, 0, len(result.Findings)) + for _, finding := range result.Findings { + if string(finding.Verdict) != "not_exploitable" { + included = append(included, finding) + } + } + + results := make([]any, 0, len(included)) + for _, finding := range included { + results = append(results, buildResult(finding)) + } + + sarif := obj{ + {"$schema", "https://json.schemastore.org/sarif-2.1.0.json"}, + {"version", "2.1.0"}, + {"runs", []any{ + obj{ + {"tool", buildToolSection(included)}, + {"results", results}, + {"automationDetails", obj{ + {"id", fmt.Sprintf("sec-af/audit/%s/%s", result.Repository, result.Timestamp.String())}, + }}, + }, + }}, + } + return dumpsIndent(sarif, 2) +} + +// RenderSarif ports render_sarif, the alias generate_sarif is exported under. +func RenderSarif(auditResult schemas.SecurityAuditResult) string { + return GenerateSarif(auditResult) +} + +// buildToolSection ports _build_tool_section: one rule per distinct +// sarif_rule_id, in sorted rule-id order (Python's `sorted(rules_by_id.items())`). +func buildToolSection(findings []schemas.VerifiedFinding) obj { + rulesByID := map[string][]schemas.VerifiedFinding{} + for _, finding := range findings { + rulesByID[finding.SarifRuleID] = append(rulesByID[finding.SarifRuleID], finding) + } + ruleIDs := make([]string, 0, len(rulesByID)) + for ruleID := range rulesByID { + ruleIDs = append(ruleIDs, ruleID) + } + sort.Strings(ruleIDs) + + rules := make([]any, 0, len(ruleIDs)) + for _, ruleID := range ruleIDs { + rules = append(rules, buildRule(ruleID, rulesByID[ruleID])) + } + + return obj{ + {"driver", obj{ + {"name", "SEC-AF"}, + {"semanticVersion", pythonPackageVersion}, + {"informationUri", "https://github.com/Agent-Field/sec-af"}, + {"rules", rules}, + }}, + } +} + +// buildRule ports _build_rule. The first finding for the rule id supplies the +// human-readable text; level, security-severity and precision are aggregated +// across every finding that shares the id. +func buildRule(ruleID string, findings []schemas.VerifiedFinding) obj { + representative := findings[0] + maxScore := findings[0].ExploitabilityScore + for _, finding := range findings[1:] { + if finding.ExploitabilityScore > maxScore { + maxScore = finding.ExploitabilityScore + } + } + number := cweNumber(representative.CweID) + return obj{ + {"id", ruleID}, + {"name", ruleName(ruleID)}, + {"shortDescription", obj{{"text", representative.Title + " vulnerability"}}}, + {"fullDescription", obj{{"text", representative.Description}}}, + {"helpUri", "https://cwe.mitre.org/data/definitions/" + number + ".html"}, + {"defaultConfiguration", obj{{"level", maxLevel(findings)}}}, + {"properties", obj{ + {"precision", maxPrecision(findings)}, + {"security-severity", formatSecuritySeverity(maxScore)}, + {"tags", aggregateRuleTags(findings)}, + }}, + } +} + +// buildResult ports _build_result: one SARIF result per included finding. +// +// `relatedLocations` and `codeFlows` are only present when non-empty, matching +// the Python `if related_locations:` / `if code_flows:` guards. +func buildResult(finding schemas.VerifiedFinding) obj { + locations := []any{obj{{"physicalLocation", physicalLocation(finding.Location)}}} + + result := obj{ + {"ruleId", finding.SarifRuleID}, + {"level", severityToLevelOf(string(finding.Severity))}, + {"message", obj{{"text", messageText(finding)}}}, + {"locations", locations}, + {"partialFingerprints", obj{{"primaryLocationLineHash", finding.Fingerprint}}}, + {"properties", obj{ + {"security-severity", formatSecuritySeverity(finding.SarifSecuritySeverity)}, + {"sec-af/verdict", string(finding.Verdict)}, + {"sec-af/evidence_level", int(finding.EvidenceLevel)}, + {"sec-af/exploitability_score", finding.ExploitabilityScore}, + {"sec-af/chain_id", finding.ChainID}, + {"sec-af/compliance", complianceList(finding)}, + {"tags", resultTags(finding)}, + }}, + } + + if related := relatedLocations(finding.RelatedLocations); len(related) > 0 { + result = append(result, kv{"relatedLocations", related}) + } + if flows := codeFlows(finding); len(flows) > 0 { + result = append(result, kv{"codeFlows", flows}) + } + return result +} + +// messageText ports _message_text. +func messageText(finding schemas.VerifiedFinding) string { + verdict := strings.ToUpper(string(finding.Verdict)) + return fmt.Sprintf("[%s] %s: %s. Evidence level: %s.", + verdict, finding.Title, finding.Description, finding.EvidenceLevel.Name()) +} + +// physicalLocation ports _physical_location. +// +// Python parity: startColumn/endColumn are emitted on an `is not None` check +// (so a real 0 would be kept) while the snippet is emitted on a TRUTHY check +// (so an empty code_snippet is dropped, exactly like a missing one). +func physicalLocation(location schemas.Location) obj { + region := obj{ + {"startLine", location.StartLine}, + {"endLine", location.EndLine}, + } + if location.StartColumn != nil { + region = append(region, kv{"startColumn", *location.StartColumn}) + } + if location.EndColumn != nil { + region = append(region, kv{"endColumn", *location.EndColumn}) + } + if location.CodeSnippet != nil && *location.CodeSnippet != "" { + region = append(region, kv{"snippet", obj{{"text", *location.CodeSnippet}}}) + } + + return obj{ + {"artifactLocation", obj{ + {"uri", location.FilePath}, + {"uriBaseId", "%SRCROOT%"}, + }}, + {"region", region}, + } +} + +// relatedLocations ports _related_locations: ids are 1-based +// (`enumerate(locations, start=1)`). +func relatedLocations(locations []schemas.Location) []any { + related := make([]any, 0, len(locations)) + for index, location := range locations { + related = append(related, obj{ + {"id", index + 1}, + {"physicalLocation", physicalLocation(location)}, + {"message", obj{{"text", "Related location"}}}, + }) + } + return related +} + +// codeFlows ports _code_flows: a single thread flow built from the proof's +// data-flow trace, or nothing at all when there is no proof or no trace. +func codeFlows(finding schemas.VerifiedFinding) []any { + if finding.Proof == nil || len(finding.Proof.DataFlowTrace) == 0 { + return nil + } + flowLocations := make([]any, 0, len(finding.Proof.DataFlowTrace)) + for _, step := range finding.Proof.DataFlowTrace { + flowLocations = append(flowLocations, obj{ + {"location", obj{ + {"physicalLocation", obj{ + {"artifactLocation", obj{{"uri", step.File}}}, + {"region", obj{{"startLine", step.Line}}}, + }}, + {"message", obj{{"text", step.Description}}}, + }}, + }) + } + return []any{obj{{"threadFlows", []any{obj{{"locations", flowLocations}}}}}} +} + +// severityToLevelOf ports _severity_to_level: an unknown severity maps to +// "warning". +func severityToLevelOf(severity string) string { + if level, ok := severityToLevel[severity]; ok { + return level + } + return "warning" +} + +// maxLevel ports _max_level. Python's max() returns the FIRST element holding +// the maximum key, which the strict `>` comparison reproduces. +func maxLevel(findings []schemas.VerifiedFinding) string { + best := severityToLevelOf(string(findings[0].Severity)) + for _, finding := range findings[1:] { + level := severityToLevelOf(string(finding.Severity)) + if levelRank[level] > levelRank[best] { + best = level + } + } + return best +} + +// maxPrecision ports _max_precision, with the same first-wins tie-break. +func maxPrecision(findings []schemas.VerifiedFinding) string { + precisionOf := func(finding schemas.VerifiedFinding) string { + if precision, ok := verdictToPrecision[string(finding.Verdict)]; ok { + return precision + } + return "medium" + } + best := precisionOf(findings[0]) + for _, finding := range findings[1:] { + precision := precisionOf(finding) + if precisionRank[precision] > precisionRank[best] { + best = precision + } + } + return best +} + +// complianceList ports _compliance_list. +func complianceList(finding schemas.VerifiedFinding) []any { + out := make([]any, 0, len(finding.Compliance)) + for _, mapping := range finding.Compliance { + out = append(out, complianceEntry(mapping.Framework, mapping.ControlID)) + } + return out +} + +// aggregateRuleTags ports _aggregate_rule_tags: the union of every finding's +// base and compliance tags, sorted. +func aggregateRuleTags(findings []schemas.VerifiedFinding) []any { + tags := map[string]struct{}{} + for _, finding := range findings { + for _, tag := range baseTags(finding) { + tags[tag] = struct{}{} + } + for _, tag := range complianceTags(finding) { + tags[tag] = struct{}{} + } + } + return sortedTagList(tags) +} + +// resultTags ports _result_tags. +func resultTags(finding schemas.VerifiedFinding) []any { + tags := map[string]struct{}{} + for _, tag := range baseTags(finding) { + tags[tag] = struct{}{} + } + for _, tag := range complianceTags(finding) { + tags[tag] = struct{}{} + } + return sortedTagList(tags) +} + +// sortedTagList renders a Python `sorted(set_of_tags)`. Python sorts strings by +// code point; Go's byte-order sort agrees for valid UTF-8. +func sortedTagList(tags map[string]struct{}) []any { + names := make([]string, 0, len(tags)) + for tag := range tags { + names = append(names, tag) + } + sort.Strings(names) + out := make([]any, 0, len(names)) + for _, name := range names { + out = append(out, name) + } + return out +} + +// baseTags ports _base_tags: "security", the upper-cased CWE id, the OWASP +// category when set, then the finding's own tags sorted. +func baseTags(finding schemas.VerifiedFinding) []string { + tags := []string{"security", strings.ToUpper(finding.CweID)} + if finding.OwaspCategory != nil && *finding.OwaspCategory != "" { + tags = append(tags, "OWASP-"+*finding.OwaspCategory) + } + own := append([]string(nil), finding.Tags...) + sort.Strings(own) + return append(tags, own...) +} + +// complianceTags ports _compliance_tags. +func complianceTags(finding schemas.VerifiedFinding) []string { + out := make([]string, 0, len(finding.Compliance)) + for _, mapping := range finding.Compliance { + out = append(out, "compliance:"+mapping.Framework+":"+normalizeControlID(mapping.ControlID)) + } + return out +} + +// complianceEntry ports _compliance_entry. +func complianceEntry(framework, controlID string) string { + return framework + ":" + normalizeControlID(controlID) +} + +// normalizeControlID ports _normalize_control_id: +// +// re.sub(r"\s+", "-", control_id.strip()) +// +// so "Req 6.2.4" becomes "Req-6.2.4". Implemented by hand rather than with +// regexp because Python's `\s` for a str pattern is Unicode-aware while Go's +// `\s` is ASCII-only; pyIsSpace below is the Python classification. +func normalizeControlID(controlID string) string { + trimmed := strings.TrimFunc(controlID, pyIsSpace) + var b strings.Builder + b.Grow(len(trimmed)) + inRun := false + for _, r := range trimmed { + if pyIsSpace(r) { + if !inRun { + b.WriteByte('-') + inRun = true + } + continue + } + inRun = false + b.WriteRune(r) + } + return b.String() +} + +// pyIsSpace reports whether r is whitespace to Python (`str.isspace()` and the +// `\s` class of a str regex): Go's unicode.IsSpace plus the four information +// separators U+001C..U+001F. +func pyIsSpace(r rune) bool { + if r >= 0x1C && r <= 0x1F { + return true + } + return unicode.IsSpace(r) +} + +// formatSecuritySeverity ports _format_security_severity: the score clamped to +// [0, 10] and formatted with one decimal. Go's %.1f and Python's `:.1f` both +// round the exact binary value half-to-even, so they agree bit for bit. +func formatSecuritySeverity(score float64) string { + bounded := score + if bounded < 0 { + bounded = 0 + } + if bounded > 10 { + bounded = 10 + } + return fmt.Sprintf("%.1f", bounded) +} + +// ruleName ports _rule_name: the last "/"-separated segment of the rule id, +// split on "-", each non-empty chunk capitalized and concatenated — +// "sec-af/sast/sql-injection" becomes "SqlInjection" — falling back to +// "SecAfRule" when that yields nothing. +func ruleName(ruleID string) string { + segments := strings.Split(ruleID, "/") + rawName := segments[len(segments)-1] + var b strings.Builder + for _, chunk := range strings.Split(rawName, "-") { + if chunk == "" { + continue + } + b.WriteString(pyCapitalize(chunk)) + } + if b.Len() == 0 { + return "SecAfRule" + } + return b.String() +} + +// pyCapitalize ports Python's str.capitalize(): the first character is +// upper-cased and EVERY other character is lower-cased ("sqlINJECTION" -> +// "Sqlinjection"). +func pyCapitalize(s string) string { + if s == "" { + return "" + } + runes := []rune(s) + out := make([]rune, 0, len(runes)) + out = append(out, unicode.ToUpper(runes[0])) + for _, r := range runes[1:] { + out = append(out, unicode.ToLower(r)) + } + return string(out) +} + +// cweNumber ports _cwe_number: the CWE id upper-cased with every "CWE-" +// occurrence removed, so "cwe-89" becomes "89". +func cweNumber(cweID string) string { + return strings.ReplaceAll(strings.ToUpper(cweID), "CWE-", "") +} diff --git a/go/internal/output/sarif_test.go b/go/internal/output/sarif_test.go new file mode 100644 index 0000000..ff65ee0 --- /dev/null +++ b/go/internal/output/sarif_test.go @@ -0,0 +1,307 @@ +package output + +import ( + "encoding/json" + "testing" +) + +// This file ports tests/test_sarif.py. The Python tests take the +// `sample_security_audit_result` conftest fixture; the Go tests load the same +// data from testdata/audit_result.json, which scripts/gen_golden.py writes from +// that very fixture. + +// sarifDoc parses a generated SARIF document into an untyped tree. +func sarifDoc(t *testing.T, raw string) map[string]any { + t.Helper() + var payload map[string]any + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + t.Fatalf("generated SARIF is not valid JSON: %v", err) + } + return payload +} + +func mapAt(t *testing.T, value any, what string) map[string]any { + t.Helper() + m, ok := value.(map[string]any) + if !ok { + t.Fatalf("%s: want an object, got %T", what, value) + } + return m +} + +func sliceAt(t *testing.T, value any, what string) []any { + t.Helper() + s, ok := value.([]any) + if !ok { + t.Fatalf("%s: want an array, got %T", what, value) + } + return s +} + +// sarifRun returns runs[0] of a generated document. +func sarifRun(t *testing.T, payload map[string]any) map[string]any { + t.Helper() + runs := sliceAt(t, payload["runs"], "runs") + if len(runs) != 1 { + t.Fatalf("want exactly one run, got %d", len(runs)) + } + return mapAt(t, runs[0], "runs[0]") +} + +func containsAny(list []any, want string) bool { + for _, item := range list { + if s, ok := item.(string); ok && s == want { + return true + } + } + return false +} + +// TestGenerateSarifHasValid210Envelope ports +// test_generate_sarif_has_valid_2_1_0_envelope. +func TestGenerateSarifHasValid210Envelope(t *testing.T) { + result := loadFixture(t, "audit_result") + payload := sarifDoc(t, GenerateSarif(result)) + run := sarifRun(t, payload) + tool := mapAt(t, mapAt(t, run["tool"], "tool")["driver"], "driver") + + if payload["$schema"] != "https://json.schemastore.org/sarif-2.1.0.json" { + t.Errorf("$schema = %v", payload["$schema"]) + } + if payload["version"] != "2.1.0" { + t.Errorf("version = %v", payload["version"]) + } + if tool["name"] != "SEC-AF" { + t.Errorf("driver.name = %v", tool["name"]) + } + if tool["informationUri"] != "https://github.com/Agent-Field/sec-af" { + t.Errorf("driver.informationUri = %v", tool["informationUri"]) + } + wantID := "sec-af/audit/Agent-Field/sec-af/2026-03-04T10:30:00+00:00" + if got := mapAt(t, run["automationDetails"], "automationDetails")["id"]; got != wantID { + t.Errorf("automationDetails.id = %v, want %q", got, wantID) + } +} + +// TestGenerateSarifFiltersNotExploitableAndMapsSeverity ports +// test_generate_sarif_filters_not_exploitable_and_maps_severity. +func TestGenerateSarifFiltersNotExploitableAndMapsSeverity(t *testing.T) { + result := loadFixture(t, "audit_result") + run := sarifRun(t, sarifDoc(t, GenerateSarif(result))) + results := sliceAt(t, run["results"], "results") + + byRule := map[string]map[string]any{} + for _, item := range results { + entry := mapAt(t, item, "result") + byRule[entry["ruleId"].(string)] = entry + } + + if len(results) != 2 { + t.Fatalf("want 2 results, got %d", len(results)) + } + if _, present := byRule["sec-af/sast/xss"]; present { + t.Error("the not_exploitable finding leaked into the SARIF results") + } + if byRule["sec-af/sast/sql-injection"]["level"] != "error" { + t.Errorf("sql-injection level = %v, want \"error\"", byRule["sec-af/sast/sql-injection"]["level"]) + } + if byRule["sec-af/api/missing-authentication"]["level"] != "error" { + t.Errorf("missing-authentication level = %v, want \"error\"", byRule["sec-af/api/missing-authentication"]["level"]) + } +} + +// TestGenerateSarifIncludesComplianceTagsCodeflowAndLocations ports +// test_generate_sarif_includes_compliance_tags_codeflow_and_locations. +func TestGenerateSarifIncludesComplianceTagsCodeflowAndLocations(t *testing.T) { + result := loadFixture(t, "audit_result") + run := sarifRun(t, sarifDoc(t, GenerateSarif(result))) + + var sql map[string]any + for _, item := range sliceAt(t, run["results"], "results") { + entry := mapAt(t, item, "result") + if entry["ruleId"] == "sec-af/sast/sql-injection" { + sql = entry + } + } + if sql == nil { + t.Fatal("no sql-injection result") + } + + properties := mapAt(t, sql["properties"], "properties") + locations := sliceAt(t, sql["locations"], "locations") + physical := mapAt(t, mapAt(t, locations[0], "locations[0]")["physicalLocation"], "physicalLocation") + region := mapAt(t, physical["region"], "region") + related := sliceAt(t, sql["relatedLocations"], "relatedLocations") + codeFlows := sliceAt(t, sql["codeFlows"], "codeFlows") + + if !containsAny(sliceAt(t, properties["sec-af/compliance"], "sec-af/compliance"), "PCI-DSS:Req-6.2.4") { + t.Errorf("sec-af/compliance = %v", properties["sec-af/compliance"]) + } + if !containsAny(sliceAt(t, properties["tags"], "tags"), "compliance:PCI-DSS:Req-6.2.4") { + t.Errorf("tags = %v", properties["tags"]) + } + if region["startLine"] != float64(42) { + t.Errorf("region.startLine = %v, want 42", region["startLine"]) + } + if region["startColumn"] != float64(9) { + t.Errorf("region.startColumn = %v, want 9", region["startColumn"]) + } + firstRelated := mapAt(t, related[0], "relatedLocations[0]") + uri := mapAt(t, mapAt(t, firstRelated["physicalLocation"], "physicalLocation")["artifactLocation"], "artifactLocation")["uri"] + if uri != "src/routes.py" { + t.Errorf("relatedLocations[0] uri = %v", uri) + } + threadFlows := sliceAt(t, mapAt(t, codeFlows[0], "codeFlows[0]")["threadFlows"], "threadFlows") + flowLocations := sliceAt(t, mapAt(t, threadFlows[0], "threadFlows[0]")["locations"], "locations") + if len(flowLocations) != 2 { + t.Errorf("thread flow has %d locations, want 2", len(flowLocations)) + } +} + +// TestGenerateSarifRuleEntriesAggregatePrecisionAndSeverity ports +// test_generate_sarif_rule_entries_aggregate_precision_and_severity. +func TestGenerateSarifRuleEntriesAggregatePrecisionAndSeverity(t *testing.T) { + result := loadFixture(t, "audit_result") + run := sarifRun(t, sarifDoc(t, GenerateSarif(result))) + driver := mapAt(t, mapAt(t, run["tool"], "tool")["driver"], "driver") + + var sqlRule map[string]any + for _, item := range sliceAt(t, driver["rules"], "rules") { + rule := mapAt(t, item, "rule") + if rule["id"] == "sec-af/sast/sql-injection" { + sqlRule = rule + } + } + if sqlRule == nil { + t.Fatal("no sql-injection rule") + } + properties := mapAt(t, sqlRule["properties"], "properties") + + if got := mapAt(t, sqlRule["defaultConfiguration"], "defaultConfiguration")["level"]; got != "error" { + t.Errorf("defaultConfiguration.level = %v, want \"error\"", got) + } + if properties["precision"] != "very-high" { + t.Errorf("precision = %v, want \"very-high\"", properties["precision"]) + } + if properties["security-severity"] != "10.0" { + t.Errorf("security-severity = %v, want \"10.0\"", properties["security-severity"]) + } + if !containsAny(sliceAt(t, properties["tags"], "tags"), "CWE-89") { + t.Errorf("tags = %v", properties["tags"]) + } +} + +// TestGenerateSarifIsStableForSameInput ports +// test_generate_sarif_is_stable_for_same_input. It is the assertion that keeps +// Go map iteration out of the artifact. +func TestGenerateSarifIsStableForSameInput(t *testing.T) { + result := loadFixture(t, "audit_result") + first := GenerateSarif(result) + for i := 0; i < 20; i++ { + if got := GenerateSarif(result); got != first { + t.Fatalf("run %d differs from the first", i) + } + } + if RenderSarif(result) != first { + t.Error("render_sarif differs from generate_sarif") + } +} + +// --------------------------------------------------------------------------- +// helper-level tests (no Python counterpart; they pin the helpers the goldens +// only exercise indirectly) +// --------------------------------------------------------------------------- + +// TestRuleName covers _rule_name, including the "SecAfRule" fallback. +func TestRuleName(t *testing.T) { + cases := map[string]string{ + "sec-af/sast/sql-injection": "SqlInjection", + "sec-af/api/missing-authentication": "MissingAuthentication", + "sec-af/": "SecAfRule", + "": "SecAfRule", + "---": "SecAfRule", + "sec-af/sast/XSS": "Xss", + "sec-af/sast/a--b": "AB", + "noslash": "Noslash", + } + for input, want := range cases { + if got := ruleName(input); got != want { + t.Errorf("ruleName(%q) = %q, want %q", input, got, want) + } + } +} + +// TestCweNumber covers _cwe_number. +func TestCweNumber(t *testing.T) { + cases := map[string]string{ + "CWE-89": "89", + "cwe-89": "89", + "89": "89", + "CWE-CWE-1": "1", + "": "", + } + for input, want := range cases { + if got := cweNumber(input); got != want { + t.Errorf("cweNumber(%q) = %q, want %q", input, got, want) + } + } +} + +// TestNormalizeControlID covers _normalize_control_id: strip, then every run of +// whitespace collapses to a single "-". +func TestNormalizeControlID(t *testing.T) { + cases := map[string]string{ + "Req 6.2.4": "Req-6.2.4", + " Req 6.2.4 ": "Req-6.2.4", + "Req 6.2.4": "Req-6.2.4", + "Req\t6.2.4": "Req-6.2.4", + "A03:2021": "A03:2021", + "": "", + " ": "", + } + for input, want := range cases { + if got := normalizeControlID(input); got != want { + t.Errorf("normalizeControlID(%q) = %q, want %q", input, got, want) + } + } +} + +// TestFormatSecuritySeverity covers _format_security_severity: clamped to +// [0, 10] and rendered with one decimal (half-to-even, like Python's :.1f). +func TestFormatSecuritySeverity(t *testing.T) { + cases := []struct { + in float64 + want string + }{ + {9.9, "9.9"}, + {10.0, "10.0"}, + {11.5, "10.0"}, + {-1.0, "0.0"}, + {0.04, "0.0"}, + {9.25, "9.2"}, + {0.35, "0.3"}, + } + for _, tc := range cases { + if got := formatSecuritySeverity(tc.in); got != tc.want { + t.Errorf("formatSecuritySeverity(%v) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// TestSeverityToLevelOf covers _severity_to_level's default. +func TestSeverityToLevelOf(t *testing.T) { + cases := map[string]string{ + "critical": "error", + "high": "error", + "medium": "warning", + "low": "note", + "info": "note", + "bogus": "warning", + "": "warning", + } + for input, want := range cases { + if got := severityToLevelOf(input); got != want { + t.Errorf("severityToLevelOf(%q) = %q, want %q", input, got, want) + } + } +} diff --git a/go/internal/output/testdata/audit_result.json b/go/internal/output/testdata/audit_result.json new file mode 100644 index 0000000..1667eb0 --- /dev/null +++ b/go/internal/output/testdata/audit_result.json @@ -0,0 +1,234 @@ +{ + "agent_invocations": 24, + "attack_chains": [ + { + "chain_id": "chain-1", + "combined_impact": "Unauthorized DB disclosure", + "combined_severity": "critical", + "description": "Untrusted input reaches SQL sink", + "findings": [ + "finding-confirmed", + "finding-likely" + ], + "mitre_attack_mapping": [ + { + "tactic": "Initial Access", + "technique_id": "T1190", + "technique_name": "Exploit Public-Facing Application" + } + ], + "title": "Input to DB read" + } + ], + "branch": "issue-23-tests", + "by_severity": { + "critical": 1, + "high": 1, + "low": 1 + }, + "commit_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "compliance_gaps": [ + { + "control_id": "Req 6.2.4", + "control_name": "Prevent injection", + "cwe_ids": [ + "CWE-89" + ], + "finding_count": 1, + "framework": "PCI-DSS", + "max_severity": "critical" + } + ], + "confirmed": 1, + "cost_breakdown": { + "hunt": 1.2, + "prove": 1.51, + "recon": 0.5 + }, + "cost_usd": 3.21, + "depth_profile": "standard", + "duration_seconds": 182.4, + "findings": [ + { + "chain_id": "chain-1", + "chain_step": 1, + "compliance": [ + { + "control_id": "Req 6.2.4", + "control_name": "Prevent injection", + "framework": "PCI-DSS" + } + ], + "cvss_v4": null, + "cwe_id": "CWE-89", + "cwe_name": "SQL Injection", + "description": "Unsanitized user input reaches SQL query execution.", + "drop_reason": null, + "enables": [ + "finding-likely" + ], + "epss": null, + "evidence_level": 6, + "exploitability_score": 10.0, + "finding_type": "sast", + "fingerprint": "fp-sql-1", + "id": "finding-confirmed", + "location": { + "code_snippet": "cursor.execute(f\"SELECT * FROM users WHERE id = {user_id}\")", + "end_column": 66, + "end_line": 42, + "file_path": "src/users.py", + "function_name": "lookup_user", + "start_column": 9, + "start_line": 42 + }, + "owasp_category": "A03:2021", + "proof": { + "chain_steps": null, + "data_flow_evidence": null, + "data_flow_trace": [ + { + "description": "Input source", + "file": "src/routes.py", + "line": 15, + "tainted": true + }, + { + "description": "SQL sink", + "file": "src/users.py", + "line": 42, + "tainted": true + } + ], + "evidence_level": 6, + "expected_outcome": "Unauthorized data access", + "exploit_hypothesis": "Inject through id parameter.", + "exploit_payload": "{\"id\": \"1 OR 1=1\"}", + "http_request": null, + "http_response": null, + "poc_code": null, + "poc_execution_output": null, + "reachability": null, + "sanitization_analysis": null, + "verification_method": "manual-review+trace", + "vulnerable_code": "cursor.execute(f\"SELECT * FROM users WHERE id = {user_id}\")" + }, + "rationale": "Source-to-sink path is confirmed and exploitable.", + "related_locations": [ + { + "code_snippet": "user_id = request.json['id']", + "end_column": null, + "end_line": 15, + "file_path": "src/routes.py", + "function_name": null, + "start_column": null, + "start_line": 15 + } + ], + "remediation": null, + "reproduction_steps": [], + "sarif_rule_id": "sec-af/sast/sql-injection", + "sarif_security_severity": 9.9, + "severity": "critical", + "tags": [ + "externally_reachable", + "user-input" + ], + "title": "SQL Injection", + "verdict": "confirmed" + }, + { + "chain_id": null, + "chain_step": null, + "compliance": [], + "cvss_v4": null, + "cwe_id": "CWE-306", + "cwe_name": "Missing Authentication for Critical Function", + "description": "Admin endpoint can be accessed without auth.", + "drop_reason": null, + "enables": null, + "epss": null, + "evidence_level": 2, + "exploitability_score": 4.8, + "finding_type": "api", + "fingerprint": "fp-auth-1", + "id": "finding-likely", + "location": { + "code_snippet": null, + "end_column": null, + "end_line": 11, + "file_path": "src/api/admin.py", + "function_name": null, + "start_column": null, + "start_line": 11 + }, + "owasp_category": "A07:2021", + "proof": null, + "rationale": "Guard checks appear absent on route.", + "related_locations": [], + "remediation": null, + "reproduction_steps": [], + "sarif_rule_id": "sec-af/api/missing-authentication", + "sarif_security_severity": 7.6, + "severity": "high", + "tags": [ + "requires_auth" + ], + "title": "Missing Authentication", + "verdict": "likely" + }, + { + "chain_id": null, + "chain_step": null, + "compliance": [], + "cvss_v4": null, + "cwe_id": "CWE-79", + "cwe_name": "Cross-site Scripting", + "description": "Output is escaped by template engine.", + "drop_reason": null, + "enables": null, + "epss": null, + "evidence_level": 1, + "exploitability_score": 0.6, + "finding_type": "sast", + "fingerprint": "fp-noise-1", + "id": "finding-noise", + "location": { + "code_snippet": null, + "end_column": null, + "end_line": 89, + "file_path": "src/views.py", + "function_name": null, + "start_column": null, + "start_line": 88 + }, + "owasp_category": null, + "proof": null, + "rationale": "Sink auto-escapes output.", + "related_locations": [], + "remediation": null, + "reproduction_steps": [], + "sarif_rule_id": "sec-af/sast/xss", + "sarif_security_severity": 1.9, + "severity": "low", + "tags": [], + "title": "Potential XSS", + "verdict": "not_exploitable" + } + ], + "inconclusive": 0, + "likely": 1, + "metadata": {}, + "noise_reduction_pct": 66.7, + "not_exploitable": 1, + "policy_violations": [], + "provider": "opencode", + "repository": "Agent-Field/sec-af", + "sarif": "{}", + "strategies_used": [ + "injection", + "auth" + ], + "timestamp": "2026-03-04T10:30:00Z", + "total_raw_findings": 6 +} diff --git a/go/internal/output/testdata/audit_result_edge.json b/go/internal/output/testdata/audit_result_edge.json new file mode 100644 index 0000000..bd30959 --- /dev/null +++ b/go/internal/output/testdata/audit_result_edge.json @@ -0,0 +1,306 @@ +{ + "agent_invocations": 3, + "attack_chains": [ + { + "chain_id": "chain-x", + "combined_impact": "Impact /", `" /"`}, + // ensure_ascii=True is json.dumps' default: every non-ASCII code point is + // escaped, and an astral one becomes a UTF-16 surrogate pair. + {"latin1", "h\u00e9llo", `"h\u00e9llo"`}, + {"em dash", "a \u2014 b", `"a \u2014 b"`}, + {"cjk", "\u4e16\u754c", `"\u4e16\u754c"`}, + {"astral becomes a surrogate pair", "\U0001F680", `"\ud83d\ude80"`}, + {"NEL", "\u0085", `"\u0085"`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := DumpsCompact(tc.in); got != tc.want { + t.Fatalf("DumpsCompact(%q) = %s, want %s", tc.in, got, tc.want) + } + }) + } +} + +// TestDumpsContainerSeparators pins the two separator regimes: (", ", ": ") with +// no indent, (",", ": ") plus newlines with one. +// +// python -c 'import json; print(json.dumps({"a":1,"b":[1,2]}))' +// python -c 'import json; print(json.dumps({"a":1,"b":[1,2]}, indent=2))' +func TestDumpsContainerSeparators(t *testing.T) { + value := Ordered{{Key: "a", Value: 1}, {Key: "b", Value: []any{1, 2}}} + + if got, want := DumpsCompact(value), `{"a": 1, "b": [1, 2]}`; got != want { + t.Fatalf("compact = %s, want %s", got, want) + } + want := "{\n \"a\": 1,\n \"b\": [\n 1,\n 2\n ]\n}" + if got := Dumps(value, 2); got != want { + t.Fatalf("indent=2 =\n%s\nwant\n%s", got, want) + } + if got := Dumps(value, 0); got != DumpsCompact(value) { + t.Fatalf("Dumps(v, 0) must equal DumpsCompact(v); got %s", got) + } + + // A four-space indent is the same document with a wider gutter. + want4 := "{\n \"a\": 1,\n \"b\": [\n 1,\n 2\n ]\n}" + if got := Dumps(value, 4); got != want4 { + t.Fatalf("indent=4 =\n%s\nwant\n%s", got, want4) + } +} + +// TestDumpsEmptyContainers pins that an empty list/dict stays on one line even +// in indent mode, exactly as Python renders it. +// +// python -c 'import json; print(json.dumps({"a": [], "b": {}}, indent=2))' +func TestDumpsEmptyContainers(t *testing.T) { + value := Ordered{{Key: "a", Value: []any{}}, {Key: "b", Value: Ordered{}}} + want := "{\n \"a\": [],\n \"b\": {}\n}" + if got := Dumps(value, 2); got != want { + t.Fatalf("got\n%s\nwant\n%s", got, want) + } + if got, want := DumpsCompact([]any{}), "[]"; got != want { + t.Fatalf("empty list compact = %s, want %s", got, want) + } + if got, want := DumpsCompact(map[string]any{}), "{}"; got != want { + t.Fatalf("empty map compact = %s, want %s", got, want) + } +} + +// TestDumpsNilContainers documents the one deliberate encoding/json-shaped +// deviation: a NIL Go slice or map is null, not [] / {}. Pydantic never +// produces None for a list field, so this only fires for an unpopulated Go +// value. +func TestDumpsNilContainers(t *testing.T) { + var nilSlice []string + var nilMap map[string]any + var nilPtr *int + var nilIface any + + for name, in := range map[string]any{ + "nil slice": nilSlice, + "nil map": nilMap, + "nil pointer": nilPtr, + "nil interface": nilIface, + } { + if got := DumpsCompact(in); got != "null" { + t.Fatalf("%s = %s, want null", name, got) + } + } +} + +// TestDumpsMapKeysAreSorted pins the documented map-ordering deviation, and +// that an Ordered is the escape hatch that preserves Python's insertion order. +func TestDumpsMapKeysAreSorted(t *testing.T) { + m := map[string]any{"zebra": 1, "Apple": 2, "_under": 3, "apple": 4} + // python -c 'import json; print(json.dumps({...}, sort_keys=True))' + want := `{"Apple": 2, "_under": 3, "apple": 4, "zebra": 1}` + if got := DumpsCompact(m); got != want { + t.Fatalf("map = %s, want %s", got, want) + } + + o := Ordered{{Key: "zebra", Value: 1}, {Key: "Apple", Value: 2}} + if got, want := DumpsCompact(o), `{"zebra": 1, "Apple": 2}`; got != want { + t.Fatalf("Ordered = %s, want %s", got, want) + } +} + +// TestDumpsMapKeyCoercion pins the non-string key spellings json.dumps uses. +// +// python -c 'import json; print(json.dumps({1: "a", True: "b"}))' # keys "1"/"true" +func TestDumpsMapKeyCoercion(t *testing.T) { + if got, want := DumpsCompact(map[int]string{2: "b", 10: "j"}), `{"10": "j", "2": "b"}`; got != want { + t.Fatalf("int keys = %s, want %s", got, want) + } + if got, want := DumpsCompact(map[bool]int{true: 1}), `{"true": 1}`; got != want { + t.Fatalf("bool key = %s, want %s", got, want) + } +} + +// jsonNumberDoc is decoded with UseNumber so integers stay integers, the way +// Python's json.loads distinguishes int from float. +func TestDumpsJSONNumber(t *testing.T) { + dec := json.NewDecoder(strings.NewReader(`{"i": 7, "big": 123456789012345678901234567890, "f": 1.5, "e": 0.00001, "cap": 1E2}`)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + t.Fatalf("decode: %v", err) + } + // python -c 'import json; print(json.dumps(json.loads(s), sort_keys=True))' + want := `{"big": 123456789012345678901234567890, "cap": 100.0, "e": 1e-05, "f": 1.5, "i": 7}` + if got := DumpsCompact(v); got != want { + t.Fatalf("got %s\nwant %s", got, want) + } +} + +// marshalerStamp stands in for schemas.Timestamp: a value type whose +// MarshalJSON produces the exact Python isoformat spelling. +type marshalerStamp struct{ text string } + +func (m marshalerStamp) MarshalJSON() ([]byte, error) { + return json.Marshal(m.text) +} + +// ptrMarshaler exercises the pointer-receiver marshaler path. +type ptrMarshaler struct{ n int } + +func (p *ptrMarshaler) MarshalJSON() ([]byte, error) { return []byte(`{"n": 1}`), nil } + +func TestDumpsHonorsJSONMarshaler(t *testing.T) { + value := Ordered{ + {Key: "ts", Value: marshalerStamp{text: "2026-01-02T03:04:05.123456+00:00"}}, + {Key: "raw", Value: json.RawMessage(`{"b":2,"a":1}`)}, + } + // The marshaler's own bytes are re-rendered through this encoder, so the + // RawMessage picks up Python's ": " separator and sorted keys. + want := `{"ts": "2026-01-02T03:04:05.123456+00:00", "raw": {"a": 1, "b": 2}}` + if got := DumpsCompact(value); got != want { + t.Fatalf("got %s\nwant %s", got, want) + } + + // A marshaler emitting an INTEGER must not be turned into a float by the + // re-render (UseNumber guards that). + if got, want := DumpsCompact(&ptrMarshaler{}), `{"n": 1}`; got != want { + t.Fatalf("pointer marshaler = %s, want %s", got, want) + } +} + +// structFixture pins the struct walk: declaration order, json tag names, +// `json:"-"`, omitempty, embedded flattening and pointer fields. +type Embedded struct { + Inner string `json:"inner"` +} + +type structFixture struct { + Embedded + Zed string `json:"zed"` + Alpha int `json:"alpha"` + Skipped string `json:"-"` + Omitted string `json:"omitted,omitempty"` + Kept string `json:"kept,omitempty"` + Ptr *float64 `json:"ptr"` + NoTag bool +} + +func TestDumpsStructWalk(t *testing.T) { + f := 1.0 + v := structFixture{ + Embedded: Embedded{Inner: "in"}, + Zed: "z", + Alpha: 1, + Skipped: "never", + Kept: "yes", + Ptr: &f, + NoTag: true, + } + // Declaration order, NOT sorted: this is what makes a Go struct stand in + // for a pydantic model_dump()'s insertion order. + want := `{"inner": "in", "zed": "z", "alpha": 1, "kept": "yes", "ptr": 1.0, "NoTag": true}` + if got := DumpsCompact(v); got != want { + t.Fatalf("got %s\nwant %s", got, want) + } + // A pointer to the struct renders identically. + if got := DumpsCompact(&v); got != want { + t.Fatalf("pointer form got %s\nwant %s", got, want) + } +} + +// TestDumpsNestedIndentation pins the indentation of a struct inside a slice +// inside a struct, the shape every model_dump() golden exercises. +func TestDumpsNestedIndentation(t *testing.T) { + type leaf struct { + A int `json:"a"` + } + type root struct { + Leaves []leaf `json:"leaves"` + } + want := "{\n \"leaves\": [\n {\n \"a\": 1\n },\n {\n \"a\": 2\n }\n ]\n}" + if got := Dumps(root{Leaves: []leaf{{A: 1}, {A: 2}}}, 2); got != want { + t.Fatalf("got\n%s\nwant\n%s", got, want) + } +} + +// TestDumpsBytesAreBase64 documents the []byte rendering. No SEC-AF model has a +// bytes field; the branch exists so a stray []byte cannot render as a list of +// integers. +func TestDumpsBytesAreBase64(t *testing.T) { + if got, want := DumpsCompact([]byte("hi")), `"aGk="`; got != want { + t.Fatalf("got %s, want %s", got, want) + } +} diff --git a/go/internal/pyfmt/testdata/golden/dumps_ArchitectureMap_compact.txt b/go/internal/pyfmt/testdata/golden/dumps_ArchitectureMap_compact.txt new file mode 100644 index 0000000..44cf8b8 --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/dumps_ArchitectureMap_compact.txt @@ -0,0 +1 @@ +{"app_type": "web_api", "modules": [{"name": "auth", "path": "app/auth/service.py", "language": "python", "description": "Session issuance \u2014 na\u00efve refresh, \u4e16\u754c emoji \ud83d\ude80", "dependencies": ["jwt", "redis"]}, {"name": "empty_deps", "path": "app/empty.py", "language": "go", "description": null, "dependencies": []}], "entry_points": [{"kind": "http", "identifier": "login", "file_path": "app/api/login.py", "line": 42, "method": "POST", "route": "/v1/login", "auth_required": false}, {"kind": "cli", "identifier": "migrate", "file_path": "tools/migrate.py", "line": 0, "method": null, "route": null, "auth_required": null}], "trust_boundaries": [{"name": "edge", "source_zone": "internet", "target_zone": "app", "description": "TLS terminates at the ingress & waf", "enforcement": []}], "services": [{"name": "postgres", "service_type": "database", "endpoint": "postgres://db:5432", "purpose": null, "auth_mechanism": "password"}], "api_surface": [{"method": "GET", "path": "/v1/users/{id}", "handler": "UserController.show", "file_path": "app/api/users.py", "line": 17, "auth_required": true, "rate_limited": null}]} \ No newline at end of file diff --git a/go/internal/pyfmt/testdata/golden/dumps_ArchitectureMap_indent2.txt b/go/internal/pyfmt/testdata/golden/dumps_ArchitectureMap_indent2.txt new file mode 100644 index 0000000..237af42 --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/dumps_ArchitectureMap_indent2.txt @@ -0,0 +1,71 @@ +{ + "app_type": "web_api", + "modules": [ + { + "name": "auth", + "path": "app/auth/service.py", + "language": "python", + "description": "Session issuance \u2014 na\u00efve refresh, \u4e16\u754c emoji \ud83d\ude80", + "dependencies": [ + "jwt", + "redis" + ] + }, + { + "name": "empty_deps", + "path": "app/empty.py", + "language": "go", + "description": null, + "dependencies": [] + } + ], + "entry_points": [ + { + "kind": "http", + "identifier": "login", + "file_path": "app/api/login.py", + "line": 42, + "method": "POST", + "route": "/v1/login", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "migrate", + "file_path": "tools/migrate.py", + "line": 0, + "method": null, + "route": null, + "auth_required": null + } + ], + "trust_boundaries": [ + { + "name": "edge", + "source_zone": "internet", + "target_zone": "app", + "description": "TLS terminates at the ingress & waf", + "enforcement": [] + } + ], + "services": [ + { + "name": "postgres", + "service_type": "database", + "endpoint": "postgres://db:5432", + "purpose": null, + "auth_mechanism": "password" + } + ], + "api_surface": [ + { + "method": "GET", + "path": "/v1/users/{id}", + "handler": "UserController.show", + "file_path": "app/api/users.py", + "line": 17, + "auth_required": true, + "rate_limited": null + } + ] +} \ No newline at end of file diff --git a/go/internal/pyfmt/testdata/golden/dumps_ConfigReport_compact.txt b/go/internal/pyfmt/testdata/golden/dumps_ConfigReport_compact.txt new file mode 100644 index 0000000..6fe3671 --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/dumps_ConfigReport_compact.txt @@ -0,0 +1 @@ +{"secrets": [{"id": "secret-01", "secret_type": "aws_access_key", "file_path": "config/prod.yaml", "line": 12, "match": "AKIA****0001", "confidence": "high", "is_test_value": false}], "misconfigs": [{"id": "misconfig-01", "category": "tls", "file_path": "config/prod.yaml", "line": null, "key": "SSL_VERIFY", "value": "false", "risk": "critical", "remediation": null}]} \ No newline at end of file diff --git a/go/internal/pyfmt/testdata/golden/dumps_ConfigReport_indent2.txt b/go/internal/pyfmt/testdata/golden/dumps_ConfigReport_indent2.txt new file mode 100644 index 0000000..fb114dc --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/dumps_ConfigReport_indent2.txt @@ -0,0 +1,25 @@ +{ + "secrets": [ + { + "id": "secret-01", + "secret_type": "aws_access_key", + "file_path": "config/prod.yaml", + "line": 12, + "match": "AKIA****0001", + "confidence": "high", + "is_test_value": false + } + ], + "misconfigs": [ + { + "id": "misconfig-01", + "category": "tls", + "file_path": "config/prod.yaml", + "line": null, + "key": "SSL_VERIFY", + "value": "false", + "risk": "critical", + "remediation": null + } + ] +} \ No newline at end of file diff --git a/go/internal/pyfmt/testdata/golden/dumps_DependencyReport_compact.txt b/go/internal/pyfmt/testdata/golden/dumps_DependencyReport_compact.txt new file mode 100644 index 0000000..7ee0647 --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/dumps_DependencyReport_compact.txt @@ -0,0 +1 @@ +{"sbom": [{"name": "django", "version": "4.2.1", "ecosystem": "pypi", "direct": true, "license": "BSD-3-Clause"}, {"name": "urllib3", "version": "1.26.5", "ecosystem": "pypi", "direct": false, "license": null}], "known_cves": [{"cve_id": "CVE-2023-0001", "package": "django", "installed_version": "4.2.1", "fixed_version": "4.2.5", "cvss_v4_score": 1.0, "epss_score": 0.5, "direct": true, "reachable": true}, {"cve_id": "CVE-2023-0002", "package": "urllib3", "installed_version": "1.26.5", "fixed_version": null, "cvss_v4_score": 1e-05, "epss_score": 1e+16, "direct": false, "reachable": null}, {"cve_id": "CVE-2023-0003", "package": "zlib", "installed_version": "1.2.11", "fixed_version": "1.2.12", "cvss_v4_score": 1000000000000000.0, "epss_score": -0.0, "direct": false, "reachable": false}], "outdated": [], "direct_count": 1, "transitive_count": 0} \ No newline at end of file diff --git a/go/internal/pyfmt/testdata/golden/dumps_DependencyReport_indent2.txt b/go/internal/pyfmt/testdata/golden/dumps_DependencyReport_indent2.txt new file mode 100644 index 0000000..dc099c2 --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/dumps_DependencyReport_indent2.txt @@ -0,0 +1,53 @@ +{ + "sbom": [ + { + "name": "django", + "version": "4.2.1", + "ecosystem": "pypi", + "direct": true, + "license": "BSD-3-Clause" + }, + { + "name": "urllib3", + "version": "1.26.5", + "ecosystem": "pypi", + "direct": false, + "license": null + } + ], + "known_cves": [ + { + "cve_id": "CVE-2023-0001", + "package": "django", + "installed_version": "4.2.1", + "fixed_version": "4.2.5", + "cvss_v4_score": 1.0, + "epss_score": 0.5, + "direct": true, + "reachable": true + }, + { + "cve_id": "CVE-2023-0002", + "package": "urllib3", + "installed_version": "1.26.5", + "fixed_version": null, + "cvss_v4_score": 1e-05, + "epss_score": 1e+16, + "direct": false, + "reachable": null + }, + { + "cve_id": "CVE-2023-0003", + "package": "zlib", + "installed_version": "1.2.11", + "fixed_version": "1.2.12", + "cvss_v4_score": 1000000000000000.0, + "epss_score": -0.0, + "direct": false, + "reachable": false + } + ], + "outdated": [], + "direct_count": 1, + "transitive_count": 0 +} \ No newline at end of file diff --git a/go/internal/pyfmt/testdata/golden/dumps_SecurityContext_compact.txt b/go/internal/pyfmt/testdata/golden/dumps_SecurityContext_compact.txt new file mode 100644 index 0000000..1c9e0a7 --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/dumps_SecurityContext_compact.txt @@ -0,0 +1 @@ +{"auth_model": "jwt", "auth_details": "HS256 \u2014 key from vault\ttab\nnewline \"quoted\" back\\slash", "crypto_usage": [{"algorithm": "AES", "key_size": 256, "mode": "GCM", "usage_context": "at-rest", "is_weak": false}, {"algorithm": "MD5", "key_size": null, "mode": null, "usage_context": null, "is_weak": true}], "framework_security": ["CsrfViewMiddleware", ""], "security_headers": [], "deployment_signals": ["k8s ingress & mTLS", "control chars: \u0000\u001f\u007f\u0085"]} \ No newline at end of file diff --git a/go/internal/pyfmt/testdata/golden/dumps_SecurityContext_indent2.txt b/go/internal/pyfmt/testdata/golden/dumps_SecurityContext_indent2.txt new file mode 100644 index 0000000..7a1ebe3 --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/dumps_SecurityContext_indent2.txt @@ -0,0 +1,29 @@ +{ + "auth_model": "jwt", + "auth_details": "HS256 \u2014 key from vault\ttab\nnewline \"quoted\" back\\slash", + "crypto_usage": [ + { + "algorithm": "AES", + "key_size": 256, + "mode": "GCM", + "usage_context": "at-rest", + "is_weak": false + }, + { + "algorithm": "MD5", + "key_size": null, + "mode": null, + "usage_context": null, + "is_weak": true + } + ], + "framework_security": [ + "CsrfViewMiddleware", + "" + ], + "security_headers": [], + "deployment_signals": [ + "k8s ingress & mTLS", + "control chars: \u0000\u001f\u007f\u0085" + ] +} \ No newline at end of file diff --git a/go/internal/pyfmt/testdata/golden/dumps_edge_cases_compact.txt b/go/internal/pyfmt/testdata/golden/dumps_edge_cases_compact.txt new file mode 100644 index 0000000..e434a7d --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/dumps_edge_cases_compact.txt @@ -0,0 +1 @@ +{"bools": [true, false], "empty_dict": {}, "empty_list": [], "escapes": "quote\" backslash\\ tab\t newline\n cr\r ff\f bs\b & / slash", "floats": [1.0, 0.5, 1e-05, -0.0, 1e+16, 1000000000000000.0, 3.141592653589793, 0.1], "ints": [0, -1, 1234567890123456789], "nested": {"a": [{"b": {"c": [1, 2.5, null]}}], "z": 1}, "none": null, "sort_check": {"C": 3, "_": 4, "a": 2, "b": 1, "\u00c9": 5}, "unicode": "h\u00e9llo \u2014 \u4e16\u754c \ud83d\ude80 \u007f"} \ No newline at end of file diff --git a/go/internal/pyfmt/testdata/golden/dumps_edge_cases_indent2.txt b/go/internal/pyfmt/testdata/golden/dumps_edge_cases_indent2.txt new file mode 100644 index 0000000..d0f67d6 --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/dumps_edge_cases_indent2.txt @@ -0,0 +1,47 @@ +{ + "bools": [ + true, + false + ], + "empty_dict": {}, + "empty_list": [], + "escapes": "quote\" backslash\\ tab\t newline\n cr\r ff\f bs\b & / slash", + "floats": [ + 1.0, + 0.5, + 1e-05, + -0.0, + 1e+16, + 1000000000000000.0, + 3.141592653589793, + 0.1 + ], + "ints": [ + 0, + -1, + 1234567890123456789 + ], + "nested": { + "a": [ + { + "b": { + "c": [ + 1, + 2.5, + null + ] + } + } + ], + "z": 1 + }, + "none": null, + "sort_check": { + "C": 3, + "_": 4, + "a": 2, + "b": 1, + "\u00c9": 5 + }, + "unicode": "h\u00e9llo \u2014 \u4e16\u754c \ud83d\ude80 \u007f" +} \ No newline at end of file diff --git a/go/internal/pyfmt/testdata/golden/model_dump_json_floats.json b/go/internal/pyfmt/testdata/golden/model_dump_json_floats.json new file mode 100644 index 0000000..4d42061 --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/model_dump_json_floats.json @@ -0,0 +1,24 @@ +{ + "-0.0": "{\"v\":-0.0}", + "-12.75": "{\"v\":-12.75}", + "0": "{\"v\":0.0}", + "0.0001": "{\"v\":0.0001}", + "0.1": "{\"v\":0.1}", + "0.3333333333333333": "{\"v\":0.3333333333333333}", + "1": "{\"v\":1.0}", + "1.23e-5": "{\"v\":0.0000123}", + "1.23e-6": "{\"v\":1.23e-6}", + "1.5": "{\"v\":1.5}", + "1e-3": "{\"v\":0.001}", + "1e-5": "{\"v\":0.00001}", + "1e-6": "{\"v\":1e-6}", + "1e-7": "{\"v\":1e-7}", + "1e15": "{\"v\":1000000000000000.0}", + "1e16": "{\"v\":1e+16}", + "1e17": "{\"v\":1e+17}", + "1e22": "{\"v\":1e+22}", + "1e300": "{\"v\":1e+300}", + "2.5": "{\"v\":2.5}", + "5e-324": "{\"v\":5e-324}", + "9.9e15": "{\"v\":9900000000000000.0}" +} diff --git a/go/internal/pyfmt/testdata/golden/model_dump_json_models.json b/go/internal/pyfmt/testdata/golden/model_dump_json_models.json new file mode 100644 index 0000000..a840f2e --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/model_dump_json_models.json @@ -0,0 +1,5 @@ +{ + "defaults": "{\"name\":\"\",\"ratio\":-0.0,\"count\":-5,\"flag\":false,\"opt\":null,\"items\":[],\"inner\":null,\"mapping\":{}}", + "rich": "{\"name\":\"h\u00e9llo <&> \\\" \\\\ \\t \ud83d\ude00 \\u0001\",\"ratio\":1.0,\"count\":3,\"flag\":true,\"opt\":null,\"items\":[\"a\",\"b\"],\"inner\":{\"x\":1000000000000000.0,\"y\":0},\"mapping\":{\"a\":2.0,\"b\":0.5}}", + "small_float": "{\"name\":\"x\",\"ratio\":0.00001,\"count\":0,\"flag\":false,\"opt\":null,\"items\":[],\"inner\":null,\"mapping\":{}}" +} diff --git a/go/internal/pyfmt/testdata/models_fixture.json b/go/internal/pyfmt/testdata/models_fixture.json new file mode 100644 index 0000000..9170f27 --- /dev/null +++ b/go/internal/pyfmt/testdata/models_fixture.json @@ -0,0 +1,169 @@ +{ + "_readme": [ + "Input fixture for the pyfmt.Dumps parity tests. Each key except this one and", + "'edge_cases' is a pydantic class name; its value is the constructor kwargs.", + "go/scripts/gen_golden.py builds the model, calls model_dump(), and writes", + "json.dumps(dump, indent=2) / json.dumps(dump) into testdata/golden/. The Go", + "test unmarshals the SAME sub-object into the identically named Go struct and", + "compares pyfmt.Dumps(v, 2) / pyfmt.DumpsCompact(v) byte for byte.", + "Every field is supplied explicitly so the comparison exercises Dumps, not the", + "two default-seeding implementations." + ], + "ArchitectureMap": { + "app_type": "web_api", + "modules": [ + { + "name": "auth", + "path": "app/auth/service.py", + "language": "python", + "description": "Session issuance — naïve refresh, 世界 emoji 🚀", + "dependencies": ["jwt", "redis"] + }, + { + "name": "empty_deps", + "path": "app/empty.py", + "language": "go", + "description": null, + "dependencies": [] + } + ], + "entry_points": [ + { + "kind": "http", + "identifier": "login", + "file_path": "app/api/login.py", + "line": 42, + "method": "POST", + "route": "/v1/login", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "migrate", + "file_path": "tools/migrate.py", + "line": 0, + "method": null, + "route": null, + "auth_required": null + } + ], + "trust_boundaries": [ + { + "name": "edge", + "source_zone": "internet", + "target_zone": "app", + "description": "TLS terminates at the ingress & waf", + "enforcement": [] + } + ], + "services": [ + { + "name": "postgres", + "service_type": "database", + "endpoint": "postgres://db:5432", + "purpose": null, + "auth_mechanism": "password" + } + ], + "api_surface": [ + { + "method": "GET", + "path": "/v1/users/{id}", + "handler": "UserController.show", + "file_path": "app/api/users.py", + "line": 17, + "auth_required": true, + "rate_limited": null + } + ] + }, + "DependencyReport": { + "sbom": [ + {"name": "django", "version": "4.2.1", "ecosystem": "pypi", "direct": true, "license": "BSD-3-Clause"}, + {"name": "urllib3", "version": "1.26.5", "ecosystem": "pypi", "direct": false, "license": null} + ], + "known_cves": [ + { + "cve_id": "CVE-2023-0001", + "package": "django", + "installed_version": "4.2.1", + "fixed_version": "4.2.5", + "cvss_v4_score": 1.0, + "epss_score": 0.5, + "direct": true, + "reachable": true + }, + { + "cve_id": "CVE-2023-0002", + "package": "urllib3", + "installed_version": "1.26.5", + "fixed_version": null, + "cvss_v4_score": 1e-05, + "epss_score": 1e16, + "direct": false, + "reachable": null + }, + { + "cve_id": "CVE-2023-0003", + "package": "zlib", + "installed_version": "1.2.11", + "fixed_version": "1.2.12", + "cvss_v4_score": 1000000000000000.0, + "epss_score": -0.0, + "direct": false, + "reachable": false + } + ], + "outdated": [], + "direct_count": 1, + "transitive_count": 0 + }, + "SecurityContext": { + "auth_model": "jwt", + "auth_details": "HS256 — key from vault\ttab\nnewline \"quoted\" back\\slash", + "crypto_usage": [ + {"algorithm": "AES", "key_size": 256, "mode": "GCM", "usage_context": "at-rest", "is_weak": false}, + {"algorithm": "MD5", "key_size": null, "mode": null, "usage_context": null, "is_weak": true} + ], + "framework_security": ["CsrfViewMiddleware", ""], + "security_headers": [], + "deployment_signals": ["k8s ingress & mTLS", "control chars: \u0000\u001f\u007f…"] + }, + "ConfigReport": { + "secrets": [ + { + "id": "secret-01", + "secret_type": "aws_access_key", + "file_path": "config/prod.yaml", + "line": 12, + "match": "AKIA****0001", + "confidence": "high", + "is_test_value": false + } + ], + "misconfigs": [ + { + "id": "misconfig-01", + "category": "tls", + "file_path": "config/prod.yaml", + "line": null, + "key": "SSL_VERIFY", + "value": "false", + "risk": "critical", + "remediation": null + } + ] + }, + "edge_cases": { + "empty_list": [], + "empty_dict": {}, + "none": null, + "bools": [true, false], + "ints": [0, -1, 1234567890123456789], + "floats": [1.0, 0.5, 1e-05, -0.0, 1e16, 1000000000000000.0, 3.141592653589793, 0.1], + "unicode": "héllo — 世界 🚀 \u007f", + "escapes": "quote\" backslash\\ tab\t newline\n cr\r ff\f bs\b & / slash", + "nested": {"a": [{"b": {"c": [1, 2.5, null]}}], "z": 1}, + "sort_check": {"b": 1, "a": 2, "C": 3, "_": 4, "É": 5} + } +} diff --git a/go/internal/reasoners/doc.go b/go/internal/reasoners/doc.go new file mode 100644 index 0000000..de3f5b0 --- /dev/null +++ b/go/internal/reasoners/doc.go @@ -0,0 +1,70 @@ +// Package reasoners ports src/sec_af/reasoners/{__init__,recon,hunt,prove}.py — +// the thin adapter layer between the control plane's reasoner surface and the +// in-process agent functions under internal/agents. +// +// Python builds that surface with a module-level router: +// +// router = AgentRouter(tags=["security", "audit", "red-team"]) # __init__.py +// +// @router.reasoner() +// async def run_architecture_mapper(repo_path: str) -> dict[str, Any]: +// router.note("Architecture mapper starting", tags=["recon", "architecture"]) +// result = await _run_architecture_mapper(router, repo_path) +// return result.model_dump() +// +// Every adapter does the same four things, and this package reproduces them one +// for one: +// +// 1. emit the reasoner's "X starting" note with its exact tags (a few carry +// none — see NameRunLogicBugsHunter and NameRunCWEExpansion); +// 2. materialize the untyped request body into the pydantic models the agent +// function expects (afx.Bind over a typed input struct whose json tags and +// defaults are the Python signature, plus the required-field checks pydantic +// performs — validate.go); +// 3. call the agent function; +// 4. return `result.model_dump()` as a map (afx.ToMap). +// +// # Input validation +// +// Step 2 is not the first thing a request meets. The Python SDK runs +// `Agent._validate_handler_input` over the body BEFORE the decorated function +// is entered, answering 422 for a null on a required parameter and COERCING +// scalars (`"50"` -> 50, `5` -> "5", `"yes"` -> true). The Go SDK has no such +// layer, so handler_input.go ports it and RegisterAll wraps every handler in +// it — internal/node does the same for `audit`. Without it the node accepted +// bodies Python 422s and rejected bodies Python accepts, on all 34 reasoners of +// the registered surface. +// +// # Registration +// +// RegisterAll mounts the 33 router reasoners onto an *agent.Router in the +// canonical DESIGN.md §3 order; internal/node mounts that router with +// agent.RouterOptions{Tags: RouterTags} (the AgentRouter(tags=...) equivalent) +// and registers the 34th reasoner, the externally driven `audit`, directly on +// the agent. The SDK's Agent keeps its reasoner table unexported and its +// discovery payload hardcodes an empty tag list, so RegisterAll returns its own +// ordered bookkeeping — the source of truth the parity test asserts (the same +// approach pr-af's Node.RegisteredNames takes). +// +// # Input schemas +// +// Python publishes a JSON Schema per reasoner, derived from the decorated +// function's signature. The Go SDK has no such derivation and would otherwise +// publish a placeholder, so the exact schemas the Python node registers are +// committed as testdata/python_input_schemas.json and replayed verbatim through +// InputSchema — including Python's own derivation quirks. input_schemas.go +// documents them and how to regenerate the capture. +// +// A SECOND capture, testdata/python_input_types.json, holds the raw +// `(annotation, default)` pairs the same signatures produce — the input +// `_validate_handler_input` runs on. The two are derived by different SDK code +// paths from the same source, and handler_input_test.go asserts they agree. +// +// # What is NOT here +// +// The `*_phase` bodies (the four reasoners that ARE the control-plane DAG) live +// in internal/phases; this package only binds their inputs and threads the node +// id. `run_cwe_expansion` likewise delegates to phases.RunCWEExpansion. Keeping +// the split means a phase can be tested without the registration layer and vice +// versa. +package reasoners diff --git a/go/internal/reasoners/handler_input.go b/go/internal/reasoners/handler_input.go new file mode 100644 index 0000000..fe1dfa0 --- /dev/null +++ b/go/internal/reasoners/handler_input.go @@ -0,0 +1,495 @@ +package reasoners + +import ( + "bytes" + _ "embed" + "encoding/json" + "fmt" + "math" + "reflect" + "strconv" + "strings" + + "github.com/Agent-Field/sec-af/go/internal/pyfmt" +) + +// handler_input.go ports the Python SDK's `Agent._validate_handler_input` +// (sdk/python/agentfield/agent.py:1146) — the layer that runs on EVERY reasoner +// request body before the handler function is entered, and whose failures the +// endpoint turns into `JSONResponse(status_code=422, ...)` (agent.py:3122-3134). +// +// # Why the port needs it at all +// +// The Go SDK has no equivalent: `handleExecute` decodes the body into +// map[string]any and hands it straight to the handler, and `InputSchema` is +// only ever published for discovery — nothing validates against it. Without +// this file the port's binding is `afx.Bind`, a bare JSON round trip, which +// diverges from Python in BOTH directions: +// +// {"findings": null, ...} Python 422; Go bound nil and +// on run_deduplicator returned an EMPTY HuntResult with 200 +// {"depth": 5} on recon_phase Python "5"; Go json.UnmarshalTypeError +// {"max_files_without_signal": "50"} Python 50; Go json.UnmarshalTypeError +// {"is_pr": "yes"} on audit Python True; Go json.UnmarshalTypeError +// +// Every producer INSIDE the pipeline emits correctly typed kwargs, so the +// divergence is only visible to a control-plane caller invoking one of the 34 +// registered reasoners directly — which is a first-class caller, not a +// hypothetical (validate.go says the same about hand-built payloads). +// +// This is a DIFFERENT layer from internal/phases' checked binders. Those are +// `Model.model_validate(payload)` for a nested pydantic model reached through a +// `.call`; this is the SDK's own parameter binding, one level above, and it has +// its own rules (scalar coercion, `str()` stringification) that pydantic does +// not share. +// +// # The rules, and the two Python quirks they preserve +// +// For each declared parameter, in signature order (the first offending one +// raises): +// +// absent + has default -> the default (Go: leave the key absent so +// the input struct's +// UnmarshalJSON seeds it) +// absent + required -> "Missing required field: x" [NOT reproduced] +// null + has default -> the default (same treatment as absent) +// null + required -> "Field 'x' cannot be None" +// present -> int(v) / float(v) / str(v) / bool-rules / dict-check / list-check +// / pass-through, per the parameter's annotation +// +// QUIRK 1 — a PEP 604 union is never unwrapped. agent.py:1193-1200 tests +// `expected_type.__origin__ is Union`, and `int | None` is a `types.UnionType` +// with NO `__origin__`, so `max_provers: int | None` reaches the trailing +// pass-through branch: Python leaves `{"max_provers": "5"}` as the STRING "5" +// rather than coercing it to 5. Captured as kind "any" and reproduced. +// +// QUIRK 2 — `bool` is checked AFTER `int`, but with `is`, and `bool is not int`, +// so a bool-annotated field takes the bool branch ("yes"/"1"/"true" -> True, +// anything else stringy -> False) rather than int(). +// +// # The one rule deliberately NOT reproduced +// +// "Missing required field: x". The Go input structs cannot express "this key +// was absent" for a scalar, and the port has always documented the missing +// argument as a divergence (see HunterInput). An absent key keeps the Go zero +// value and the pipeline carries on, where Python answers 422. +// +// # Provenance +// +// testdata/python_input_types.json is generated from the LIVE registry the SDK +// validates against — `app._reasoner_registry[name].input_types` — by +// go/scripts/gen_input_types.py. See that script for the command. A Go-side +// change must never edit the fixture to make a test pass. + +// pythonInputTypesJSON is the committed capture, embedded so the specs ship in +// the binary rather than being read from disk at boot. +// +//go:embed testdata/python_input_types.json +var pythonInputTypesJSON []byte + +// paramKind names which branch of `_validate_handler_input` a parameter takes. +type paramKind string + +const ( + kindStr paramKind = "str" + kindInt paramKind = "int" + kindFloat paramKind = "float" + kindBool paramKind = "bool" + kindDict paramKind = "dict" + kindList paramKind = "list" + // kindAny is the trailing `else: result[name] = value` pass-through. It + // covers every `X | None` parameter in this node (see QUIRK 1) as well as + // `Any | None` (hunt_phase's ai_gate). + kindAny paramKind = "any" +) + +// handlerParam is one row of the capture. +type handlerParam struct { + Name string `json:"name"` + Kind paramKind `json:"kind"` + Annotation string `json:"annotation"` + Required bool `json:"required"` + Default any `json:"default"` +} + +// handlerSpecs maps reasoner id -> its ordered parameter list. Parsed once, at +// package init, so a corrupt or unknown-kind fixture fails the process +// immediately instead of at the first request. +var handlerSpecs = mustParseInputTypes(pythonInputTypesJSON) + +func mustParseInputTypes(raw []byte) map[string][]handlerParam { + var decoded map[string][]handlerParam + if err := json.Unmarshal(raw, &decoded); err != nil { + panic(fmt.Sprintf("reasoners: testdata/python_input_types.json is not a JSON object of parameter lists: %v", err)) + } + if len(decoded) == 0 { + panic("reasoners: testdata/python_input_types.json is empty") + } + for name, params := range decoded { + for _, p := range params { + switch p.Kind { + case kindStr, kindInt, kindFloat, kindBool, kindDict, kindList, kindAny: + default: + panic(fmt.Sprintf("reasoners: %s.%s has unknown kind %q", name, p.Name, p.Kind)) + } + } + } + return decoded +} + +// handlerSpecFor returns the captured parameter list for a reasoner, panicking +// on a name the capture does not know — see ValidateHandlerInput. +func handlerSpecFor(name string) []handlerParam { + params, ok := handlerSpecs[name] + if !ok { + panic(fmt.Sprintf( + "reasoners: no input types for reasoner %q in testdata/python_input_types.json; "+ + "regenerate the capture (see handler_input.go) after changing the Python surface", name)) + } + return params +} + +// HandlerInputError is the Go shape of `_HandlerInputError`, the ValueError +// subclass `_validate_handler_input` raises. Its message is the SDK-constructed +// `safe_message` Python puts in the 422 body verbatim. +type HandlerInputError struct{ Message string } + +func (e *HandlerInputError) Error() string { return e.Message } + +// ValidateHandlerInput is `_validate_handler_input(data, input_types)` for the +// reasoner called name: it returns the validated, coerced keyword map to bind, +// or a *HandlerInputError. +// +// Like Python's, the returned map contains ONLY declared parameters — an +// undeclared key in the body is dropped rather than forwarded. +// +// It PANICS on a name the capture does not know, for the same reason +// InputSchema does: every registration goes through here, so adding a reasoner +// without regenerating the capture fails at boot and in every test instead of +// silently shipping an unvalidated handler. +func ValidateHandlerInput(name string, data map[string]any) (map[string]any, error) { + params := handlerSpecFor(name) + out := make(map[string]any, len(params)) + for _, p := range params { + value, present := data[p.Name] + if !present { + // Python: `result[name] = default`, or + // `raise _HandlerInputError("Missing required field: x")`. The Go + // input structs seed the same defaults from an ABSENT key, and the + // required case is the documented missing-argument divergence. + continue + } + if isPyNone(value) { + if !p.Required { + // Python: `if default is not ...: result[name] = default`. An + // explicit null on a defaulted parameter yields the DEFAULT, + // not None — which is what leaving the key absent produces + // here, since every input struct's UnmarshalJSON seeds exactly + // the Python defaults. + continue + } + return nil, &HandlerInputError{Message: "Field '" + p.Name + "' cannot be None"} + } + coerced, err := coerceParam(p, value) + if err != nil { + return nil, err + } + out[p.Name] = coerced + } + return out, nil +} + +// isPyNone is `value is None` for the shapes a request map can hold. +// +// An untyped nil is what encoding/json leaves for a JSON `null`, which is the +// only case on the wire. A TYPED nil (a nil *int, []string or map) reaches here +// only from an in-process Go caller building the kwargs directly; it marshals +// to `null`, so CPython would see None for it too, and treating it as None +// keeps the two paths from disagreeing. +func isPyNone(value any) bool { + if value == nil { + return true + } + rv := reflect.ValueOf(value) + switch rv.Kind() { + case reflect.Pointer, reflect.Interface, reflect.Map, reflect.Slice: + return rv.IsNil() + } + return false +} + +// coerceParam applies one parameter's branch of agent.py:1202-1236. +func coerceParam(p handlerParam, value any) (any, error) { + // Python wraps the whole coercion in + // `except (ValueError, TypeError): raise _HandlerInputError(f"Invalid value for field '{name}'")`, + // deliberately dropping the inner exception's text. Every failure below is + // one of those two, so they all surface with that message — except the + // dict/list shape checks, which raise _HandlerInputError directly and keep + // their own wording. + invalid := &HandlerInputError{Message: "Invalid value for field '" + p.Name + "'"} + + switch p.Kind { + case kindInt: + n, ok := pyInt(value) + if !ok { + return nil, invalid + } + return n, nil + case kindFloat: + f, ok := pyFloat(value) + if !ok { + return nil, invalid + } + return f, nil + case kindStr: + return pyStr(value), nil + case kindBool: + return pyBool(value), nil + case kindDict: + if reflect.ValueOf(value).Kind() != reflect.Map { + return nil, &HandlerInputError{Message: "Field '" + p.Name + "' must be a dict"} + } + return value, nil + case kindList: + if kind := reflect.ValueOf(value).Kind(); kind != reflect.Slice && kind != reflect.Array { + return nil, &HandlerInputError{Message: "Field '" + p.Name + "' must be a list"} + } + return value, nil + } + // kindAny: `else: result[name] = value`. + return value, nil +} + +// pyInt is `int(value)` for the value kinds a decoded body can hold. +// +// bool -> 1 / 0 +// number -> TRUNCATED toward zero (int(5.7) == 5, int(-5.7) == -5) +// string -> the CPython int() grammar (see parsePyInt) +// anything else (list, dict) -> TypeError +func pyInt(value any) (int, bool) { + switch v := value.(type) { + case bool: + if v { + return 1, true + } + return 0, true + case string: + return parsePyInt(v) + case json.Number: + return parsePyInt(v.String()) + } + rv := reflect.ValueOf(value) + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return int(rv.Int()), true + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return int(rv.Uint()), true + case reflect.Float32, reflect.Float64: + f := rv.Float() + if math.IsNaN(f) || math.IsInf(f, 0) { + // Python raises ValueError / OverflowError; JSON cannot express + // either literal, so this is only reachable from a Go caller. + return 0, false + } + return int(f), true + } + return 0, false +} + +// pyFloat is `float(value)`. +// +// Unreachable from the current surface — no reasoner parameter is annotated +// `float` (the only float-ish one, audit's `max_cost_usd: float | None`, is a +// PEP 604 union and therefore kind "any"). Implemented anyway so a future +// signature is handled rather than mis-handled, and pinned by a unit test. +func pyFloat(value any) (float64, bool) { + switch v := value.(type) { + case bool: + if v { + return 1, true + } + return 0, true + case string: + return parsePyFloat(v) + case json.Number: + return parsePyFloat(v.String()) + } + rv := reflect.ValueOf(value) + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return float64(rv.Int()), true + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return float64(rv.Uint()), true + case reflect.Float32, reflect.Float64: + return rv.Float(), true + } + return 0, false +} + +// pyStr is `str(value)`. +// +// DOCUMENTED RESIDUAL, the wire-number one afx.PyTypeName carries too: Go's +// encoding/json decodes every JSON number to float64, so an INTEGER literal is +// indistinguishable from a float here. `str()` of an integral value is rendered +// the int way ("5"), which is right for `{"depth": 5}` — the case that +// matters — and wrong for the pathological `{"depth": 5.0}`, where Python says +// "5.0". The same ambiguity applies inside a list/dict rendered through +// pyfmt.Str, which additionally sorts map keys where CPython uses insertion +// order. +func pyStr(value any) string { + switch v := value.(type) { + case string: + return v + case bool: + if v { + return "True" + } + return "False" + case json.Number: + return v.String() + case float32: + return pyStrFloat(float64(v)) + case float64: + return pyStrFloat(v) + } + return pyfmt.Str(wireInts(value)) +} + +// wireInts rewrites every INTEGRAL float64 in a decoded JSON tree as an int, so +// pyfmt.Repr spells it the way CPython would have: `str([1, 2])` is "[1, 2]", +// not "[1.0, 2.0]", because json.loads produced ints. Same heuristic (and same +// residual for an explicit `1.0` literal) as pyStrFloat. +func wireInts(value any) any { + switch v := value.(type) { + case float64: + if !math.IsNaN(v) && !math.IsInf(v, 0) && v == math.Trunc(v) && math.Abs(v) < 1e15 { + return int64(v) + } + case []any: + out := make([]any, len(v)) + for i, item := range v { + out[i] = wireInts(item) + } + return out + case map[string]any: + out := make(map[string]any, len(v)) + for key, item := range v { + out[key] = wireInts(item) + } + return out + } + return value +} + +// pyStrFloat renders a decoded JSON number the way `str()` renders whichever +// Python type json.loads would have produced for it: an integral value is an +// int (no ".0"), anything else is repr(float). +func pyStrFloat(f float64) string { + if !math.IsNaN(f) && !math.IsInf(f, 0) && f == math.Trunc(f) && math.Abs(f) < 1e15 { + return strconv.FormatInt(int64(f), 10) + } + return pyfmt.FormatFloat(f) +} + +// pyBool ports agent.py:1207-1212: +// +// if isinstance(value, bool): value +// elif isinstance(value, str): value.lower() in ("true", "1", "yes") +// else: bool(value) +// +// Note the string rule is a MEMBERSHIP test, not a parse: "no", "0", "false" +// and "" are all False, and so is "TRUE " with a trailing space. +func pyBool(value any) bool { + switch v := value.(type) { + case bool: + return v + case string: + switch strings.ToLower(v) { + case "true", "1", "yes": + return true + } + return false + } + return pyTruthy(value) +} + +// pyTruthy is `bool(x)` for the remaining kinds: a zero number, an empty +// container and None are falsy. +func pyTruthy(value any) bool { + if value == nil { + return false + } + rv := reflect.ValueOf(value) + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return rv.Int() != 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return rv.Uint() != 0 + case reflect.Float32, reflect.Float64: + return rv.Float() != 0 + case reflect.Slice, reflect.Array, reflect.Map, reflect.String: + return rv.Len() != 0 + case reflect.Pointer, reflect.Interface: + return !rv.IsNil() + } + return true +} + +// parsePyInt implements CPython's `int(str)` grammar: surrounding whitespace, +// an optional sign, decimal digits, and single underscores BETWEEN digits +// (int("1_0") == 10, while "1__0", "_1" and "1_" all raise). A float spelling +// ("50.5"), a base prefix ("0x10") and the empty string all raise. +// +// Residual: CPython also accepts unicode whitespace and unicode decimal digits; +// only ASCII is handled here. +func parsePyInt(s string) (int, bool) { + digits, ok := stripPyUnderscores(strings.TrimSpace(s)) + if !ok { + return 0, false + } + n, err := strconv.ParseInt(digits, 10, 64) + if err != nil { + return 0, false + } + return int(n), true +} + +// parsePyFloat implements CPython's `float(str)`: whitespace, an optional sign, +// a decimal or exponent form, or the "inf"/"infinity"/"nan" spellings in any +// case; underscores are allowed between digits. A hex float ("0x1p-2") is +// accepted by strconv and REJECTED by Python, so it is rejected here. +func parsePyFloat(s string) (float64, bool) { + text, ok := stripPyUnderscores(strings.TrimSpace(s)) + if !ok { + return 0, false + } + if strings.ContainsAny(text, "xX") { + return 0, false + } + f, err := strconv.ParseFloat(text, 64) + if err != nil { + return 0, false + } + return f, true +} + +// stripPyUnderscores removes PEP 515 digit separators, rejecting the placements +// CPython rejects: an underscore must sit BETWEEN two digits. +func stripPyUnderscores(s string) (string, bool) { + if !strings.Contains(s, "_") { + return s, true + } + var b bytes.Buffer + for i := 0; i < len(s); i++ { + if s[i] != '_' { + b.WriteByte(s[i]) + continue + } + if i == 0 || i+1 >= len(s) || !isASCIIDigit(s[i-1]) || !isASCIIDigit(s[i+1]) { + return "", false + } + } + return b.String(), true +} + +func isASCIIDigit(c byte) bool { return c >= '0' && c <= '9' } diff --git a/go/internal/reasoners/handler_input_test.go b/go/internal/reasoners/handler_input_test.go new file mode 100644 index 0000000..4b212f6 --- /dev/null +++ b/go/internal/reasoners/handler_input_test.go @@ -0,0 +1,560 @@ +package reasoners + +// Tests for handler_input.go — the port of the Python SDK's +// `Agent._validate_handler_input`, which runs on every reasoner request body. +// +// Validation contract (behaviour, MEASURED by calling the real +// `app._validate_handler_input(body, app._reasoner_registry[name].input_types)` +// on the pinned interpreter; every `want` below is that call's output): +// +// - an explicit null on a REQUIRED parameter is rejected with +// `Field 'x' cannot be None`, which the endpoint answers 422 — the case +// that used to bind nil in Go and return an empty result with 200; +// - an explicit null on a DEFAULTED parameter yields the Python DEFAULT +// (depth null -> "standard", max_files_without_signal null -> 30), NOT None; +// - scalars are COERCED, not type-checked: int(v), float(v), str(v) and the +// bool spellings, so `{"depth": 5}` is "5" and +// `{"max_files_without_signal": "50"}` is 50 — both of which afx.Bind alone +// rejected outright; +// - a coercion failure is `Invalid value for field 'x'`, with the inner +// exception's text deliberately dropped; +// - a dict/list-annotated parameter is SHAPE-checked, with its own wording: +// `Field 'x' must be a dict` / `must be a list`; +// - a PEP 604 `X | None` parameter is NEVER unwrapped (types.UnionType has no +// __origin__), so `{"max_provers": "5"}` stays the STRING "5"; +// - an undeclared key is dropped; +// - the one divergence: an ABSENT required key is a 422 in Python +// ("Missing required field: x") and is accepted here, matching the +// missing-argument divergence the port has always documented. + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "sort" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + + "github.com/Agent-Field/sec-af/go/internal/appx" +) + +// TestValidateHandlerInputMatchesPython replays the measured cases. `want` is +// the SUBSET of Python's validated kwargs that the Go result must carry +// verbatim; keys Python fills from a default and Go leaves absent (so the input +// struct's UnmarshalJSON seeds them) are covered by TestDefaultsAreTheGoSeeds. +func TestValidateHandlerInputMatchesPython(t *testing.T) { + for _, tc := range []struct { + name string + reasoner string + body map[string]any + // want is the SUBSET of Python's validated kwargs the Go result must + // carry verbatim; absent is the set of body keys that must NOT survive. + want map[string]any + absent []string + wantErr string + }{ + // --- null on a required collection: the 200-with-empty-result bug --- + {"deduplicator findings null", NameRunDeduplicator, + map[string]any{"findings": nil, "recon_context": map[string]any{}, "repo_path": "/r"}, + nil, nil, "Field 'findings' cannot be None"}, + {"remediation_phase verified_findings null", NameRemediationPhase, + map[string]any{"repo_path": "/r", "verified_findings": nil}, + nil, nil, "Field 'verified_findings' cannot be None"}, + {"cross_service services null", NameRunCrossServiceAnalyzer, + map[string]any{"repo_path": "/r", "services": nil, "findings_summary": "s", "depth": "standard"}, + nil, nil, "Field 'services' cannot be None"}, + {"recon_phase repo_path null", NameReconPhase, + map[string]any{"repo_path": nil}, nil, nil, "Field 'repo_path' cannot be None"}, + {"audit repo_url null", NameAudit, + map[string]any{"repo_url": nil}, nil, nil, "Field 'repo_url' cannot be None"}, + + // --- an empty list is NOT null and is accepted --- + {"deduplicator findings empty", NameRunDeduplicator, + map[string]any{"findings": []any{}, "recon_context": map[string]any{}, "repo_path": "/r"}, + map[string]any{"findings": []any{}, "recon_context": map[string]any{}, "repo_path": "/r"}, nil, ""}, + + // --- shape checks keep their own wording --- + {"deduplicator findings not a list", NameRunDeduplicator, + map[string]any{"findings": "nope", "recon_context": map[string]any{}, "repo_path": "/r"}, + nil, nil, "Field 'findings' must be a list"}, + {"hunter recon_context not a dict", NameRunInjectionHunter, + map[string]any{"repo_path": "/r", "recon_context": []any{}, "depth": "standard"}, + nil, nil, "Field 'recon_context' must be a dict"}, + + // --- str() coercion --- + {"recon_phase depth from a number", NameReconPhase, + map[string]any{"repo_path": "/r", "depth": float64(5)}, + map[string]any{"repo_path": "/r", "depth": "5"}, nil, ""}, + {"recon_phase depth from a bool", NameReconPhase, + map[string]any{"repo_path": "/r", "depth": true}, + map[string]any{"repo_path": "/r", "depth": "True"}, nil, ""}, + {"recon_phase depth from a list", NameReconPhase, + map[string]any{"repo_path": "/r", "depth": []any{float64(1), float64(2)}}, + map[string]any{"repo_path": "/r", "depth": "[1, 2]"}, nil, ""}, + {"audit repo_url from a number", NameAudit, + map[string]any{"repo_url": float64(5)}, + map[string]any{"repo_url": "5"}, nil, ""}, + + // --- int() coercion --- + {"hunter max_files from a string", NameRunInjectionHunter, + map[string]any{"repo_path": "/r", "recon_context": map[string]any{}, "depth": "standard", + "max_files_without_signal": "50"}, + map[string]any{"max_files_without_signal": 50}, nil, ""}, + {"hunter max_files truncates", NameRunInjectionHunter, + map[string]any{"repo_path": "/r", "recon_context": map[string]any{}, "depth": "standard", + "max_files_without_signal": 30.9}, + map[string]any{"max_files_without_signal": 30}, nil, ""}, + {"hunter max_files from a bool", NameRunInjectionHunter, + map[string]any{"repo_path": "/r", "recon_context": map[string]any{}, "depth": "standard", + "max_files_without_signal": true}, + map[string]any{"max_files_without_signal": 1}, nil, ""}, + {"hunter max_files unparseable", NameRunInjectionHunter, + map[string]any{"repo_path": "/r", "recon_context": map[string]any{}, "depth": "standard", + "max_files_without_signal": "abc"}, + nil, nil, "Invalid value for field 'max_files_without_signal'"}, + {"hunt_phase max_concurrent_hunters from a string", NameHuntPhase, + map[string]any{"repo_path": "/r", "recon_context": map[string]any{}, + "max_concurrent_hunters": "2"}, + map[string]any{"max_concurrent_hunters": 2}, nil, ""}, + + // --- bool() coercion (agent.py's membership test, not a parse) --- + {"audit is_pr yes", NameAudit, map[string]any{"repo_url": "u", "is_pr": "yes"}, + map[string]any{"is_pr": true}, nil, ""}, + {"audit is_pr no", NameAudit, map[string]any{"repo_url": "u", "is_pr": "no"}, + map[string]any{"is_pr": false}, nil, ""}, + {"audit is_pr zero", NameAudit, map[string]any{"repo_url": "u", "is_pr": float64(0)}, + map[string]any{"is_pr": false}, nil, ""}, + + // --- null on a DEFAULTED parameter is the default, not None --- + {"recon_phase depth null", NameReconPhase, + map[string]any{"repo_path": "/r", "depth": nil}, + map[string]any{"repo_path": "/r"}, []string{"depth"}, ""}, + {"hunter max_files null", NameRunInjectionHunter, + map[string]any{"repo_path": "/r", "recon_context": map[string]any{}, "depth": "standard", + "max_files_without_signal": nil}, + map[string]any{"repo_path": "/r"}, []string{"max_files_without_signal"}, ""}, + {"audit is_pr null", NameAudit, map[string]any{"repo_url": "u", "is_pr": nil}, + map[string]any{"repo_url": "u"}, []string{"is_pr"}, ""}, + + // --- QUIRK 1: a PEP 604 union is never unwrapped, so no coercion --- + {"prove_phase max_provers stays a string", NameProvePhase, + map[string]any{"repo_path": "/r", "hunt_result": map[string]any{}, "max_provers": "5"}, + map[string]any{"max_provers": "5"}, nil, ""}, + + // --- an undeclared key is dropped --- + {"recon_phase drops an unknown key", NameReconPhase, + map[string]any{"repo_path": "/r", "unknown_key": float64(1)}, + map[string]any{"repo_path": "/r"}, []string{"unknown_key"}, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := ValidateHandlerInput(tc.reasoner, tc.body) + if tc.wantErr != "" { + if err == nil { + t.Fatalf("accepted %v, want %q", tc.body, tc.wantErr) + } + var hie *HandlerInputError + if !errors.As(err, &hie) { + t.Fatalf("error = %T, want *HandlerInputError", err) + } + if err.Error() != tc.wantErr { + t.Errorf("error = %q, want %q", err.Error(), tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("ValidateHandlerInput: %v", err) + } + for key, want := range tc.want { + if !reflect.DeepEqual(got[key], want) { + t.Errorf("%s = %#v, want %#v", key, got[key], want) + } + } + for _, key := range tc.absent { + if value, present := got[key]; present { + t.Errorf("%s survived as %#v; it must be absent so the input "+ + "struct's seeded default applies", key, value) + } + } + }) + } +} + +// TestValidateHandlerInputDropsNullOnDefaultedParams is the second half of the +// null rule, stated where it is actually observable: a null on a defaulted +// parameter must leave the KEY ABSENT so the input struct's UnmarshalJSON seeds +// the Python default. Keeping the null instead would zero the field. +func TestValidateHandlerInputDropsNullOnDefaultedParams(t *testing.T) { + // `depth` has NO default in the hunter signature, so a null there is an + // error rather than a fallback — only the defaulted parameter is nulled. + got, err := ValidateHandlerInput(NameRunInjectionHunter, map[string]any{ + "repo_path": "/r", "recon_context": map[string]any{}, "depth": "quick", + "max_files_without_signal": nil, + }) + if err != nil { + t.Fatalf("ValidateHandlerInput: %v", err) + } + if _, present := got["max_files_without_signal"]; present { + t.Errorf("max_files_without_signal survived as %#v; it must be absent so "+ + "NewHunterInput's 30 applies", got["max_files_without_signal"]) + } + + raw, err := json.Marshal(got) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var in HunterInput + if err := json.Unmarshal(raw, &in); err != nil { + t.Fatalf("bind: %v", err) + } + if in.MaxFilesWithoutSignal != DefaultMaxFilesWithoutSignal { + t.Errorf("max_files_without_signal = %d, want the Python default %d", + in.MaxFilesWithoutSignal, DefaultMaxFilesWithoutSignal) + } + if in.Depth != "quick" { + t.Errorf("depth = %q, want quick", in.Depth) + } +} + +// TestDefaultsAreTheGoSeeds cross-checks the two captures against the Go input +// structs: every DEFAULT the Python signature declares must be the value the +// corresponding Go constructor seeds, because ValidateHandlerInput reproduces +// "absent or null -> default" by leaving the key out and letting the seed win. +// A drifted Go default would otherwise be invisible. +func TestDefaultsAreTheGoSeeds(t *testing.T) { + for _, tc := range []struct { + reasoner string + seed any + }{ + {NameRunInjectionHunter, NewHunterInput()}, + {NameReconPhase, NewReconPhaseInput()}, + {NameHuntPhase, NewHuntPhaseInput()}, + {NameProvePhase, NewProvePhaseInput()}, + {NameRemediationPhase, NewRemediationPhaseInput()}, + } { + t.Run(tc.reasoner, func(t *testing.T) { + raw, err := json.Marshal(tc.seed) + if err != nil { + t.Fatalf("marshal seed: %v", err) + } + var seeded map[string]any + if err := json.Unmarshal(raw, &seeded); err != nil { + t.Fatalf("decode seed: %v", err) + } + for _, p := range handlerSpecFor(tc.reasoner) { + if p.Required { + continue + } + got, ok := seeded[p.Name] + if !ok { + t.Errorf("%s: the Go input struct has no %q field", tc.reasoner, p.Name) + continue + } + if !sameJSONValue(got, p.Default) { + t.Errorf("%s.%s seed = %#v, Python default = %#v", tc.reasoner, p.Name, got, p.Default) + } + } + }) + } +} + +// sameJSONValue compares a decoded Go seed with a decoded Python default, +// tolerating the int/float64 spread JSON decoding introduces. +func sameJSONValue(got, want any) bool { + gotNum, gotIsNum := got.(float64) + wantNum, wantIsNum := want.(float64) + if gotIsNum && wantIsNum { + return gotNum == wantNum + } + return reflect.DeepEqual(got, want) +} + +// TestInputTypesCoverExactlyTheNodeSurface is the drift guard: the capture +// carries the 33 router reasoners plus `audit`, and nothing else. +func TestInputTypesCoverExactlyTheNodeSurface(t *testing.T) { + want := append([]string{NameAudit}, Names...) + sort.Strings(want) + + got := make([]string, 0, len(handlerSpecs)) + for name := range handlerSpecs { + got = append(got, name) + } + sort.Strings(got) + + if !reflect.DeepEqual(got, want) { + t.Fatalf("captured input-type ids mismatch:\n got = %v\n want = %v", got, want) + } +} + +// TestInputTypesAgreeWithTheInputSchemaCapture cross-checks the two INDEPENDENT +// captures of the same Python signatures: python_input_schemas.json (what the +// node publishes to the control plane, derived by `_types_to_json_schema`) and +// python_input_types.json (what `_validate_handler_input` runs on, the raw +// `(annotation, default)` pairs). They are produced by different SDK code paths +// from the same source, so they must agree on parameter NAMES, on which +// parameters are REQUIRED, and — for the kinds the schema names unambiguously — +// on the kind. +// +// This is what makes the coercion table above non-tautological: a bad +// regeneration of either capture fails here. +func TestInputTypesAgreeWithTheInputSchemaCapture(t *testing.T) { + // _types_to_json_schema's mapping, inverted. {"type":"object"} with no + // additionalProperties is the PEP 604 fall-through, i.e. kind "any". + schemaKind := func(prop map[string]any) paramKind { + switch prop["type"] { + case "string": + return kindStr + case "integer": + return kindInt + case "number": + return kindFloat + case "boolean": + return kindBool + case "array": + return kindList + case "object": + if _, ok := prop["additionalProperties"]; ok { + return kindDict + } + return kindAny + } + return kindAny + } + + for _, name := range InputSchemaNames() { + t.Run(name, func(t *testing.T) { + var schema struct { + Properties map[string]map[string]any `json:"properties"` + Required []string `json:"required"` + } + if err := json.Unmarshal(InputSchema(name), &schema); err != nil { + t.Fatalf("decode schema: %v", err) + } + required := map[string]bool{} + for _, key := range schema.Required { + required[key] = true + } + + params := handlerSpecFor(name) + if len(params) != len(schema.Properties) { + t.Fatalf("%d parameters, schema has %d properties", len(params), len(schema.Properties)) + } + // The schema's `required` list is in PARAMETER order, so it also + // pins the capture's ordering for the required subset. + var requiredInOrder []string + for _, p := range params { + prop, ok := schema.Properties[p.Name] + if !ok { + t.Errorf("%s: not in the published schema", p.Name) + continue + } + if want := schemaKind(prop); p.Kind != want { + t.Errorf("%s: kind %q, schema says %q (%v)", p.Name, p.Kind, want, prop) + } + if p.Required != required[p.Name] { + t.Errorf("%s: required=%v, schema says %v", p.Name, p.Required, required[p.Name]) + } + if p.Required { + requiredInOrder = append(requiredInOrder, p.Name) + } + } + if len(schema.Required) > 0 && !reflect.DeepEqual(requiredInOrder, schema.Required) { + t.Errorf("required order = %v, schema says %v", requiredInOrder, schema.Required) + } + }) + } +} + +// TestEveryRegisteredHandlerIsValidated drives the registered surface over +// HTTP — the way a control-plane request arrives — and asserts both ends of the +// divergence: a body Python 422s is refused with the SAME status and message, +// and a body Python accepts is accepted. +func TestEveryRegisteredHandlerIsValidated(t *testing.T) { + handler := nodeHandler(t) + + t.Run("null on a required collection is a 422", func(t *testing.T) { + status, body := postReasoner(t, handler, NameRunDeduplicator, map[string]any{ + "findings": nil, "recon_context": map[string]any{}, "repo_path": "/r", + }) + if status != http.StatusUnprocessableEntity { + t.Fatalf("status = %d, want 422 (Python: Field 'findings' cannot be None); body %v", + status, body) + } + if want := "Field 'findings' cannot be None"; body["error"] != want { + t.Errorf("error = %v, want %q", body["error"], want) + } + }) + + t.Run("null on a required collection reaches remediation_phase too", func(t *testing.T) { + status, body := postReasoner(t, handler, NameRemediationPhase, map[string]any{ + "repo_path": "/r", "verified_findings": nil, + }) + if status != http.StatusUnprocessableEntity { + t.Fatalf("status = %d, want 422; body %v", status, body) + } + if want := "Field 'verified_findings' cannot be None"; body["error"] != want { + t.Errorf("error = %v, want %q", body["error"], want) + } + }) + + t.Run("a string-encoded int is coerced, not rejected", func(t *testing.T) { + // max_files_without_signal="50" is 50 for Python and used to be a + // json.UnmarshalTypeError here. A 200 means the handler ran. + status, body := postReasoner(t, handler, NameRunInjectionHunter, map[string]any{ + "repo_path": t.TempDir(), "recon_context": map[string]any{}, + "depth": "quick", "max_files_without_signal": "50", + }) + // The fake app has no scripted harness, so the reasoner FAILS — but on + // its own work, not on binding: a 422 here would mean the input layer + // rejected a body Python accepts. + if status == http.StatusUnprocessableEntity { + t.Fatalf("max_files_without_signal=\"50\" was rejected; Python coerces it to 50. body %v", body) + } + }) + + t.Run("a number where a string is declared is coerced", func(t *testing.T) { + status, body := postReasoner(t, handler, NameReconPhase, map[string]any{ + "repo_path": t.TempDir(), "depth": float64(5), + }) + // recon_phase fans out to five `.call`s the fake answers with an error, + // so the reasoner FAILS — but with the DAG's error, not a bind error: + // the point is that "5" got through the input layer. + if status == http.StatusUnprocessableEntity { + t.Fatalf("depth=5 was rejected; Python coerces it to \"5\". body %v", body) + } + }) +} + +// nodeHandler mounts the router on a real *agent.Agent and returns its HTTP +// handler, so a request travels the same path a control-plane call does. +func nodeHandler(t *testing.T) http.Handler { + t.Helper() + a, err := agent.New(agent.Config{ + NodeID: "sec-af", + Version: "0.1.0", + AgentFieldURL: "http://127.0.0.1:1", // never dialled: no Initialize here + ListenAddress: ":0", + }) + if err != nil { + t.Fatalf("agent.New: %v", err) + } + router := agent.NewRouter() + RegisterAll(router, &appx.Fake{}) + a.IncludeRouter(router, agent.RouterOptions{Tags: RouterTags}) + return a.Handler() +} + +// postReasoner POSTs a body to /reasoners/ and returns the status and the +// decoded response object. +func postReasoner(t *testing.T, handler http.Handler, name string, body map[string]any) (int, map[string]any) { + t.Helper() + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/reasoners/"+name, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response (%d): %v\n%s", rec.Code, err, rec.Body.String()) + } + return rec.Code, payload +} + +// TestPyIntGrammar pins CPython's `int(str)`, which `_validate_handler_input` +// calls for every `int`-annotated parameter. VERIFIED on the pinned +// interpreter: int("50")==50, int(" 50 ")==50, int("1_0")==10, int("+7")==7, +// while "0x10", "50.5", "" and "abc" all raise ValueError. +func TestPyIntGrammar(t *testing.T) { + for _, tc := range []struct { + in string + want int + ok bool + }{ + {"50", 50, true}, + {" 50 ", 50, true}, + {"\t50\n", 50, true}, + {"+7", 7, true}, + {"-7", -7, true}, + {"1_0", 10, true}, + {"1_000_000", 1000000, true}, + {"0", 0, true}, + {"0x10", 0, false}, + {"50.5", 0, false}, + {"1e3", 0, false}, + {"", 0, false}, + {"abc", 0, false}, + {"1__0", 0, false}, + {"_1", 0, false}, + {"1_", 0, false}, + } { + got, ok := parsePyInt(tc.in) + if ok != tc.ok || (ok && got != tc.want) { + t.Errorf("int(%q) = (%d, %v), want (%d, %v)", tc.in, got, ok, tc.want, tc.ok) + } + } +} + +// TestPyFloatGrammar pins CPython's `float(str)`. No reasoner parameter is +// annotated `float` today — audit's `max_cost_usd: float | None` is a PEP 604 +// union and therefore kind "any" — so this is the only exercise the branch +// gets. VERIFIED: float("1_0.5")==10.5 and float("inf")==inf, while +// float("0x1p-2") raises where Go's strconv would happily accept it. +func TestPyFloatGrammar(t *testing.T) { + for _, tc := range []struct { + in string + want float64 + ok bool + }{ + {"1.5", 1.5, true}, + {"1e3", 1000, true}, + {" 2 ", 2, true}, + {"1_0.5", 10.5, true}, + {"-0.25", -0.25, true}, + {"0x1p-2", 0, false}, + {"", 0, false}, + {"abc", 0, false}, + } { + got, ok := parsePyFloat(tc.in) + if ok != tc.ok || (ok && got != tc.want) { + t.Errorf("float(%q) = (%v, %v), want (%v, %v)", tc.in, got, ok, tc.want, tc.ok) + } + } + if f, ok := parsePyFloat("inf"); !ok || f <= 0 { + t.Errorf(`float("inf") = (%v, %v), want +Inf`, f, ok) + } +} + +// TestPyStrRendersDecodedJSONTheCPythonWay pins `str(value)` for the value +// kinds a decoded body holds. VERIFIED: str(5)=="5", str(5.5)=="5.5", +// str(True)=="True", str([1, 2])=="[1, 2]", str({'a': 1})=="{'a': 1}". +// +// The wire-number residual is deliberate: Go's decoder cannot tell `5` from +// `5.0`, and the int spelling is the one that matches the realistic body. +func TestPyStrRendersDecodedJSONTheCPythonWay(t *testing.T) { + for _, tc := range []struct { + in any + want string + }{ + {"already", "already"}, + {float64(5), "5"}, + {float64(5.5), "5.5"}, + {float64(-3), "-3"}, + {true, "True"}, + {false, "False"}, + {[]any{float64(1), float64(2)}, "[1, 2]"}, + {[]any{"a", "b"}, "['a', 'b']"}, + {map[string]any{"a": float64(1)}, "{'a': 1}"}, + {[]any{}, "[]"}, + } { + if got := pyStr(tc.in); got != tc.want { + t.Errorf("str(%#v) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/go/internal/reasoners/helpers_test.go b/go/internal/reasoners/helpers_test.go new file mode 100644 index 0000000..e7702cb --- /dev/null +++ b/go/internal/reasoners/helpers_test.go @@ -0,0 +1,124 @@ +package reasoners + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// helpers_test.go holds the fixtures the adapter tests share. + +// newScanFake returns an appx.Fake whose harness answers every call by leaving +// dest untouched and reporting success. For a hunter that means "the location +// scanner found nothing", which is the cheapest way to drive an adapter end to +// end: the hunter returns its empty HuntResult without enriching, and the ONE +// recorded prompt is the scan prompt whose bytes the parity assertions read. +func newScanFake() *appx.Fake { + return &appx.Fake{ + HarnessFn: func(_ context.Context, _ string, _ map[string]any, dest any, _ harness.Options) (*harness.Result, error) { + return &harness.Result{Parsed: dest}, nil + }, + } +} + +// assertNote asserts that the fake recorded exactly one note, with the given +// message and tags in order. +func assertNote(t *testing.T, fake *appx.Fake, message string, tags ...string) { + t.Helper() + if len(fake.Notes) != 1 { + t.Fatalf("notes = %d (%v), want exactly 1", len(fake.Notes), fake.NoteMessages()) + } + if fake.Notes[0].Message != message { + t.Errorf("note message = %q, want %q", fake.Notes[0].Message, message) + } + if !reflect.DeepEqual(fake.Notes[0].Tags, tags) { + t.Errorf("note tags = %v, want %v", fake.Notes[0].Tags, tags) + } +} + +// assertPromptHasFileBudget asserts the hunter scan prompt names `want` as its +// early-stop file budget. Every hunter phrases the sentence differently, so the +// assertion looks for " files without". +func assertPromptHasFileBudget(t *testing.T, prompt, want string) { + t.Helper() + if !strings.Contains(prompt, want+" files without") { + t.Errorf("prompt does not carry the file budget %q; prompt tail:\n%s", + want, tail(prompt, 400)) + } +} + +func tail(s string, n int) string { + if len(s) <= n { + return s + } + return "..." + s[len(s)-n:] +} + +// fullReconContext is a recon_context that satisfies ReconResult's five +// required nested models without relying on _recon_model's seed — the shape +// hunt_phase forwards to run_deduplicator. +func fullReconContext() map[string]any { + return map[string]any{ + "architecture": map[string]any{}, + "data_flows": map[string]any{}, + "dependencies": map[string]any{}, + "config": map[string]any{}, + "security_context": map[string]any{"auth_model": "jwt", "auth_details": "bearer"}, + } +} + +// rawFindingPayload is a minimal RawFinding dict carrying every required field. +func rawFindingPayload() map[string]any { + return map[string]any{ + "id": "finding-1", + "hunter_strategy": "injection", + "title": "SQL injection", + "description": "unsanitized input reaches a query", + "finding_type": "sast", + "cwe_id": "CWE-89", + "cwe_name": "SQL Injection", + "file_path": "app/db.py", + "start_line": float64(10), + "end_line": float64(12), + "code_snippet": "query(f\"...{x}\")", + "estimated_severity": "high", + "confidence": "high", + "fingerprint": "fp-1", + } +} + +// asMap renders a typed value the way the control plane would (marshal, then +// decode into an untyped map), so a test can compare against Python's +// model_dump() key set. +func asMap(t *testing.T, v any) map[string]any { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return out +} + +// verifierProjection is what prove_phase actually sends to run_verifier: +// `finding.for_verifier().model_dump()`. +func verifierProjection(t *testing.T) map[string]any { + t.Helper() + raw, err := bindRawFinding(rawFindingPayload()) + if err != nil { + t.Fatalf("bindRawFinding: %v", err) + } + return asMap(t, raw.ForVerifier()) +} + +var _ = schemas.RawFinding{} diff --git a/go/internal/reasoners/hunt.go b/go/internal/reasoners/hunt.go new file mode 100644 index 0000000..bb6a2b0 --- /dev/null +++ b/go/internal/reasoners/hunt.go @@ -0,0 +1,282 @@ +package reasoners + +import ( + "context" + + "github.com/Agent-Field/sec-af/go/internal/afx" + dedupagent "github.com/Agent-Field/sec-af/go/internal/agents/dedup" + huntagent "github.com/Agent-Field/sec-af/go/internal/agents/hunt" + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/phases" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// hunt.go ports src/sec_af/reasoners/hunt.py — the twelve hunter adapters, the +// logic-bugs alias and the deduplicator. + +// reconModel ports `_recon_model(recon_context)` (reasoners/hunt.py:26): +// +// normalized = { +// "architecture": {}, "data_flows": {}, "dependencies": {}, "config": {}, +// "security_context": {"auth_model": "unknown", "auth_details": ""}, +// "languages": [], "frameworks": [], "lines_of_code": 0, "file_count": 0, +// } +// normalized.update(recon_context) +// return ReconResult.model_validate(normalized) +// +// The seed exists because ReconResult has five REQUIRED nested models and +// SecurityContext two required scalars: a hunter reasoner invoked with a +// strategy-pruned recon projection (which is what hunt_phase sends) would +// otherwise fail validation. Seeding-then-overlaying is what makes the pruned +// projection bind. +// +// Python parity, and the reason this is a literal dict merge rather than +// "bind with defaults": +// +// - `dict.update` replaces a key WHOLESALE. A recon_context that carries +// `security_context: {"auth_model": "jwt"}` therefore loses the +// auth_details seed and FAILS validation — the seed is not merged field by +// field. Reproduced exactly: the overlay assigns the caller's value for the +// key, it does not deep-merge. +// - the security_context seed is `auth_details: ""`, NOT the "unknown" that +// recon_phase's quick-depth placeholder uses. The two are different values +// in Python and are kept different here. +// - a key present in recon_context with value None overwrites the seed with +// None and then fails validation, same as Python. +func reconModel(reconContext map[string]any) (schemas.ReconResult, error) { + normalized := map[string]any{ + "architecture": map[string]any{}, + "data_flows": map[string]any{}, + "dependencies": map[string]any{}, + "config": map[string]any{}, + "security_context": map[string]any{ + "auth_model": "unknown", + "auth_details": "", + }, + "languages": []any{}, + "frameworks": []any{}, + "lines_of_code": 0, + "file_count": 0, + } + for k, v := range reconContext { + normalized[k] = v + } + return phases.BindReconResult(normalized) +} + +// hunterFunc is one hunter's agent-level entry point, already closed over the +// arguments the adapter binds. +type hunterFunc func(ctx context.Context, app appx.Harnesser, recon schemas.ReconResult) (schemas.HuntResult, error) + +// runHunter ports `_run_hunter(runner, *, repo_path, recon_context, depth, +// max_files_without_signal=30)` (reasoners/hunt.py:41) — minus its TypeError +// cascade, whose OBSERVABLE effect is captured by each caller instead. +// +// The cascade tries four call shapes in order and keeps the first that does not +// raise TypeError: +// +// 1. runner(app=, repo_path=, recon_result=, depth=, max_files_without_signal=) +// 2. runner(app=, repo_path=, recon=, depth=, max_files_without_signal=) +// 3. runner(app=, repo_path=, recon=) +// 4. runner(app, repo_path, recon_model, depth) [positional] +// +// VERIFIED on the repo's own interpreter (by binding each hunter's real +// signature against each shape): the seven hunters that declare `depth` +// — injection, dos, ssrf, auth, xss, business_logic, and logic — take shape 1 +// and receive the caller's max_files_without_signal. The five that do NOT +// declare depth — crypto, data_exposure, supply_chain, config_secrets, +// api_security — fall through to shape 3, which passes NEITHER depth NOR +// max_files_without_signal, so they always run with their own default of 30. +// +// That is a live Python quirk, not an artifact: those five embed the number in +// their prompt ("if you inspect {n} files without credible ..."), so a caller +// that passes max_files_without_signal=50 gets 50 in seven prompts and 30 in +// the other five. It is reproduced (see hunterMaxFilesForNoDepthHunter), and is +// invisible in the live pipeline because hunt_phase always sends 30. +// Shape 4 is unreachable — no hunter's signature rejects shape 3. +func runHunter( + ctx context.Context, + app appx.App, + reconContext map[string]any, + run hunterFunc, +) (map[string]any, error) { + recon, err := reconModel(reconContext) + if err != nil { + return nil, err + } + result, err := run(ctx, app, recon) + if err != nil { + return nil, err + } + return afx.ToMap(result) +} + +// hunterMaxFilesForNoDepthHunter is the value the five depth-less hunters +// actually receive: their OWN default, because _run_hunter's third cascade +// shape omits the keyword entirely. See runHunter. +const hunterMaxFilesForNoDepthHunter = DefaultMaxFilesWithoutSignal + +// RunInjectionHunter ports `run_injection_hunter` (reasoners/hunt.py:72). +func RunInjectionHunter(ctx context.Context, app appx.App, in HunterInput) (map[string]any, error) { + app.Note(ctx, "Injection hunter starting", "hunt", "injection") + return runHunter(ctx, app, in.ReconContext, + func(ctx context.Context, a appx.Harnesser, recon schemas.ReconResult) (schemas.HuntResult, error) { + return huntagent.RunInjectionHunter(ctx, a, in.RepoPath, recon, in.Depth, in.MaxFilesWithoutSignal) + }) +} + +// RunDosHunter ports `run_dos_hunter` (reasoners/hunt.py:89). +func RunDosHunter(ctx context.Context, app appx.App, in HunterInput) (map[string]any, error) { + app.Note(ctx, "DoS hunter starting", "hunt", "dos") + return runHunter(ctx, app, in.ReconContext, + func(ctx context.Context, a appx.Harnesser, recon schemas.ReconResult) (schemas.HuntResult, error) { + return huntagent.RunDosHunter(ctx, a, in.RepoPath, recon, in.Depth, in.MaxFilesWithoutSignal) + }) +} + +// RunSSRFHunter ports `run_ssrf_hunter` (reasoners/hunt.py:106). +func RunSSRFHunter(ctx context.Context, app appx.App, in HunterInput) (map[string]any, error) { + app.Note(ctx, "SSRF hunter starting", "hunt", "ssrf") + return runHunter(ctx, app, in.ReconContext, + func(ctx context.Context, a appx.Harnesser, recon schemas.ReconResult) (schemas.HuntResult, error) { + return huntagent.RunSSRFHunter(ctx, a, in.RepoPath, recon, in.Depth, in.MaxFilesWithoutSignal) + }) +} + +// RunAuthHunter ports `run_auth_hunter` (reasoners/hunt.py:123). +func RunAuthHunter(ctx context.Context, app appx.App, in HunterInput) (map[string]any, error) { + app.Note(ctx, "Auth hunter starting", "hunt", "auth") + return runHunter(ctx, app, in.ReconContext, + func(ctx context.Context, a appx.Harnesser, recon schemas.ReconResult) (schemas.HuntResult, error) { + return huntagent.RunAuthHunter(ctx, a, in.RepoPath, recon, in.Depth, in.MaxFilesWithoutSignal) + }) +} + +// RunXSSHunter ports `run_xss_hunter` (reasoners/hunt.py:140). +func RunXSSHunter(ctx context.Context, app appx.App, in HunterInput) (map[string]any, error) { + app.Note(ctx, "XSS hunter starting", "hunt", "xss") + return runHunter(ctx, app, in.ReconContext, + func(ctx context.Context, a appx.Harnesser, recon schemas.ReconResult) (schemas.HuntResult, error) { + return huntagent.RunXSSHunter(ctx, a, in.RepoPath, recon, in.Depth, in.MaxFilesWithoutSignal) + }) +} + +// RunCryptoHunter ports `run_crypto_hunter` (reasoners/hunt.py:157). +// +// Python parity: run_crypto_hunter's signature has no `depth`, so _run_hunter +// reaches it through the third cascade shape — depth is DROPPED and +// max_files_without_signal falls back to 30. See runHunter. +func RunCryptoHunter(ctx context.Context, app appx.App, in HunterInput) (map[string]any, error) { + app.Note(ctx, "Crypto hunter starting", "hunt", "crypto") + return runHunter(ctx, app, in.ReconContext, + func(ctx context.Context, a appx.Harnesser, recon schemas.ReconResult) (schemas.HuntResult, error) { + return huntagent.RunCryptoHunter(ctx, a, in.RepoPath, recon, hunterMaxFilesForNoDepthHunter) + }) +} + +// RunBusinessLogicHunter ports `run_business_logic_hunter` +// (reasoners/hunt.py:174). +// +// Python parity: the agent function's sixth parameter `depth_prompt: str = ""` +// is never supplied by _run_hunter, so it keeps its default. +func RunBusinessLogicHunter(ctx context.Context, app appx.App, in HunterInput) (map[string]any, error) { + app.Note(ctx, "Business logic hunter starting", "hunt", "business-logic") + return runHunter(ctx, app, in.ReconContext, + func(ctx context.Context, a appx.Harnesser, recon schemas.ReconResult) (schemas.HuntResult, error) { + return huntagent.RunBusinessLogicHunter(ctx, a, in.RepoPath, recon, in.Depth, in.MaxFilesWithoutSignal, "") + }) +} + +// RunLogicBugsHunter ports `run_logic_bugs_hunter` (reasoners/hunt.py:191): +// +// return await run_business_logic_hunter(repo_path=..., recon_context=..., +// depth=..., max_files_without_signal=...) +// +// Python parity, two points that are easy to miss: +// +// - it emits NO note of its own. The note the execution shows is the +// "Business logic hunter starting" one the delegate emits. +// - it delegates to the REASONER, not to agents/hunt/logic.py's +// run_logic_hunter. reasoners/hunt.py imports run_logic_hunter and never +// uses it (a dead import in the Python source), so this reasoner is +// behaviorally indistinguishable from run_business_logic_hunter. +func RunLogicBugsHunter(ctx context.Context, app appx.App, in HunterInput) (map[string]any, error) { + return RunBusinessLogicHunter(ctx, app, in) +} + +// RunDataExposureHunter ports `run_data_exposure_hunter` +// (reasoners/hunt.py:205). Depth-less hunter — see runHunter. +func RunDataExposureHunter(ctx context.Context, app appx.App, in HunterInput) (map[string]any, error) { + app.Note(ctx, "Data exposure hunter starting", "hunt", "data-exposure") + return runHunter(ctx, app, in.ReconContext, + func(ctx context.Context, a appx.Harnesser, recon schemas.ReconResult) (schemas.HuntResult, error) { + return huntagent.RunDataExposureHunter(ctx, a, in.RepoPath, recon, hunterMaxFilesForNoDepthHunter) + }) +} + +// RunSupplyChainHunter ports `run_supply_chain_hunter` +// (reasoners/hunt.py:222). Depth-less hunter — see runHunter. +func RunSupplyChainHunter(ctx context.Context, app appx.App, in HunterInput) (map[string]any, error) { + app.Note(ctx, "Supply chain hunter starting", "hunt", "supply-chain") + return runHunter(ctx, app, in.ReconContext, + func(ctx context.Context, a appx.Harnesser, recon schemas.ReconResult) (schemas.HuntResult, error) { + return huntagent.RunSupplyChainHunter(ctx, a, in.RepoPath, recon, hunterMaxFilesForNoDepthHunter) + }) +} + +// RunConfigSecretsHunter ports `run_config_secrets_hunter` +// (reasoners/hunt.py:239). Depth-less hunter — see runHunter. +func RunConfigSecretsHunter(ctx context.Context, app appx.App, in HunterInput) (map[string]any, error) { + app.Note(ctx, "Config secrets hunter starting", "hunt", "config-secrets") + return runHunter(ctx, app, in.ReconContext, + func(ctx context.Context, a appx.Harnesser, recon schemas.ReconResult) (schemas.HuntResult, error) { + return huntagent.RunConfigSecretsHunter(ctx, a, in.RepoPath, recon, hunterMaxFilesForNoDepthHunter) + }) +} + +// RunAPISecurityHunter ports `run_api_security_hunter` +// (reasoners/hunt.py:256). Depth-less hunter — see runHunter. +func RunAPISecurityHunter(ctx context.Context, app appx.App, in HunterInput) (map[string]any, error) { + app.Note(ctx, "API security hunter starting", "hunt", "api-security") + return runHunter(ctx, app, in.ReconContext, + func(ctx context.Context, a appx.Harnesser, recon schemas.ReconResult) (schemas.HuntResult, error) { + return huntagent.RunAPISecurityHunter(ctx, a, in.RepoPath, recon, hunterMaxFilesForNoDepthHunter) + }) +} + +// RunDeduplicator ports `run_deduplicator(findings, recon_context, repo_path)` +// (reasoners/hunt.py:273): +// +// raw_findings = [RawFinding(**f) for f in findings] +// recon = ReconResult(**recon_context) +// result = await _deduplicate_and_correlate(raw_findings, recon, router, repo_path) +// return result.model_dump() +// +// Python parity: `ReconResult(**recon_context)` here is the RAW constructor — +// it does NOT go through _recon_model, so an incomplete recon_context fails +// validation instead of being seeded. hunt_phase always forwards the caller's +// full recon dump to this reasoner (only the HUNTERS get the pruned +// projection), so the strict bind is the correct one. +func RunDeduplicator(ctx context.Context, app appx.App, in DeduplicatorInput) (map[string]any, error) { + app.Note(ctx, "Deduplicator starting", "hunt", "dedup") + + findings := make([]schemas.RawFinding, 0, len(in.Findings)) + for _, raw := range in.Findings { + finding, err := bindRawFinding(raw) + if err != nil { + return nil, err + } + findings = append(findings, finding) + } + + recon, err := phases.BindReconResult(in.ReconContext) + if err != nil { + return nil, err + } + + result, err := dedupagent.DeduplicateAndCorrelate(ctx, findings, recon, app, in.RepoPath) + if err != nil { + return nil, err + } + return afx.ToMap(result) +} diff --git a/go/internal/reasoners/hunt_test.go b/go/internal/reasoners/hunt_test.go new file mode 100644 index 0000000..5b328a1 --- /dev/null +++ b/go/internal/reasoners/hunt_test.go @@ -0,0 +1,247 @@ +package reasoners + +// Tests for src/sec_af/reasoners/hunt.py. +// +// Validation contract (behaviour, derived from the Python module): +// +// - every hunter reasoner emits its own "X starting" note with its own tags, +// BEFORE the agent function runs; +// - run_logic_bugs_hunter emits NO note of its own and is otherwise +// indistinguishable from run_business_logic_hunter; +// - _recon_model seeds the five required nested models plus +// security_context={"auth_model":"unknown","auth_details":""} and then +// OVERLAYS recon_context wholesale (dict.update semantics — a partial +// security_context replaces the seed and fails validation); +// - the seven hunters whose Python signature declares `depth` receive the +// caller's max_files_without_signal; the five that do not always run with +// 30 (the TypeError cascade drops the keyword); +// - run_deduplicator validates its findings as RawFindings and its +// recon_context as a ReconResult WITHOUT the _recon_model seed; +// - each adapter returns the agent result's model_dump() key set. + +import ( + "context" + "testing" + + "github.com/Agent-Field/sec-af/go/internal/appx" +) + +// hunterAdapter is one hunter reasoner under test. +type hunterAdapter struct { + Name string + Fn func(context.Context, appx.App, HunterInput) (map[string]any, error) + Note string + Tags []string + // TakesDepth reports whether the Python agent signature declares `depth`, + // which is what decides whether max_files_without_signal survives + // _run_hunter's TypeError cascade. + TakesDepth bool + // Gated hunters return early without a harness call when the recon context + // gives them nothing to do, so their prompt cannot be inspected. + Gated bool +} + +func hunterAdapters() []hunterAdapter { + return []hunterAdapter{ + {"run_injection_hunter", RunInjectionHunter, "Injection hunter starting", []string{"hunt", "injection"}, true, false}, + {"run_dos_hunter", RunDosHunter, "DoS hunter starting", []string{"hunt", "dos"}, true, false}, + {"run_ssrf_hunter", RunSSRFHunter, "SSRF hunter starting", []string{"hunt", "ssrf"}, true, false}, + {"run_auth_hunter", RunAuthHunter, "Auth hunter starting", []string{"hunt", "auth"}, true, false}, + {"run_xss_hunter", RunXSSHunter, "XSS hunter starting", []string{"hunt", "xss"}, true, false}, + {"run_crypto_hunter", RunCryptoHunter, "Crypto hunter starting", []string{"hunt", "crypto"}, false, true}, + {"run_business_logic_hunter", RunBusinessLogicHunter, "Business logic hunter starting", []string{"hunt", "business-logic"}, true, false}, + {"run_data_exposure_hunter", RunDataExposureHunter, "Data exposure hunter starting", []string{"hunt", "data-exposure"}, false, false}, + {"run_supply_chain_hunter", RunSupplyChainHunter, "Supply chain hunter starting", []string{"hunt", "supply-chain"}, false, true}, + {"run_config_secrets_hunter", RunConfigSecretsHunter, "Config secrets hunter starting", []string{"hunt", "config-secrets"}, false, false}, + {"run_api_security_hunter", RunAPISecurityHunter, "API security hunter starting", []string{"hunt", "api-security"}, false, true}, + } +} + +func TestHunterAdaptersEmitTheirNote(t *testing.T) { + for _, tc := range hunterAdapters() { + t.Run(tc.Name, func(t *testing.T) { + fake := newScanFake() + if _, err := tc.Fn(context.Background(), fake, HunterInput{ + RepoPath: t.TempDir(), + ReconContext: map[string]any{}, + Depth: "standard", + MaxFilesWithoutSignal: DefaultMaxFilesWithoutSignal, + }); err != nil { + t.Fatalf("%s: %v", tc.Name, err) + } + assertNote(t, fake, tc.Note, tc.Tags...) + }) + } +} + +// TestHunterMaxFilesCascadeParity is the observable half of _run_hunter's +// TypeError cascade: with max_files_without_signal=50, the hunters whose +// signature takes `depth` put 50 in their prompt and the ones that do not put +// 30, because the third cascade shape passes neither keyword. +// +// VERIFIED against the Python source by binding each hunter's real signature +// against each cascade shape on the repo's own interpreter. +func TestHunterMaxFilesCascadeParity(t *testing.T) { + const custom = 50 + + for _, tc := range hunterAdapters() { + if tc.Gated { + continue // no harness call to inspect + } + t.Run(tc.Name, func(t *testing.T) { + fake := newScanFake() + if _, err := tc.Fn(context.Background(), fake, HunterInput{ + RepoPath: t.TempDir(), + ReconContext: map[string]any{}, + Depth: "thorough", + MaxFilesWithoutSignal: custom, + }); err != nil { + t.Fatalf("%s: %v", tc.Name, err) + } + if len(fake.Harnesses) == 0 { + t.Fatalf("%s made no harness call", tc.Name) + } + want := "30" + if tc.TakesDepth { + want = "50" + } + assertPromptHasFileBudget(t, fake.Harnesses[0].Prompt, want) + }) + } +} + +// TestRunLogicBugsHunterDelegates pins the alias: no note of its own, and the +// same prompt run_business_logic_hunter produces for the same input. +func TestRunLogicBugsHunterDelegates(t *testing.T) { + repo := t.TempDir() + in := HunterInput{ + RepoPath: repo, + ReconContext: map[string]any{}, + Depth: "thorough", + MaxFilesWithoutSignal: 42, + } + + aliasFake := newScanFake() + aliasResult, err := RunLogicBugsHunter(context.Background(), aliasFake, in) + if err != nil { + t.Fatalf("RunLogicBugsHunter: %v", err) + } + + directFake := newScanFake() + directResult, err := RunBusinessLogicHunter(context.Background(), directFake, in) + if err != nil { + t.Fatalf("RunBusinessLogicHunter: %v", err) + } + + // The alias emits exactly the delegate's note — one note, not two, and not + // a "logic bugs" one. + assertNote(t, aliasFake, "Business logic hunter starting", "hunt", "business-logic") + + if len(aliasFake.Harnesses) != len(directFake.Harnesses) { + t.Fatalf("harness calls: alias %d, direct %d", len(aliasFake.Harnesses), len(directFake.Harnesses)) + } + for i := range aliasFake.Harnesses { + if aliasFake.Harnesses[i].Prompt != directFake.Harnesses[i].Prompt { + t.Errorf("prompt %d differs between the alias and the delegate", i) + } + } + if len(aliasResult) != len(directResult) { + t.Errorf("result key count: alias %d, direct %d", len(aliasResult), len(directResult)) + } +} + +// TestReconModelSeeds pins _recon_model's normalization. +func TestReconModelSeeds(t *testing.T) { + t.Run("empty context binds with the seeded defaults", func(t *testing.T) { + recon, err := reconModel(map[string]any{}) + if err != nil { + t.Fatalf("reconModel: %v", err) + } + if recon.SecurityContext.AuthModel != "unknown" { + t.Errorf("auth_model = %q, want %q", recon.SecurityContext.AuthModel, "unknown") + } + // Python parity: the hunt.py seed uses "" for auth_details, NOT the + // "unknown" recon_phase's quick-depth placeholder uses. + if recon.SecurityContext.AuthDetails != "" { + t.Errorf("auth_details = %q, want the empty string", recon.SecurityContext.AuthDetails) + } + if recon.LinesOfCode != 0 || recon.FileCount != 0 { + t.Errorf("metrics = (%d, %d), want (0, 0)", recon.LinesOfCode, recon.FileCount) + } + }) + + t.Run("caller values overlay the seed", func(t *testing.T) { + recon, err := reconModel(map[string]any{ + "languages": []any{"python"}, + "lines_of_code": float64(4200), + }) + if err != nil { + t.Fatalf("reconModel: %v", err) + } + if len(recon.Languages) != 1 || recon.Languages[0] != "python" { + t.Errorf("languages = %v, want [python]", recon.Languages) + } + if recon.LinesOfCode != 4200 { + t.Errorf("lines_of_code = %d, want 4200", recon.LinesOfCode) + } + }) + + t.Run("a partial security_context replaces the seed wholesale and fails", func(t *testing.T) { + // Python parity: dict.update replaces the KEY, so the auth_details seed + // is lost and SecurityContext validation fails. + if _, err := reconModel(map[string]any{ + "security_context": map[string]any{"auth_model": "jwt"}, + }); err == nil { + t.Fatal("want a validation error for the partial security_context") + } + }) +} + +// TestRunDeduplicatorValidatesStrictly pins the two binds run_deduplicator does +// — RawFinding per finding, and a RAW ReconResult (no _recon_model seed). +func TestRunDeduplicatorValidatesStrictly(t *testing.T) { + t.Run("an incomplete recon_context fails", func(t *testing.T) { + fake := newScanFake() + _, err := RunDeduplicator(context.Background(), fake, DeduplicatorInput{ + Findings: nil, + ReconContext: map[string]any{}, + RepoPath: t.TempDir(), + }) + if err == nil { + t.Fatal("want a validation error: run_deduplicator does not seed defaults") + } + assertNote(t, fake, "Deduplicator starting", "hunt", "dedup") + }) + + t.Run("a malformed finding fails", func(t *testing.T) { + fake := newScanFake() + _, err := RunDeduplicator(context.Background(), fake, DeduplicatorInput{ + Findings: []map[string]any{{"title": "malformed"}}, + ReconContext: fullReconContext(), + RepoPath: t.TempDir(), + }) + if err == nil { + t.Fatal("want a validation error for a RawFinding missing required fields") + } + }) + + t.Run("a well-formed request returns a HuntResult dump", func(t *testing.T) { + fake := newScanFake() + got, err := RunDeduplicator(context.Background(), fake, DeduplicatorInput{ + Findings: []map[string]any{rawFindingPayload()}, + ReconContext: fullReconContext(), + RepoPath: t.TempDir(), + }) + if err != nil { + t.Fatalf("RunDeduplicator: %v", err) + } + for _, key := range []string{ + "findings", "chains", "total_raw", "deduplicated_count", "chain_count", + "strategies_run", "hunt_duration_seconds", + } { + if _, ok := got[key]; !ok { + t.Errorf("result is missing the %q key (HuntResult.model_dump())", key) + } + } + }) +} diff --git a/go/internal/reasoners/input_schemas.go b/go/internal/reasoners/input_schemas.go new file mode 100644 index 0000000..2b7deaf --- /dev/null +++ b/go/internal/reasoners/input_schemas.go @@ -0,0 +1,143 @@ +package reasoners + +// input_schemas.go gives every reasoner the SAME input schema the Python node +// publishes to the control plane. +// +// # Why a fixture and not a derivation +// +// The Python SDK does not hand-write these schemas: `@router.reasoner()` stores +// the decorated function's `(annotation, default)` pairs and +// `Agent._types_to_json_schema` (sdk/python/agentfield/agent.py) turns them +// into JSON Schema on demand, once per registration. The Go SDK has no +// equivalent — `RegisterReasoner` stamps every reasoner with the placeholder +// `{"type":"object","additionalProperties":true}` unless the caller passes +// agent.WithInputSchema. Re-deriving the schemas from the Go input structs +// would NOT reproduce Python, because `_type_to_json_schema` has quirks a +// reflection-based Go derivation would "fix" (see below). So the exact bytes +// Python registers are committed as a fixture and replayed verbatim. +// +// # The three quirks the fixture preserves +// +// - `X | None` becomes {"type":"object"}, NOT the base type. The Union branch +// of `_type_to_json_schema` tests `typ.__origin__ is Union`, but a PEP 604 +// union (`str | None`) is a `types.UnionType` with no `__origin__` at all, +// so it falls through to the trailing `return {"type": "object"}`. Every +// optional parameter in this node is spelled the PEP 604 way, so +// `commit_sha: str | None`, `scan_types: list[str] | None`, +// `max_provers: int | None` and `ai_gate: Any | None` are all reported as +// bare objects. +// - `dict[str, Any]` becomes {"type":"object","additionalProperties":true} +// while a plain `dict` would be {"type":"object"} — the two are visibly +// different in the fixture (`recon_context` vs `commit_sha`). +// - a parameter with ANY default is omitted from `required`, and `required` +// itself is omitted when empty. `required` keeps the Python parameter +// ORDER, not alphabetical order — e.g. run_verdict_agent requires +// ["finding","data_flow","sanitization","exploit"]. +// +// None of this is "better" or "worse" than what a Go derivation would produce; +// it is what callers reading `sec-af`'s discovery payload see today, and +// DESIGN.md §0.2 says to reproduce rather than improve. +// +// # Provenance and regeneration +// +// testdata/python_input_schemas.json was captured from a LIVE Python sec-af +// node running agentfield==0.1.131, through the control plane's discovery API +// with input schemas requested: +// +// curl -s "$AGENTFIELD_URL/api/v1/discovery/capabilities?node_id=sec-af&include_input_schema=true" +// +// keyed by reasoner id. It holds all 34 reasoners: the 33 router reasoners plus +// the top-level `audit`. +// +// To regenerate it WITHOUT a running node or control plane — verified to be +// byte-identical to the live capture, because `app.reasoners` is the very +// property the SDK serialises into the registration payload: +// +// PYTHONPATH=/src ~/.agentfield/packages/sec-af/venv/bin/python -c ' +// import json +// from sec_af.app import app +// print(json.dumps({r["id"]: r["input_schema"] for r in app.reasoners}, +// indent=1, sort_keys=True)) +// ' > go/internal/reasoners/testdata/python_input_schemas.json +// +// Regenerate only when the PYTHON signatures change. A Go-side change must +// never edit this file to make a test pass — that would be the Go port quietly +// redefining the contract. + +import ( + "bytes" + _ "embed" + "encoding/json" + "fmt" + "sort" +) + +// pythonInputSchemasJSON is the committed capture, embedded so the schemas ship +// in the binary rather than being read from disk at boot. +// +//go:embed testdata/python_input_schemas.json +var pythonInputSchemasJSON []byte + +// pythonInputSchemas maps reasoner id -> the compacted schema bytes. Parsing +// happens once, at package init, so a corrupt fixture fails the process +// immediately instead of at the first registration. +var pythonInputSchemas = mustParseInputSchemas(pythonInputSchemasJSON) + +// mustParseInputSchemas decodes the fixture and compacts each schema. +// +// Compaction is cosmetic but deliberate: the fixture is stored pretty-printed +// so a human can diff it, while the bytes that go on the wire should look like +// what Python's HTTP client sends (json.dumps with no indent). Compacting also +// normalises the value, so two identical schemas registered from different +// entries are byte-identical. +func mustParseInputSchemas(raw []byte) map[string]json.RawMessage { + var decoded map[string]json.RawMessage + if err := json.Unmarshal(raw, &decoded); err != nil { + panic(fmt.Sprintf("reasoners: testdata/python_input_schemas.json is not a JSON object of schemas: %v", err)) + } + if len(decoded) == 0 { + panic("reasoners: testdata/python_input_schemas.json is empty") + } + + out := make(map[string]json.RawMessage, len(decoded)) + for name, schema := range decoded { + var buf bytes.Buffer + if err := json.Compact(&buf, schema); err != nil { + panic(fmt.Sprintf("reasoners: input schema for %q is not valid JSON: %v", name, err)) + } + out[name] = json.RawMessage(buf.Bytes()) + } + return out +} + +// InputSchema returns the input schema the Python node publishes for the +// reasoner called name, ready to hand to agent.WithInputSchema. +// +// It PANICS when the fixture has no entry for name. That is the point: every +// registration goes through here, so adding a reasoner to the Go port without +// regenerating the capture (or renaming one out of sync with Python) crashes at +// registration — loudly, at boot and in every test — instead of silently +// publishing the SDK's `{"type":"object","additionalProperties":true}` +// placeholder and letting the two nodes drift apart in discovery. +// +// The returned slice is a copy, so a caller cannot mutate the shared fixture. +func InputSchema(name string) json.RawMessage { + schema, ok := pythonInputSchemas[name] + if !ok { + panic(fmt.Sprintf( + "reasoners: no input schema for reasoner %q in testdata/python_input_schemas.json; "+ + "regenerate the capture (see input_schemas.go) after changing the Python surface", name)) + } + return append(json.RawMessage(nil), schema...) +} + +// InputSchemaNames returns every reasoner id the fixture carries, sorted. It is +// the "what Python publishes" side of the registration parity assertions. +func InputSchemaNames() []string { + names := make([]string, 0, len(pythonInputSchemas)) + for name := range pythonInputSchemas { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/go/internal/reasoners/input_schemas_test.go b/go/internal/reasoners/input_schemas_test.go new file mode 100644 index 0000000..66dd4f4 --- /dev/null +++ b/go/internal/reasoners/input_schemas_test.go @@ -0,0 +1,284 @@ +package reasoners + +// Input-schema parity for the router surface. +// +// Validation contract (behaviour, derived from what the Python node publishes +// to the control plane — NOT from input_schemas.go): +// +// - every reasoner this package registers is published WITH a schema derived +// from its Python signature; none is left on the Go SDK's +// `{"type":"object","additionalProperties":true}` placeholder; +// - the schema the SDK actually holds for a reasoner (read back through +// /discover, the same payload the control plane receives) is the schema the +// Python node publishes for that reasoner id, compared key-order-insensitively; +// - the capture covers exactly the node's surface: the 33 router reasoners +// plus `audit`. A fixture entry with no registration, or a registration +// with no fixture entry, is drift and fails; +// - a registration for a name the capture does not know panics, so drift is +// impossible to ship quietly; +// - the published shapes carry Python's derivation quirks verbatim: a PEP 604 +// `X | None` is {"type":"object"}, a `dict[str, Any]` is +// {"type":"object","additionalProperties":true}, parameters with defaults +// are absent from `required`, and `required` keeps the Python parameter +// order. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "sort" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + + "github.com/Agent-Field/sec-af/go/internal/appx" +) + +// sdkPlaceholderSchema is what agent.RegisterReasoner stamps on a reasoner when +// the caller passes no agent.WithInputSchema. Publishing it would mean this +// node advertises less than the Python node it replaces. +const sdkPlaceholderSchema = `{"type":"object","additionalProperties":true}` + +// discoverInputSchemas mounts the router on a real *agent.Agent and reads the +// per-reasoner input schemas back out of the SDK through /discover. +// +// Reading back (rather than asserting on InputSchema directly) is what makes +// the test meaningful: it proves the agent.WithInputSchema option survived +// RegisterReasoner, router flattening and IncludeRouter, and it inspects the +// exact bytes the control plane is told about at registration. +func discoverInputSchemas(t *testing.T) map[string]any { + t.Helper() + + a, err := agent.New(agent.Config{ + NodeID: "sec-af", + Version: "0.1.0", + AgentFieldURL: "http://127.0.0.1:1", // never dialled: no Initialize here + ListenAddress: ":0", + }) + if err != nil { + t.Fatalf("agent.New: %v", err) + } + + router := agent.NewRouter() + RegisterAll(router, &appx.Fake{}) + a.IncludeRouter(router, agent.RouterOptions{Tags: RouterTags}) + + rec := httptest.NewRecorder() + a.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/discover", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("/discover status = %d, want 200", rec.Code) + } + + var payload struct { + Reasoners []struct { + ID string `json:"id"` + InputSchema any `json:"input_schema"` + } `json:"reasoners"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode /discover: %v", err) + } + + out := make(map[string]any, len(payload.Reasoners)) + for _, r := range payload.Reasoners { + out[r.ID] = r.InputSchema + } + return out +} + +// decodeSchema renders raw JSON as untyped Go values, which makes a comparison +// insensitive to object key order (JSON objects become maps) while staying +// sensitive to array order — `required` is a list whose order is Python's +// parameter order and must be reproduced. +func decodeSchema(t *testing.T, raw []byte) any { + t.Helper() + var v any + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("decode schema %s: %v", raw, err) + } + return v +} + +// TestCaptureCoversExactlyTheNodeSurface pins the two-way containment: the +// capture is neither missing a reasoner this port registers nor carrying one it +// does not. `audit` is in the capture but registered by internal/node, so it is +// added to the expected set here. +func TestCaptureCoversExactlyTheNodeSurface(t *testing.T) { + want := append([]string{NameAudit}, Names...) + sort.Strings(want) + + got := InputSchemaNames() + if !reflect.DeepEqual(got, want) { + t.Fatalf("captured schema ids mismatch:\n got = %v\n want = %v", got, want) + } + if len(got) != 34 { + t.Errorf("captured %d schemas, want 34 (audit + 33 router reasoners)", len(got)) + } +} + +// TestEveryRegisteredReasonerPublishesItsPythonSchema is the whole-surface +// assertion: for all 33 router reasoners, what the SDK holds equals what Python +// publishes, and nothing was left on the placeholder. +func TestEveryRegisteredReasonerPublishesItsPythonSchema(t *testing.T) { + published := discoverInputSchemas(t) + registered := RegisterAll(agent.NewRouter(), &appx.Fake{}) + + if len(published) != len(registered) { + t.Fatalf("/discover reports %d reasoners, want %d", len(published), len(registered)) + } + + placeholder := decodeSchema(t, []byte(sdkPlaceholderSchema)) + + for _, name := range registered { + got, ok := published[name] + if !ok { + t.Errorf("%s: not present in /discover", name) + continue + } + want := decodeSchema(t, InputSchema(name)) + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: published schema mismatch\n got = %#v\n want = %#v", name, got, want) + } + if reflect.DeepEqual(got, placeholder) { + t.Errorf("%s: published the SDK placeholder schema — WithInputSchema was not applied", name) + } + } +} + +// TestEveryCapturedRouterSchemaIsRegistered walks the other direction: every +// captured id except `audit` must be reachable on the router. A Python reasoner +// that was never ported would otherwise sit in the capture unnoticed. +func TestEveryCapturedRouterSchemaIsRegistered(t *testing.T) { + published := discoverInputSchemas(t) + + for _, name := range InputSchemaNames() { + if name == NameAudit { + // Registered by internal/node on the Agent, not on the router; + // internal/node's own test asserts it. + continue + } + if _, ok := published[name]; !ok { + t.Errorf("captured schema %q has no registered reasoner", name) + } + } +} + +// TestRepresentativeSchemasMatchPythonSignatures is the non-tautological half: +// four reasoners whose expected schema is transcribed HERE from the Python +// signature, so a bad regeneration of the capture fails too. Between them they +// cover every mapping the node exercises. +func TestRepresentativeSchemasMatchPythonSignatures(t *testing.T) { + published := discoverInputSchemas(t) + + cases := []struct { + // name is the reasoner id; signature is the Python one it transcribes. + name string + signature string + want string + }{ + { + // The simplest shape: one required `str`. + name: NameRunArchitectureMapper, + signature: "run_architecture_mapper(repo_path: str)", + want: `{"type":"object", + "properties":{"repo_path":{"type":"string"}}, + "required":["repo_path"]}`, + }, + { + // dict[str, Any] -> object + additionalProperties; the `= 30` + // default keeps max_files_without_signal OUT of required, while + // `depth` (no default) stays in — and required is in PARAMETER + // order, not alphabetical. + name: NameRunInjectionHunter, + signature: "run_injection_hunter(repo_path: str, recon_context: dict[str, Any], " + + "depth: str, max_files_without_signal: int = 30)", + want: `{"type":"object", + "properties":{"repo_path":{"type":"string"}, + "recon_context":{"type":"object","additionalProperties":true}, + "depth":{"type":"string"}, + "max_files_without_signal":{"type":"integer"}}, + "required":["repo_path","recon_context","depth"]}`, + }, + { + // `int | None` is a PEP 604 union, which _type_to_json_schema's + // Union branch never sees (no __origin__), so it falls through to + // the {"type":"object"} default instead of {"type":"integer"}. + name: NameProvePhase, + signature: "prove_phase(repo_path: str, hunt_result: dict[str, Any], depth: str = \"standard\", " + + "max_provers: int | None = None, max_concurrent_provers: int = 3)", + want: `{"type":"object", + "properties":{"repo_path":{"type":"string"}, + "hunt_result":{"type":"object","additionalProperties":true}, + "depth":{"type":"string"}, + "max_provers":{"type":"object"}, + "max_concurrent_provers":{"type":"integer"}}, + "required":["repo_path","hunt_result"]}`, + }, + { + // list[dict[str, Any]] -> array whose items carry the dict mapping. + name: NameRemediationPhase, + signature: "remediation_phase(repo_path: str, verified_findings: list[dict[str, Any]], " + + "max_concurrent_remediations: int = 3)", + want: `{"type":"object", + "properties":{"repo_path":{"type":"string"}, + "verified_findings":{"type":"array", + "items":{"type":"object","additionalProperties":true}}, + "max_concurrent_remediations":{"type":"integer"}}, + "required":["repo_path","verified_findings"]}`, + }, + { + // list[str] -> array of string, and a reasoner with NO defaulted + // parameter requires all of them. + name: NameRunCWEExpansion, + signature: "run_cwe_expansion(recon_summary: str, strategies: list[str])", + want: `{"type":"object", + "properties":{"recon_summary":{"type":"string"}, + "strategies":{"type":"array","items":{"type":"string"}}}, + "required":["recon_summary","strategies"]}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + want := decodeSchema(t, []byte(tc.want)) + + if got := decodeSchema(t, InputSchema(tc.name)); !reflect.DeepEqual(got, want) { + t.Errorf("captured schema for %s does not match the Python signature\n %s\n got = %#v\n want = %#v", + tc.name, tc.signature, got, want) + } + if got := published[tc.name]; !reflect.DeepEqual(got, want) { + t.Errorf("published schema for %s does not match the Python signature\n %s\n got = %#v\n want = %#v", + tc.name, tc.signature, got, want) + } + }) + } +} + +// TestInputSchemaPanicsOnUnknownReasoner pins the loud-drift contract: a +// registration for a name the capture does not carry must crash rather than +// fall back to the SDK placeholder. +func TestInputSchemaPanicsOnUnknownReasoner(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("InputSchema on an unknown reasoner returned instead of panicking") + } + }() + _ = InputSchema("run_not_a_reasoner") +} + +// TestInputSchemaReturnsACopy proves a caller cannot corrupt the shared fixture +// for every later registration — the schemas are handed out as json.RawMessage, +// which is a mutable slice. +func TestInputSchemaReturnsACopy(t *testing.T) { + first := InputSchema(NameRunArchitectureMapper) + original := append(json.RawMessage(nil), first...) + + for i := range first { + first[i] = 'x' + } + + if second := InputSchema(NameRunArchitectureMapper); !reflect.DeepEqual(second, original) { + t.Errorf("mutating a returned schema changed the fixture: %s", second) + } +} diff --git a/go/internal/reasoners/inputs.go b/go/internal/reasoners/inputs.go new file mode 100644 index 0000000..6342092 --- /dev/null +++ b/go/internal/reasoners/inputs.go @@ -0,0 +1,343 @@ +package reasoners + +import ( + "encoding/json" + + "github.com/Agent-Field/sec-af/go/internal/phases" +) + +// inputs.go declares one struct per reasoner, transcribing the Python +// `async def (...)` signature: json tag == parameter name, field type +// == annotation, and — where the parameter has a non-zero default — a +// `New()` constructor plus an UnmarshalJSON that seeds it before decoding. +// +// The seeding pattern is the schemas package's (`type alias T` strips the +// method set so the inner Unmarshal does not recurse). Its contract matters: +// an ABSENT key keeps the Python default, while a PRESENT key — even one whose +// value is 0 / "" / false / null — overrides it, exactly as Python's +// keyword-argument binding does. +// +// Fields typed `map[string]any` / `[]map[string]any` stand for the Python +// `dict[str, Any]` / `list[dict[str, Any]]` parameters that the adapters hand +// to a pydantic constructor; they are validated in validate.go rather than by +// the bind. +// +// The bind is not the first thing a request meets. handler_input.go runs the +// Python SDK's own `_validate_handler_input` ahead of it — rejecting an +// explicit null on a required parameter, coercing scalars (`"50"` -> 50, +// `5` -> "5", `"yes"` -> true) and shape-checking the dict/list parameters — so +// the map these structs decode has already been through Python's rules. + +// --------------------------------------------------------------------------- +// reasoners/recon.py +// --------------------------------------------------------------------------- + +// RepoPathInput is the signature shared by the three recon reasoners that take +// only a repository path: +// +// run_architecture_mapper(repo_path: str) +// run_dependency_auditor(repo_path: str) +// run_config_scanner(repo_path: str) +type RepoPathInput struct { + RepoPath string `json:"repo_path"` +} + +// ArchitectureInput ports the two deep-recon reasoner signatures: +// +// run_data_flow_mapper(repo_path: str, architecture: dict[str, Any]) +// run_security_context_profiler(repo_path: str, architecture: dict[str, Any]) +// +// `architecture` becomes `ArchitectureMap(**architecture)`. ArchitectureMap has +// no required field of its own, but its five lists hold models that do, so the +// bind goes through phases.BindArchitectureMap rather than a bare afx.Bind. +type ArchitectureInput struct { + RepoPath string `json:"repo_path"` + Architecture map[string]any `json:"architecture"` +} + +// --------------------------------------------------------------------------- +// reasoners/hunt.py +// --------------------------------------------------------------------------- + +// DefaultMaxFilesWithoutSignal ports the `max_files_without_signal: int = 30` +// default every hunter reasoner declares. +const DefaultMaxFilesWithoutSignal = 30 + +// HunterInput is the signature all twelve hunter reasoners share: +// +// run__hunter(repo_path: str, recon_context: dict[str, Any], +// depth: str, max_files_without_signal: int = 30) +// +// `depth` has NO default here (it is positional-or-keyword without one), so a +// request that OMITS it binds the empty string — which every downstream +// _normalize_depth turns into "standard". +// +// This is the ONE rule of handler_input.go's port of the Python SDK's +// `_validate_handler_input` that is deliberately not reproduced: Python answers +// `422 Missing required field: depth` before the handler body runs +// (agent.py:1169-1171), because it can see that the key was absent. The Go +// input structs cannot — an absent scalar is indistinguishable from a zero one +// — so the request proceeds. Everything else that layer does (the +// null-on-required rejection, the int/float/str/bool coercions, the dict/list +// shape checks) IS reproduced; see handler_input.go. +type HunterInput struct { + RepoPath string `json:"repo_path"` + ReconContext map[string]any `json:"recon_context"` + Depth string `json:"depth"` + MaxFilesWithoutSignal int `json:"max_files_without_signal"` +} + +// NewHunterInput returns the Python keyword defaults. +func NewHunterInput() HunterInput { + return HunterInput{MaxFilesWithoutSignal: DefaultMaxFilesWithoutSignal} +} + +// UnmarshalJSON seeds max_files_without_signal=30 before decoding. +func (h *HunterInput) UnmarshalJSON(b []byte) error { + *h = NewHunterInput() + type alias HunterInput + return json.Unmarshal(b, (*alias)(h)) +} + +// DeduplicatorInput ports: +// +// run_deduplicator(findings: list[dict[str, Any]], recon_context: dict[str, Any], repo_path: str) +// +// Field ORDER follows the Python signature (findings first), which is also the +// order the JSON keys are documented in; it has no effect on binding. +type DeduplicatorInput struct { + Findings []map[string]any `json:"findings"` + ReconContext map[string]any `json:"recon_context"` + RepoPath string `json:"repo_path"` +} + +// --------------------------------------------------------------------------- +// reasoners/prove.py +// --------------------------------------------------------------------------- + +// FindingDepthInput ports the three signatures shaped +// `(repo_path, finding, depth)`: +// +// run_dep_reachability(repo_path: str, finding: dict[str, Any], depth: str) +// run_verifier(repo_path: str, finding: dict[str, Any], depth: str) +// run_tracer(repo_path: str, finding: dict[str, Any], depth: str) +type FindingDepthInput struct { + RepoPath string `json:"repo_path"` + Finding map[string]any `json:"finding"` + Depth string `json:"depth"` +} + +// SanitizationInput ports: +// +// run_sanitization_analyzer(repo_path: str, finding: dict[str, Any], +// data_flow: dict[str, Any], depth: str) +type SanitizationInput struct { + RepoPath string `json:"repo_path"` + Finding map[string]any `json:"finding"` + DataFlow map[string]any `json:"data_flow"` + Depth string `json:"depth"` +} + +// ExploitInput ports: +// +// run_exploit_hypothesizer(repo_path: str, finding: dict[str, Any], +// data_flow: dict[str, Any], +// sanitization: dict[str, Any], depth: str) +type ExploitInput struct { + RepoPath string `json:"repo_path"` + Finding map[string]any `json:"finding"` + DataFlow map[string]any `json:"data_flow"` + Sanitization map[string]any `json:"sanitization"` + Depth string `json:"depth"` +} + +// VerdictInput ports: +// +// run_verdict_agent(finding: dict[str, Any], data_flow: dict[str, Any], +// sanitization: dict[str, Any], exploit: dict[str, Any]) +// +// There is no repo_path parameter: the adapter passes the literal "." to +// run_verdict_agent, which never reads it. +type VerdictInput struct { + Finding map[string]any `json:"finding"` + DataFlow map[string]any `json:"data_flow"` + Sanitization map[string]any `json:"sanitization"` + Exploit map[string]any `json:"exploit"` +} + +// RemediationInput ports: +// +// run_remediation(repo_path: str, finding: dict[str, Any]) +// +// `finding` is a VerifiedFinding here (not a RawFinding) — this is the reasoner +// remediation_phase calls. +type RemediationInput struct { + RepoPath string `json:"repo_path"` + Finding map[string]any `json:"finding"` +} + +// RemediationAgentInput ports: +// +// run_remediation_agent(repo_path: str, finding: dict[str, Any], verdict: str, rationale: str) +// +// `finding` is a RawFinding here. Nothing in the pipeline calls this reasoner; +// it is part of the registered surface. +type RemediationAgentInput struct { + RepoPath string `json:"repo_path"` + Finding map[string]any `json:"finding"` + Verdict string `json:"verdict"` + Rationale string `json:"rationale"` +} + +// DastVerifierInput ports: +// +// run_dast_verifier(repo_path: str, finding: dict[str, Any], exploit_payload: str, depth: str) +type DastVerifierInput struct { + RepoPath string `json:"repo_path"` + Finding map[string]any `json:"finding"` + ExploitPayload string `json:"exploit_payload"` + Depth string `json:"depth"` +} + +// CrossServiceInput ports: +// +// run_cross_service_analyzer(repo_path: str, services: list[str], +// findings_summary: str, depth: str) +type CrossServiceInput struct { + RepoPath string `json:"repo_path"` + Services []string `json:"services"` + FindingsSummary string `json:"findings_summary"` + Depth string `json:"depth"` +} + +// --------------------------------------------------------------------------- +// reasoners/phases.py +// --------------------------------------------------------------------------- + +// CWEExpansionInput ports: +// +// run_cwe_expansion(recon_summary: str, strategies: list[str]) +type CWEExpansionInput struct { + ReconSummary string `json:"recon_summary"` + Strategies []string `json:"strategies"` +} + +// ReconPhaseInput ports: +// +// recon_phase(repo_path: str, depth: str = "standard") +type ReconPhaseInput struct { + RepoPath string `json:"repo_path"` + Depth string `json:"depth"` +} + +// NewReconPhaseInput returns the Python keyword defaults. +func NewReconPhaseInput() ReconPhaseInput { + return ReconPhaseInput{Depth: phases.DefaultDepth} +} + +// UnmarshalJSON seeds depth="standard" before decoding. +func (r *ReconPhaseInput) UnmarshalJSON(b []byte) error { + *r = NewReconPhaseInput() + type alias ReconPhaseInput + return json.Unmarshal(b, (*alias)(r)) +} + +// HuntPhaseInput ports: +// +// hunt_phase(repo_path: str, recon_context: dict[str, Any], depth: str = "standard", +// ai_gate: Any | None = None, max_concurrent_hunters: int = 4, +// early_stop_file_threshold: int = 30) +// +// `ai_gate` is kept as a RAW JSON value. It is an object parameter that +// app.py's `.call` into hunt_phase never passes, but hunt_phase is a REGISTERED +// reasoner, so a control-plane caller can send one — and Python binds it (the +// SDK passes an `Any`-hinted parameter through untouched), takes the +// `ai_gate is not None` branch, raises AttributeError on +// `ai_gate.select_strategy(...)` and emits +// +// AI gate failed: 'dict' object has no attribute 'select_strategy', using default strategies +// +// with tags ["hunt","ai_gate","error"] before falling back to the default +// strategies. phases.NewJSONAIGate turns the raw value into exactly that: nil +// (Python's None) for an absent key or `null`, and otherwise a gate that fails +// with the CPython message for that JSON type. Raw bytes rather than `any` +// because encoding/json decodes every number to float64, which would report +// "float" where CPython reports "int". +type HuntPhaseInput struct { + RepoPath string `json:"repo_path"` + ReconContext map[string]any `json:"recon_context"` + Depth string `json:"depth"` + AIGate json.RawMessage `json:"ai_gate"` + MaxConcurrentHunters int `json:"max_concurrent_hunters"` + EarlyStopFileThreshold int `json:"early_stop_file_threshold"` +} + +// NewHuntPhaseInput returns the Python keyword defaults. +func NewHuntPhaseInput() HuntPhaseInput { + return HuntPhaseInput{ + Depth: phases.DefaultDepth, + MaxConcurrentHunters: phases.DefaultMaxConcurrentHunters, + EarlyStopFileThreshold: phases.DefaultEarlyStopFileThreshold, + } +} + +// UnmarshalJSON seeds hunt_phase's three keyword defaults before decoding. +func (h *HuntPhaseInput) UnmarshalJSON(b []byte) error { + *h = NewHuntPhaseInput() + type alias HuntPhaseInput + return json.Unmarshal(b, (*alias)(h)) +} + +// ProvePhaseInput ports: +// +// prove_phase(repo_path: str, hunt_result: dict[str, Any], depth: str = "standard", +// max_provers: int | None = None, max_concurrent_provers: int = 3) +// +// max_provers is a pointer: nil is Python's None (use the depth's cap), and a +// present 0 caps the prover fan-out at zero. +type ProvePhaseInput struct { + RepoPath string `json:"repo_path"` + HuntResult map[string]any `json:"hunt_result"` + Depth string `json:"depth"` + MaxProvers *int `json:"max_provers"` + MaxConcurrentProvers int `json:"max_concurrent_provers"` +} + +// NewProvePhaseInput returns the Python keyword defaults. +func NewProvePhaseInput() ProvePhaseInput { + return ProvePhaseInput{ + Depth: phases.DefaultDepth, + MaxConcurrentProvers: phases.DefaultMaxConcurrentProvers, + } +} + +// UnmarshalJSON seeds prove_phase's keyword defaults before decoding. +func (p *ProvePhaseInput) UnmarshalJSON(b []byte) error { + *p = NewProvePhaseInput() + type alias ProvePhaseInput + return json.Unmarshal(b, (*alias)(p)) +} + +// RemediationPhaseInput ports: +// +// remediation_phase(repo_path: str, verified_findings: list[dict[str, Any]], +// max_concurrent_remediations: int = 3) +type RemediationPhaseInput struct { + RepoPath string `json:"repo_path"` + VerifiedFindings []map[string]any `json:"verified_findings"` + MaxConcurrentRemediations int `json:"max_concurrent_remediations"` +} + +// NewRemediationPhaseInput returns the Python keyword defaults. +func NewRemediationPhaseInput() RemediationPhaseInput { + return RemediationPhaseInput{ + MaxConcurrentRemediations: phases.DefaultMaxConcurrentRemediations, + } +} + +// UnmarshalJSON seeds max_concurrent_remediations=3 before decoding. +func (r *RemediationPhaseInput) UnmarshalJSON(b []byte) error { + *r = NewRemediationPhaseInput() + type alias RemediationPhaseInput + return json.Unmarshal(b, (*alias)(r)) +} diff --git a/go/internal/reasoners/inputs_test.go b/go/internal/reasoners/inputs_test.go new file mode 100644 index 0000000..50d68dd --- /dev/null +++ b/go/internal/reasoners/inputs_test.go @@ -0,0 +1,150 @@ +package reasoners + +// Tests for inputs.go — the transcription of each reasoner's Python signature. +// +// Validation contract: +// +// - an ABSENT key yields the Python keyword default; +// - a PRESENT key overrides it, even when its value is falsy (0, "", null), +// which is what Python's keyword binding does; +// - max_provers distinguishes "absent" (None -> the depth's cap) from an +// explicit 0. + +import ( + "context" + "testing" + + "github.com/Agent-Field/sec-af/go/internal/afx" + "github.com/Agent-Field/sec-af/go/internal/phases" +) + +func TestHunterInputDefaults(t *testing.T) { + got, err := afx.Bind[HunterInput](map[string]any{"repo_path": "/r", "depth": "quick"}) + if err != nil { + t.Fatalf("Bind: %v", err) + } + if got.MaxFilesWithoutSignal != 30 { + t.Errorf("max_files_without_signal = %d, want 30", got.MaxFilesWithoutSignal) + } + + got, err = afx.Bind[HunterInput](map[string]any{"max_files_without_signal": 0}) + if err != nil { + t.Fatalf("Bind: %v", err) + } + if got.MaxFilesWithoutSignal != 0 { + t.Errorf("an explicit 0 must override the default, got %d", got.MaxFilesWithoutSignal) + } +} + +func TestReconPhaseInputDefaults(t *testing.T) { + got, err := afx.Bind[ReconPhaseInput](map[string]any{"repo_path": "/r"}) + if err != nil { + t.Fatalf("Bind: %v", err) + } + if got.Depth != phases.DefaultDepth { + t.Errorf("depth = %q, want %q", got.Depth, phases.DefaultDepth) + } + + got, err = afx.Bind[ReconPhaseInput](map[string]any{"repo_path": "/r", "depth": ""}) + if err != nil { + t.Fatalf("Bind: %v", err) + } + if got.Depth != "" { + t.Errorf("an explicit empty depth must override the default, got %q", got.Depth) + } +} + +func TestHuntPhaseInputDefaults(t *testing.T) { + got, err := afx.Bind[HuntPhaseInput](map[string]any{"repo_path": "/r"}) + if err != nil { + t.Fatalf("Bind: %v", err) + } + if got.Depth != phases.DefaultDepth { + t.Errorf("depth = %q, want %q", got.Depth, phases.DefaultDepth) + } + if got.MaxConcurrentHunters != phases.DefaultMaxConcurrentHunters { + t.Errorf("max_concurrent_hunters = %d, want %d", got.MaxConcurrentHunters, phases.DefaultMaxConcurrentHunters) + } + if got.EarlyStopFileThreshold != phases.DefaultEarlyStopFileThreshold { + t.Errorf("early_stop_file_threshold = %d, want %d", got.EarlyStopFileThreshold, phases.DefaultEarlyStopFileThreshold) + } + // `ai_gate: Any | None = None` — absent binds to Python's None. + if gate := phases.NewJSONAIGate(got.AIGate); gate != nil { + t.Errorf("an absent ai_gate must be None, got %v", gate) + } +} + +// TestHuntPhaseInputBindsAIGate: hunt_phase is a REGISTERED reasoner, so a +// control-plane caller can send `ai_gate`. Python binds the raw JSON (the SDK +// passes an `Any`-hinted parameter straight through) and takes the +// `ai_gate is not None` branch; the Go adapter must reach the same branch and +// report the same CPython type name in the note it produces. +func TestHuntPhaseInputBindsAIGate(t *testing.T) { + cases := []struct { + value any + wantType string + }{ + {map[string]any{"model": "x"}, "dict"}, + {[]any{"a"}, "list"}, + {"gate", "str"}, + {1, "int"}, + {1.5, "float"}, + {true, "bool"}, + } + for _, tc := range cases { + got, err := afx.Bind[HuntPhaseInput](map[string]any{"repo_path": "/r", "ai_gate": tc.value}) + if err != nil { + t.Fatalf("Bind(%v): %v", tc.value, err) + } + gate := phases.NewJSONAIGate(got.AIGate) + if gate == nil { + t.Fatalf("ai_gate=%v must be non-None", tc.value) + } + _, err = gate.SelectStrategy(context.Background(), "", "standard", nil) + want := "'" + tc.wantType + "' object has no attribute 'select_strategy'" + if err == nil || err.Error() != want { + t.Errorf("ai_gate=%v -> err %v, want %q", tc.value, err, want) + } + } + + // An explicit null is Python's None. + got, err := afx.Bind[HuntPhaseInput](map[string]any{"repo_path": "/r", "ai_gate": nil}) + if err != nil { + t.Fatalf("Bind: %v", err) + } + if gate := phases.NewJSONAIGate(got.AIGate); gate != nil { + t.Errorf("ai_gate=null must be None, got %v", gate) + } +} + +func TestProvePhaseInputDefaults(t *testing.T) { + got, err := afx.Bind[ProvePhaseInput](map[string]any{"repo_path": "/r"}) + if err != nil { + t.Fatalf("Bind: %v", err) + } + if got.MaxProvers != nil { + t.Errorf("max_provers = %v, want nil (Python None)", *got.MaxProvers) + } + if got.MaxConcurrentProvers != phases.DefaultMaxConcurrentProvers { + t.Errorf("max_concurrent_provers = %d, want %d", got.MaxConcurrentProvers, phases.DefaultMaxConcurrentProvers) + } + + got, err = afx.Bind[ProvePhaseInput](map[string]any{"repo_path": "/r", "max_provers": 0}) + if err != nil { + t.Fatalf("Bind: %v", err) + } + if got.MaxProvers == nil || *got.MaxProvers != 0 { + t.Errorf("an explicit 0 must bind as a pointer to 0, got %v", got.MaxProvers) + } +} + +func TestRemediationPhaseInputDefaults(t *testing.T) { + got, err := afx.Bind[RemediationPhaseInput](map[string]any{"repo_path": "/r"}) + if err != nil { + t.Fatalf("Bind: %v", err) + } + if got.MaxConcurrentRemediations != phases.DefaultMaxConcurrentRemediations { + t.Errorf("max_concurrent_remediations = %d, want %d", + got.MaxConcurrentRemediations, phases.DefaultMaxConcurrentRemediations) + } +} diff --git a/go/internal/reasoners/names.go b/go/internal/reasoners/names.go new file mode 100644 index 0000000..1237607 --- /dev/null +++ b/go/internal/reasoners/names.go @@ -0,0 +1,120 @@ +package reasoners + +// names.go declares the SEC-AF reasoner surface: the 33 names the Python +// AgentRouter registers (reasoners/recon.py, hunt.py, prove.py, phases.py) plus +// the one top-level `audit` reasoner app.py registers on the agent itself. +// +// The constants exist so a registration, a `.call` target and a test all spell +// a reasoner the same way; Names fixes the ORDER, which DESIGN.md §3 pins. + +// The 33 router reasoner names, in DESIGN.md §3 order. The order is the Python +// import order of reasoners/__init__.py (recon -> hunt -> prove -> phases) and, +// within each module, the order the `@router.reasoner()` decorators appear. +const ( + // --- reasoners/recon.py --- + + NameRunArchitectureMapper = "run_architecture_mapper" + NameRunDependencyAuditor = "run_dependency_auditor" + NameRunConfigScanner = "run_config_scanner" + NameRunDataFlowMapper = "run_data_flow_mapper" + NameRunSecurityContextProfiler = "run_security_context_profiler" + + // --- reasoners/hunt.py --- + + NameRunInjectionHunter = "run_injection_hunter" + NameRunDosHunter = "run_dos_hunter" + NameRunSSRFHunter = "run_ssrf_hunter" + NameRunAuthHunter = "run_auth_hunter" + NameRunXSSHunter = "run_xss_hunter" + NameRunCryptoHunter = "run_crypto_hunter" + NameRunBusinessLogicHunter = "run_business_logic_hunter" + // NameRunLogicBugsHunter is an ALIAS reasoner: its Python body forwards to + // run_business_logic_hunter (the reasoner, not the agent function), so it + // emits no note of its own and its DAG child is indistinguishable from a + // direct run_business_logic_hunter call. + NameRunLogicBugsHunter = "run_logic_bugs_hunter" + NameRunDataExposureHunter = "run_data_exposure_hunter" + NameRunSupplyChainHunter = "run_supply_chain_hunter" + NameRunConfigSecretsHunter = "run_config_secrets_hunter" + NameRunAPISecurityHunter = "run_api_security_hunter" + NameRunDeduplicator = "run_deduplicator" + + // --- reasoners/prove.py --- + + NameRunDepReachability = "run_dep_reachability" + NameRunVerifier = "run_verifier" + NameRunTracer = "run_tracer" + NameRunSanitizationAnalyzer = "run_sanitization_analyzer" + NameRunExploitHypothesizer = "run_exploit_hypothesizer" + NameRunVerdictAgent = "run_verdict_agent" + NameRunRemediation = "run_remediation" + NameRunRemediationAgent = "run_remediation_agent" + NameRunDastVerifier = "run_dast_verifier" + NameRunCrossServiceAnalyzer = "run_cross_service_analyzer" + + // --- reasoners/phases.py --- + + // NameRunCWEExpansion is REGISTERED but never `.call`ed: hunt_phase invokes + // expand_cwes_for_hunt in process (an `.ai()` call), so this reasoner + // contributes no DAG node. See DESIGN.md §3. + NameRunCWEExpansion = "run_cwe_expansion" + + NameReconPhase = "recon_phase" + NameHuntPhase = "hunt_phase" + NameProvePhase = "prove_phase" + NameRemediationPhase = "remediation_phase" +) + +// NameAudit is the ONE externally driven reasoner. Python registers it with +// `@app.reasoner()` on the Agent, not on the AgentRouter, so it carries none of +// the router's tags. internal/node owns its handler. +const NameAudit = "audit" + +// Names is the canonical ordered list of the 33 router reasoner names — +// DESIGN.md §3's list verbatim. RegisterAll registers exactly these, in exactly +// this order, and returns the same slice content as its bookkeeping. +var Names = []string{ + NameRunArchitectureMapper, + NameRunDependencyAuditor, + NameRunConfigScanner, + NameRunDataFlowMapper, + NameRunSecurityContextProfiler, + NameRunInjectionHunter, + NameRunDosHunter, + NameRunSSRFHunter, + NameRunAuthHunter, + NameRunXSSHunter, + NameRunCryptoHunter, + NameRunBusinessLogicHunter, + NameRunLogicBugsHunter, + NameRunDataExposureHunter, + NameRunSupplyChainHunter, + NameRunConfigSecretsHunter, + NameRunAPISecurityHunter, + NameRunDeduplicator, + NameRunDepReachability, + NameRunVerifier, + NameRunTracer, + NameRunSanitizationAnalyzer, + NameRunExploitHypothesizer, + NameRunVerdictAgent, + NameRunRemediation, + NameRunRemediationAgent, + NameRunDastVerifier, + NameRunCrossServiceAnalyzer, + NameRunCWEExpansion, + NameReconPhase, + NameHuntPhase, + NameProvePhase, + NameRemediationPhase, +} + +// RouterTags ports `AgentRouter(tags=["security", "audit", "red-team"])` +// (reasoners/__init__.py:4). internal/node passes it as +// agent.RouterOptions{Tags: RouterTags}, which the SDK merges into every +// handler the router carries — the Go equivalent of the AgentRouter's +// tag inheritance. +// +// These are SEMANTIC domain tags, not node-identity tags: callers reach this +// node through node_id=sec-af, e.g. `sec-af.audit`. +var RouterTags = []string{"security", "audit", "red-team"} diff --git a/go/internal/reasoners/phases.go b/go/internal/reasoners/phases.go new file mode 100644 index 0000000..4702b3a --- /dev/null +++ b/go/internal/reasoners/phases.go @@ -0,0 +1,77 @@ +package reasoners + +import ( + "context" + + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/phases" +) + +// phases.go ports the five reasoner ENTRY POINTS declared in +// src/sec_af/reasoners/phases.py. The bodies live in internal/phases; these +// adapters only bind the request and thread the node id. +// +// nodeID is a parameter rather than a package variable so a test can pin the +// `.call` targets. RegisterAll reads phases.NodeID() ONCE and closes over the +// result, which is Python's `NODE_ID = os.getenv("NODE_ID", "sec-af")` at +// module import: a node that starts with NODE_ID=sec-af-go keeps calling +// sec-af-go.* for its whole life even if the variable is later changed. + +// RunCWEExpansion ports `run_cwe_expansion(recon_summary, strategies)` +// (reasoners/phases.py:139): +// +// additional = await expand_cwes_for_hunt(recon_summary, strategies) +// return {"additional_cwes": additional} +// +// Python parity: no note, and no error path — expand_cwes_for_hunt swallows +// every failure and returns []. It is registered but never `.call`ed +// (hunt_phase runs expand_cwes_for_hunt in process), so it draws no DAG node. +func RunCWEExpansion(ctx context.Context, app appx.App, in CWEExpansionInput) (map[string]any, error) { + return phases.RunCWEExpansion(ctx, app, in.ReconSummary, in.Strategies), nil +} + +// ReconPhase ports the `recon_phase` reasoner (reasoners/phases.py:152). +func ReconPhase(ctx context.Context, app appx.App, nodeID string, in ReconPhaseInput) (map[string]any, error) { + return phases.ReconPhase(ctx, app, nodeID, in.RepoPath, in.Depth) +} + +// HuntPhase ports the `hunt_phase` reasoner (reasoners/phases.py:239). +// +// Python parity: `ai_gate` is always None on the live path (app.py's `.call` +// omits it), so phases.NewJSONAIGate yields nil there. A direct caller that +// supplies one takes the `ai_gate is not None` branch and gets the +// "AI gate failed: ..." note — see HuntPhaseInput. +func HuntPhase(ctx context.Context, app appx.App, nodeID string, in HuntPhaseInput) (map[string]any, error) { + return phases.HuntPhase( + ctx, app, nodeID, + in.RepoPath, + in.ReconContext, + in.Depth, + phases.NewJSONAIGate(in.AIGate), + in.MaxConcurrentHunters, + in.EarlyStopFileThreshold, + ) +} + +// ProvePhase ports the `prove_phase` reasoner (reasoners/phases.py:381). +func ProvePhase(ctx context.Context, app appx.App, nodeID string, in ProvePhaseInput) (map[string]any, error) { + return phases.ProvePhase( + ctx, app, nodeID, + in.RepoPath, + in.HuntResult, + in.Depth, + in.MaxProvers, + in.MaxConcurrentProvers, + ) +} + +// RemediationPhase ports the `remediation_phase` reasoner +// (reasoners/phases.py:487). +func RemediationPhase(ctx context.Context, app appx.App, nodeID string, in RemediationPhaseInput) (map[string]any, error) { + return phases.RemediationPhase( + ctx, app, nodeID, + in.RepoPath, + in.VerifiedFindings, + in.MaxConcurrentRemediations, + ) +} diff --git a/go/internal/reasoners/phases_test.go b/go/internal/reasoners/phases_test.go new file mode 100644 index 0000000..6f21efc --- /dev/null +++ b/go/internal/reasoners/phases_test.go @@ -0,0 +1,196 @@ +package reasoners + +// Tests for the five reasoner entry points of src/sec_af/reasoners/phases.py +// as seen from the ADAPTER layer: input binding, node-id threading and the +// `.call` targets the phase draws. The phase bodies themselves are covered in +// internal/phases. +// +// Validation contract: +// +// - the four *_phase adapters prefix every `.call` target with the node id +// read from NODE_ID at registration time (default "sec-af"); +// - hunt_phase is invoked with ai_gate=None, so the AI strategy gate is never +// consulted and the default strategy list is used; +// - run_cwe_expansion emits no note, never fails, and returns +// {"additional_cwes": [...]} — [] when the AI gate is unavailable; +// - the keyword defaults of every phase signature survive a request body that +// omits them. + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + + "github.com/Agent-Field/sec-af/go/internal/appx" +) + +func TestRunCWEExpansionSwallowsGateFailure(t *testing.T) { + fake := &appx.Fake{} // no AIFn: every .ai() call fails + got, err := RunCWEExpansion(context.Background(), fake, CWEExpansionInput{ + ReconSummary: "python (django), 100 LOC", + Strategies: []string{"injection", "xss"}, + }) + if err != nil { + t.Fatalf("RunCWEExpansion must never fail: %v", err) + } + cwes, ok := got["additional_cwes"].([]string) + if !ok { + t.Fatalf("additional_cwes = %#v, want []string", got["additional_cwes"]) + } + if len(cwes) != 0 { + t.Errorf("additional_cwes = %v, want [] on a failed gate", cwes) + } + if len(fake.Notes) != 0 { + t.Errorf("run_cwe_expansion must emit no note, got %v", fake.NoteMessages()) + } +} + +// TestReconPhaseCallTargetsCarryNodeID pins the DAG edges recon_phase draws and +// the node-id prefix, through the REGISTERED handler (so the nodeID that +// RegisterAll captured is what is exercised). +func TestReconPhaseCallTargetsCarryNodeID(t *testing.T) { + t.Setenv("NODE_ID", "sec-af-go") + + fake := &appx.Fake{ + CallFn: func(_ context.Context, target string, _ map[string]any) (map[string]any, error) { + switch { + case strings.HasSuffix(target, ".run_security_context_profiler"): + return map[string]any{"auth_model": "jwt", "auth_details": "bearer"}, nil + default: + return map[string]any{}, nil + } + }, + } + + out := executeRegistered(t, fake, "recon_phase", map[string]any{ + "repo_path": t.TempDir(), + "depth": "quick", + }) + if out == nil { + t.Fatal("recon_phase returned no payload") + } + + // depth=quick: the three unconditional children only. + want := []string{ + "sec-af-go.run_architecture_mapper", + "sec-af-go.run_dependency_auditor", + "sec-af-go.run_config_scanner", + } + got := fake.CallTargets() + if len(got) != len(want) { + t.Fatalf("call targets = %v, want the %d quick-depth children", got, len(want)) + } + seen := map[string]bool{} + for _, target := range got { + seen[target] = true + } + for _, target := range want { + if !seen[target] { + t.Errorf("missing DAG edge %q (got %v)", target, got) + } + } +} + +// TestPhaseAdaptersBindKeywordDefaults drives each phase through its registered +// handler with a body that omits every optional key, and asserts the Python +// default reached the phase — observed through the kwargs of the `.call` the +// phase makes. +func TestPhaseAdaptersBindKeywordDefaults(t *testing.T) { + t.Setenv("NODE_ID", "sec-af") + + t.Run("recon_phase depth defaults to standard", func(t *testing.T) { + fake := &appx.Fake{ + CallFn: func(_ context.Context, target string, _ map[string]any) (map[string]any, error) { + if strings.HasSuffix(target, ".run_security_context_profiler") { + return map[string]any{"auth_model": "jwt", "auth_details": ""}, nil + } + return map[string]any{}, nil + }, + } + executeRegistered(t, fake, "recon_phase", map[string]any{"repo_path": t.TempDir()}) + + // "standard" is not "quick", so the two deep-recon children run. + if len(fake.Calls) != 5 { + t.Fatalf("calls = %v, want 5 (depth defaulted to standard)", fake.CallTargets()) + } + }) + + t.Run("prove_phase max_provers defaults to None", func(t *testing.T) { + fake := &appx.Fake{ + CallFn: func(context.Context, string, map[string]any) (map[string]any, error) { + return map[string]any{}, nil + }, + } + out := executeRegistered(t, fake, "prove_phase", map[string]any{ + "repo_path": t.TempDir(), + "hunt_result": map[string]any{}, + }) + // No findings -> no verifier calls, and the phase reports the empty shape. + if len(fake.Calls) != 0 { + t.Errorf("calls = %v, want none for an empty hunt result", fake.CallTargets()) + } + for _, key := range []string{"verified", "total_selected", "total_findings", "not_verified", "drop_summary"} { + if _, ok := out[key]; !ok { + t.Errorf("prove_phase result is missing %q", key) + } + } + }) + + t.Run("remediation_phase with nothing to remediate", func(t *testing.T) { + fake := &appx.Fake{} + out := executeRegistered(t, fake, "remediation_phase", map[string]any{ + "repo_path": t.TempDir(), + "verified_findings": []any{}, + }) + if _, ok := out["verified"]; !ok { + t.Error("remediation_phase result is missing \"verified\"") + } + if len(fake.Notes) != 2 { + t.Fatalf("notes = %v, want the starting + no-op pair", fake.NoteMessages()) + } + if fake.Notes[1].Message != "No findings need remediation" { + t.Errorf("second note = %q, want the no-op note", fake.Notes[1].Message) + } + }) +} + +// executeRegistered runs one reasoner through the router registration path on a +// real *agent.Agent, which is what the control plane does. +func executeRegistered(t *testing.T, app appx.App, name string, input map[string]any) map[string]any { + t.Helper() + + a, err := agent.New(agent.Config{ + NodeID: "sec-af", + Version: "0.1.0", + AgentFieldURL: "http://127.0.0.1:1", + ListenAddress: ":0", + }) + if err != nil { + t.Fatalf("agent.New: %v", err) + } + router := agent.NewRouter() + RegisterAll(router, app) + a.IncludeRouter(router, agent.RouterOptions{Tags: RouterTags}) + + got, err := a.Execute(context.Background(), name, input) + if err != nil { + var exec *agent.ExecuteError + if errors.As(err, &exec) { + t.Fatalf("Execute(%s): %d %s", name, exec.StatusCode, exec.Message) + } + t.Fatalf("Execute(%s): %v", name, err) + } + + out, ok := got.(map[string]any) + if !ok { + // The SDK hands the handler's return value back unchanged; every phase + // returns a map. + b, _ := json.Marshal(got) + t.Fatalf("Execute(%s) returned %T: %s", name, got, b) + } + return out +} diff --git a/go/internal/reasoners/prove.go b/go/internal/reasoners/prove.go new file mode 100644 index 0000000..0f2975a --- /dev/null +++ b/go/internal/reasoners/prove.go @@ -0,0 +1,291 @@ +package reasoners + +import ( + "context" + + "github.com/Agent-Field/sec-af/go/internal/afx" + proveagent "github.com/Agent-Field/sec-af/go/internal/agents/prove" + remediationagent "github.com/Agent-Field/sec-af/go/internal/agents/remediation" + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/phases" + "github.com/Agent-Field/sec-af/go/internal/schemas" + "github.com/Agent-Field/sec-af/go/internal/scoring" +) + +// prove.go ports src/sec_af/reasoners/prove.py — the ten PROVE-side adapters +// plus `_coerce_verifier_finding`, the one non-trivial helper in the file. + +// CoerceVerifierFinding ports `_coerce_verifier_finding(finding)` +// (reasoners/prove.py:28): +// +// try: +// return RawFinding.model_validate(finding) +// except Exception: +// view = FindingForVerifier.model_validate(finding) +// return RawFinding( +// id=view.id, +// hunter_strategy="phase_boundary_projection", +// title=view.title, +// description=view.data_flow_summary or view.title, +// finding_type=FindingType.SAST, +// cwe_id=view.cwe_id, +// cwe_name=view.cwe_id, +// file_path=view.file_path, +// start_line=view.start_line, +// end_line=view.end_line, +// function_name=view.function_name, +// code_snippet=view.code_snippet, +// estimated_severity=apply_cwe_severity_floor(view.cwe_id, Severity.MEDIUM), +// confidence=Confidence.MEDIUM, +// fingerprint=view.id, +// ) +// +// This is the reasoner boundary that makes prove_phase work: prove_phase sends +// `finding.for_verifier().model_dump()`, a ten-field FindingForVerifier +// projection, which cannot satisfy RawFinding's twelve required fields — so the +// fallback branch is the LIVE path, not an edge case. See validate.go for why +// the required-field check has to be real. +// +// Python parity in the fallback: +// +// - `cwe_name` is set to the CWE **id**, not a name. Reproduced. +// - `description` uses `view.data_flow_summary or view.title` — Python +// truthiness, so an empty summary falls back to the title. +// - the reconstructed RawFinding's `fingerprint` is the view's id, and its +// `id` is the view's id too, so the two match (a fresh RawFinding would +// have minted two different uuid4s). +// - `related_files` is not passed and keeps `default_factory=list` -> []; +// `data_flow` keeps None. NewRawFinding() seeds exactly that (and then the +// explicit ID/Fingerprint assignments overwrite its minted uuid4s). +// - the `except Exception` is BROAD: any failure to build a RawFinding — not +// just a missing field — takes the fallback. A payload that is neither +// model still fails, on the FindingForVerifier validation. +func CoerceVerifierFinding(finding map[string]any) (schemas.RawFinding, error) { + if raw, err := bindRawFinding(finding); err == nil { + return raw, nil + } + + view, err := bindFindingForVerifier(finding) + if err != nil { + return schemas.RawFinding{}, err + } + + description := view.DataFlowSummary + if description == "" { + description = view.Title + } + + out := schemas.NewRawFinding() + out.ID = view.ID + out.HunterStrategy = "phase_boundary_projection" + out.Title = view.Title + out.Description = description + out.FindingType = schemas.FindingTypeSast + out.CweID = view.CweID + out.CweName = view.CweID + out.FilePath = view.FilePath + out.StartLine = view.StartLine + out.EndLine = view.EndLine + out.FunctionName = view.FunctionName + out.CodeSnippet = view.CodeSnippet + out.EstimatedSeverity = scoring.ApplyCWESeverityFloor(view.CweID, schemas.SeverityMedium) + out.Confidence = schemas.ConfidenceMedium + out.Fingerprint = view.ID + return out, nil +} + +// RunDepReachability ports `run_dep_reachability(repo_path, finding, depth)` +// (reasoners/prove.py:52). +// +// Python parity: `finding` is forwarded to the agent as the RAW DICT — this is +// the one prove reasoner that does not build a model first, because +// agents/prove/dep_reachability.py renders the dict into its prompt directly. +func RunDepReachability(ctx context.Context, app appx.App, in FindingDepthInput) (map[string]any, error) { + app.Note(ctx, "Dependency reachability analyzer starting", "prove", "dep-reachability") + result, err := proveagent.RunDepReachability(ctx, app, in.RepoPath, in.Finding, in.Depth) + if err != nil { + return nil, err + } + return afx.ToMap(result) +} + +// RunVerifier ports `run_verifier(repo_path, finding, depth)` +// (reasoners/prove.py:60) — the reasoner prove_phase fans out over. +func RunVerifier(ctx context.Context, app appx.App, in FindingDepthInput) (map[string]any, error) { + app.Note(ctx, "Verifier starting", "prove", "verifier") + finding, err := CoerceVerifierFinding(in.Finding) + if err != nil { + return nil, err + } + result, err := proveagent.RunVerifier(ctx, app, in.RepoPath, finding, in.Depth) + if err != nil { + return nil, err + } + return afx.ToMap(result) +} + +// RunTracer ports `run_tracer(repo_path, finding, depth)` +// (reasoners/prove.py:69). +// +// Python parity: `RawFinding(**finding)` here is the STRICT constructor, not +// _coerce_verifier_finding — a FindingForVerifier projection sent to this +// reasoner raises rather than being adapted. +func RunTracer(ctx context.Context, app appx.App, in FindingDepthInput) (map[string]any, error) { + app.Note(ctx, "Tracer starting", "prove", "tracer") + finding, err := bindRawFinding(in.Finding) + if err != nil { + return nil, err + } + result, err := proveagent.RunTracer(ctx, app, in.RepoPath, finding, in.Depth) + if err != nil { + return nil, err + } + return afx.ToMap(result) +} + +// RunSanitizationAnalyzer ports +// `run_sanitization_analyzer(repo_path, finding, data_flow, depth)` +// (reasoners/prove.py:77). +func RunSanitizationAnalyzer(ctx context.Context, app appx.App, in SanitizationInput) (map[string]any, error) { + app.Note(ctx, "Sanitization analyzer starting", "prove", "sanitization") + finding, err := bindRawFinding(in.Finding) + if err != nil { + return nil, err + } + flow, err := bindDataFlowTrace(in.DataFlow) + if err != nil { + return nil, err + } + result, err := proveagent.RunSanitizationAnalyzer(ctx, app, in.RepoPath, finding, flow, in.Depth) + if err != nil { + return nil, err + } + return afx.ToMap(result) +} + +// RunExploitHypothesizer ports +// `run_exploit_hypothesizer(repo_path, finding, data_flow, sanitization, depth)` +// (reasoners/prove.py:91). +func RunExploitHypothesizer(ctx context.Context, app appx.App, in ExploitInput) (map[string]any, error) { + app.Note(ctx, "Exploit hypothesizer starting", "prove", "exploit") + finding, err := bindRawFinding(in.Finding) + if err != nil { + return nil, err + } + flow, err := bindDataFlowTrace(in.DataFlow) + if err != nil { + return nil, err + } + sanitization, err := bindSanitizationResult(in.Sanitization) + if err != nil { + return nil, err + } + result, err := proveagent.RunExploitHypothesizer(ctx, app, in.RepoPath, finding, flow, sanitization, in.Depth) + if err != nil { + return nil, err + } + return afx.ToMap(result) +} + +// RunVerdictAgent ports +// `run_verdict_agent(finding, data_flow, sanitization, exploit)` +// (reasoners/prove.py:109). +// +// Python parity: the reasoner has no repo_path parameter and passes the literal +// "." to the agent, which never reads it. +func RunVerdictAgent(ctx context.Context, app appx.App, in VerdictInput) (map[string]any, error) { + app.Note(ctx, "Verdict agent starting", "prove", "verdict") + finding, err := bindRawFinding(in.Finding) + if err != nil { + return nil, err + } + flow, err := bindDataFlowTrace(in.DataFlow) + if err != nil { + return nil, err + } + sanitization, err := bindSanitizationResult(in.Sanitization) + if err != nil { + return nil, err + } + exploit, err := bindExploitHypothesis(in.Exploit) + if err != nil { + return nil, err + } + result, err := proveagent.RunVerdictAgent(ctx, app, ".", finding, flow, sanitization, exploit) + if err != nil { + return nil, err + } + return afx.ToMap(result) +} + +// RunRemediation ports `run_remediation(repo_path, finding)` +// (reasoners/prove.py:131) — the reasoner remediation_phase fans out over. +// +// finding_model = VerifiedFinding(**finding) +// result = await generate_remediation(router, repo_path, finding_model) +// return RemediationSuggestion.model_validate(result).model_dump() +// +// Python parity: the note is "Remediation agent starting" — the same string +// run_remediation_agent uses, tags included. +// +// The trailing `RemediationSuggestion.model_validate(result)` re-validates a +// value that generate_remediation already returned as a RemediationSuggestion. +// It is a no-op round trip; GenerateRemediation returns the typed value here, +// so ToMap is the whole of it. +func RunRemediation(ctx context.Context, app appx.App, in RemediationInput) (map[string]any, error) { + app.Note(ctx, "Remediation agent starting", "prove", "remediation") + finding, err := phases.BindVerifiedFinding(in.Finding) + if err != nil { + return nil, err + } + result, err := remediationagent.GenerateRemediation(ctx, app, in.RepoPath, finding) + if err != nil { + return nil, err + } + return afx.ToMap(result) +} + +// RunRemediationAgent ports +// `run_remediation_agent(repo_path, finding, verdict, rationale)` +// (reasoners/prove.py:145). `finding` is a RawFinding here, unlike +// run_remediation's VerifiedFinding. +func RunRemediationAgent(ctx context.Context, app appx.App, in RemediationAgentInput) (map[string]any, error) { + app.Note(ctx, "Remediation agent starting", "prove", "remediation") + finding, err := bindRawFinding(in.Finding) + if err != nil { + return nil, err + } + result, err := remediationagent.RunRemediation(ctx, app, in.RepoPath, finding, in.Verdict, in.Rationale) + if err != nil { + return nil, err + } + return afx.ToMap(result) +} + +// RunDastVerifier ports +// `run_dast_verifier(repo_path, finding, exploit_payload, depth)` +// (reasoners/prove.py:158). +func RunDastVerifier(ctx context.Context, app appx.App, in DastVerifierInput) (map[string]any, error) { + app.Note(ctx, "DAST verifier starting", "prove", "dast") + finding, err := bindRawFinding(in.Finding) + if err != nil { + return nil, err + } + result, err := proveagent.RunDastVerifier(ctx, app, in.RepoPath, finding, in.ExploitPayload, in.Depth) + if err != nil { + return nil, err + } + return afx.ToMap(result) +} + +// RunCrossServiceAnalyzer ports +// `run_cross_service_analyzer(repo_path, services, findings_summary, depth)` +// (reasoners/prove.py:169). +func RunCrossServiceAnalyzer(ctx context.Context, app appx.App, in CrossServiceInput) (map[string]any, error) { + app.Note(ctx, "Cross-service analyzer starting", "prove", "cross-service") + result, err := proveagent.RunCrossServiceAnalyzer(ctx, app, in.RepoPath, in.Services, in.FindingsSummary, in.Depth) + if err != nil { + return nil, err + } + return afx.ToMap(result) +} diff --git a/go/internal/reasoners/prove_test.go b/go/internal/reasoners/prove_test.go new file mode 100644 index 0000000..2406104 --- /dev/null +++ b/go/internal/reasoners/prove_test.go @@ -0,0 +1,263 @@ +package reasoners + +// Tests for src/sec_af/reasoners/prove.py. +// +// Validation contract: +// +// - each adapter emits its own note (message + tags); run_remediation and +// run_remediation_agent deliberately share the SAME note; +// - _coerce_verifier_finding returns the payload as a RawFinding when it +// validates, and otherwise projects a FindingForVerifier into a RawFinding +// with hunter_strategy="phase_boundary_projection", cwe_name = the CWE ID, +// description falling back to the title, the CWE severity floor applied +// over MEDIUM, confidence MEDIUM and fingerprint = the view's id; +// - the fallback is the LIVE path: prove_phase sends +// finding.for_verifier().model_dump(), which cannot satisfy RawFinding; +// - the strict adapters (tracer, sanitization, exploit, verdict, dast, +// remediation_agent) reject a payload that is not their model. + +import ( + "context" + "testing" + + "github.com/Agent-Field/sec-af/go/internal/schemas" + "github.com/Agent-Field/sec-af/go/internal/scoring" +) + +func TestCoerceVerifierFindingKeepsAValidRawFinding(t *testing.T) { + got, err := CoerceVerifierFinding(rawFindingPayload()) + if err != nil { + t.Fatalf("CoerceVerifierFinding: %v", err) + } + if got.HunterStrategy != "injection" { + t.Errorf("hunter_strategy = %q, want the payload's own value", got.HunterStrategy) + } + if got.CweName != "SQL Injection" { + t.Errorf("cwe_name = %q, want the payload's own value", got.CweName) + } +} + +func TestCoerceVerifierFindingProjectsTheVerifierView(t *testing.T) { + view := verifierProjection(t) + + got, err := CoerceVerifierFinding(view) + if err != nil { + t.Fatalf("CoerceVerifierFinding: %v", err) + } + + if got.HunterStrategy != "phase_boundary_projection" { + t.Errorf("hunter_strategy = %q, want phase_boundary_projection", got.HunterStrategy) + } + if got.CweName != got.CweID { + t.Errorf("cwe_name = %q, want it to mirror cwe_id %q (Python parity)", got.CweName, got.CweID) + } + if got.FindingType != schemas.FindingTypeSast { + t.Errorf("finding_type = %q, want sast", got.FindingType) + } + if got.Confidence != schemas.ConfidenceMedium { + t.Errorf("confidence = %q, want medium", got.Confidence) + } + wantSeverity := scoring.ApplyCWESeverityFloor(got.CweID, schemas.SeverityMedium) + if got.EstimatedSeverity != wantSeverity { + t.Errorf("estimated_severity = %q, want %q (CWE floor over MEDIUM)", got.EstimatedSeverity, wantSeverity) + } + if got.Fingerprint != view["id"] { + t.Errorf("fingerprint = %q, want the view id %v", got.Fingerprint, view["id"]) + } + if got.ID != view["id"] { + t.Errorf("id = %q, want the view id %v", got.ID, view["id"]) + } + if got.RelatedFiles == nil { + t.Error("related_files = nil, want [] (default_factory=list)") + } +} + +// TestCoerceVerifierFindingDescriptionFallback pins +// `view.data_flow_summary or view.title` — Python truthiness, so an EMPTY +// summary falls back to the title. +func TestCoerceVerifierFindingDescriptionFallback(t *testing.T) { + view := verifierProjection(t) + + view["data_flow_summary"] = "" + got, err := CoerceVerifierFinding(view) + if err != nil { + t.Fatalf("CoerceVerifierFinding: %v", err) + } + if got.Description != got.Title { + t.Errorf("description = %q, want the title %q", got.Description, got.Title) + } + + view["data_flow_summary"] = "src -> sink" + got, err = CoerceVerifierFinding(view) + if err != nil { + t.Fatalf("CoerceVerifierFinding: %v", err) + } + if got.Description != "src -> sink" { + t.Errorf("description = %q, want the data flow summary", got.Description) + } +} + +func TestCoerceVerifierFindingRejectsNeitherModel(t *testing.T) { + if _, err := CoerceVerifierFinding(map[string]any{"title": "malformed"}); err == nil { + t.Fatal("want an error: the payload is neither a RawFinding nor a FindingForVerifier") + } +} + +func TestProveAdapterNotes(t *testing.T) { + repo := t.TempDir() + finding := rawFindingPayload() + + t.Run("run_dep_reachability", func(t *testing.T) { + fake := newScanFake() + if _, err := RunDepReachability(context.Background(), fake, FindingDepthInput{ + RepoPath: repo, Finding: finding, Depth: "standard", + }); err != nil { + t.Fatalf("RunDepReachability: %v", err) + } + assertNote(t, fake, "Dependency reachability analyzer starting", "prove", "dep-reachability") + }) + + t.Run("run_tracer", func(t *testing.T) { + fake := newScanFake() + if _, err := RunTracer(context.Background(), fake, FindingDepthInput{ + RepoPath: repo, Finding: finding, Depth: "standard", + }); err != nil { + t.Fatalf("RunTracer: %v", err) + } + assertNote(t, fake, "Tracer starting", "prove", "tracer") + }) + + t.Run("run_sanitization_analyzer", func(t *testing.T) { + fake := newScanFake() + if _, err := RunSanitizationAnalyzer(context.Background(), fake, SanitizationInput{ + RepoPath: repo, Finding: finding, DataFlow: dataFlowPayload(), Depth: "standard", + }); err != nil { + t.Fatalf("RunSanitizationAnalyzer: %v", err) + } + assertNote(t, fake, "Sanitization analyzer starting", "prove", "sanitization") + }) + + t.Run("run_exploit_hypothesizer", func(t *testing.T) { + fake := newScanFake() + if _, err := RunExploitHypothesizer(context.Background(), fake, ExploitInput{ + RepoPath: repo, Finding: finding, DataFlow: dataFlowPayload(), + Sanitization: sanitizationPayload(), Depth: "standard", + }); err != nil { + t.Fatalf("RunExploitHypothesizer: %v", err) + } + assertNote(t, fake, "Exploit hypothesizer starting", "prove", "exploit") + }) + + t.Run("run_dast_verifier", func(t *testing.T) { + fake := newScanFake() + if _, err := RunDastVerifier(context.Background(), fake, DastVerifierInput{ + RepoPath: repo, Finding: finding, ExploitPayload: "' OR 1=1", Depth: "standard", + }); err != nil { + t.Fatalf("RunDastVerifier: %v", err) + } + assertNote(t, fake, "DAST verifier starting", "prove", "dast") + }) + + t.Run("run_cross_service_analyzer", func(t *testing.T) { + fake := newScanFake() + if _, err := RunCrossServiceAnalyzer(context.Background(), fake, CrossServiceInput{ + RepoPath: repo, Services: []string{"api", "worker"}, FindingsSummary: "s", Depth: "standard", + }); err != nil { + t.Fatalf("RunCrossServiceAnalyzer: %v", err) + } + assertNote(t, fake, "Cross-service analyzer starting", "prove", "cross-service") + }) + + t.Run("run_remediation_agent", func(t *testing.T) { + fake := newScanFake() + if _, err := RunRemediationAgent(context.Background(), fake, RemediationAgentInput{ + RepoPath: repo, Finding: finding, Verdict: "confirmed", Rationale: "reachable", + }); err != nil { + t.Fatalf("RunRemediationAgent: %v", err) + } + // Python parity: the same message and tags run_remediation uses. + assertNote(t, fake, "Remediation agent starting", "prove", "remediation") + }) +} + +// TestRunRemediationNote pins that run_remediation shares run_remediation_agent's +// note verbatim, and that the note fires before the VerifiedFinding bind. +func TestRunRemediationNote(t *testing.T) { + fake := newScanFake() + _, err := RunRemediation(context.Background(), fake, RemediationInput{ + RepoPath: t.TempDir(), + Finding: map[string]any{"title": "malformed"}, + }) + if err == nil { + t.Fatal("want a VerifiedFinding validation error") + } + assertNote(t, fake, "Remediation agent starting", "prove", "remediation") +} + +// TestRunVerdictAgentNote pins the verdict adapter's note. +func TestRunVerdictAgentNote(t *testing.T) { + fake := newScanFake() + _, err := RunVerdictAgent(context.Background(), fake, VerdictInput{ + Finding: map[string]any{"title": "malformed"}, + DataFlow: dataFlowPayload(), + Sanitization: sanitizationPayload(), + Exploit: exploitPayload(), + }) + if err == nil { + t.Fatal("want a RawFinding validation error") + } + assertNote(t, fake, "Verdict agent starting", "prove", "verdict") +} + +// TestRunVerifierNoteFiresBeforeCoercion pins the ordering: the note is emitted +// first, so a finding that coerces to nothing still leaves its trace. +func TestRunVerifierNoteFiresBeforeCoercion(t *testing.T) { + fake := newScanFake() + _, err := RunVerifier(context.Background(), fake, FindingDepthInput{ + RepoPath: t.TempDir(), + Finding: map[string]any{"title": "malformed"}, + Depth: "standard", + }) + if err == nil { + t.Fatal("want an error for a payload that is neither model") + } + assertNote(t, fake, "Verifier starting", "prove", "verifier") +} + +// TestStrictAdaptersRejectMalformedPayloads pins that the adapters using the +// STRICT constructors (not _coerce_verifier_finding) fail on a verifier +// projection, which is what Python's `RawFinding(**finding)` does. +func TestStrictAdaptersRejectMalformedPayloads(t *testing.T) { + view := verifierProjection(t) + repo := t.TempDir() + + if _, err := RunTracer(context.Background(), newScanFake(), FindingDepthInput{ + RepoPath: repo, Finding: view, Depth: "standard", + }); err == nil { + t.Error("run_tracer must reject a FindingForVerifier projection") + } + + if _, err := RunVerdictAgent(context.Background(), newScanFake(), VerdictInput{ + Finding: view, DataFlow: dataFlowPayload(), + Sanitization: sanitizationPayload(), Exploit: exploitPayload(), + }); err == nil { + t.Error("run_verdict_agent must reject a FindingForVerifier projection") + } +} + +func dataFlowPayload() map[string]any { + return map[string]any{ + "source": "request.args", + "sink": "cursor.execute", + "steps": []any{"a", "b"}, + "sink_reached": true, + } +} + +func sanitizationPayload() map[string]any { + return map[string]any{"found": false} +} + +func exploitPayload() map[string]any { + return map[string]any{"hypothesis": "h", "expected_outcome": "o"} +} diff --git a/go/internal/reasoners/recon.go b/go/internal/reasoners/recon.go new file mode 100644 index 0000000..58e0f32 --- /dev/null +++ b/go/internal/reasoners/recon.go @@ -0,0 +1,96 @@ +package reasoners + +import ( + "context" + + "github.com/Agent-Field/sec-af/go/internal/afx" + reconagent "github.com/Agent-Field/sec-af/go/internal/agents/recon" + "github.com/Agent-Field/sec-af/go/internal/appx" +) + +// recon.go ports src/sec_af/reasoners/recon.py — the five RECON reasoner +// adapters. Each is three statements in Python: +// +// router.note(" starting", tags=[...]) +// result = await _run_(router, ...) +// return result.model_dump() +// +// The note fires BEFORE the agent function, so a failing mapper still leaves +// its "starting" note in the execution log; that ordering is reproduced. + +// RunArchitectureMapper ports `run_architecture_mapper(repo_path)` +// (reasoners/recon.py:16). +func RunArchitectureMapper(ctx context.Context, app appx.App, in RepoPathInput) (map[string]any, error) { + app.Note(ctx, "Architecture mapper starting", "recon", "architecture") + result, err := reconagent.RunArchitectureMapper(ctx, app, in.RepoPath) + if err != nil { + return nil, err + } + return afx.ToMap(result) +} + +// RunDependencyAuditor ports `run_dependency_auditor(repo_path)` +// (reasoners/recon.py:24). +func RunDependencyAuditor(ctx context.Context, app appx.App, in RepoPathInput) (map[string]any, error) { + app.Note(ctx, "Dependency auditor starting", "recon", "dependencies") + result, err := reconagent.RunDependencyAuditor(ctx, app, in.RepoPath) + if err != nil { + return nil, err + } + return afx.ToMap(result) +} + +// RunConfigScanner ports `run_config_scanner(repo_path)` +// (reasoners/recon.py:32). +func RunConfigScanner(ctx context.Context, app appx.App, in RepoPathInput) (map[string]any, error) { + app.Note(ctx, "Config scanner starting", "recon", "config") + result, err := reconagent.RunConfigScanner(ctx, app, in.RepoPath) + if err != nil { + return nil, err + } + return afx.ToMap(result) +} + +// RunDataFlowMapper ports `run_data_flow_mapper(repo_path, architecture)` +// (reasoners/recon.py:40): +// +// architecture_model = ArchitectureMap(**architecture) +// +// ArchitectureMap has no required field of its OWN, so an empty (or absent) +// `architecture` binds to the pydantic defaults rather than raising — but each +// of its five lists holds a model that DOES have required fields (Module +// name/path/language, EntryPoint kind/identifier/file_path/line, APIEndpoint +// method/path/handler/file_path/line, ...), and pydantic validates those. +// `ArchitectureMap(**{"modules": [{"name": "x"}]})` raises 2 errors on the +// pinned interpreter, so this is bindArchitectureMap, not a bare afx.Bind: the +// reasoner is registered on the router and a control-plane caller can send that +// payload directly. +func RunDataFlowMapper(ctx context.Context, app appx.App, in ArchitectureInput) (map[string]any, error) { + app.Note(ctx, "Data flow mapper starting", "recon", "data-flow") + architecture, err := bindArchitectureMap(in.Architecture) + if err != nil { + return nil, err + } + result, err := reconagent.RunDataFlowMapper(ctx, app, in.RepoPath, architecture) + if err != nil { + return nil, err + } + return afx.ToMap(result) +} + +// RunSecurityContextProfiler ports +// `run_security_context_profiler(repo_path, architecture)` +// (reasoners/recon.py:49). Same `ArchitectureMap(**architecture)` bind, same +// nested validation — see RunDataFlowMapper. +func RunSecurityContextProfiler(ctx context.Context, app appx.App, in ArchitectureInput) (map[string]any, error) { + app.Note(ctx, "Security context profiler starting", "recon", "security-context") + architecture, err := bindArchitectureMap(in.Architecture) + if err != nil { + return nil, err + } + result, err := reconagent.RunSecurityContextProfiler(ctx, app, in.RepoPath, architecture) + if err != nil { + return nil, err + } + return afx.ToMap(result) +} diff --git a/go/internal/reasoners/recon_test.go b/go/internal/reasoners/recon_test.go new file mode 100644 index 0000000..92aafaf --- /dev/null +++ b/go/internal/reasoners/recon_test.go @@ -0,0 +1,100 @@ +package reasoners + +// Tests for src/sec_af/reasoners/recon.py. +// +// Validation contract: +// +// - each of the five adapters emits its own note (message + tags) before the +// agent function runs; +// - the two deep-recon adapters materialize `architecture` with +// ArchitectureMap(**architecture), which has no required field — an empty +// or absent dict binds to the pydantic defaults rather than failing; +// - each adapter returns the agent result's model_dump() key set. + +import ( + "context" + "testing" + + "github.com/Agent-Field/sec-af/go/internal/appx" +) + +func TestReconAdapterNotes(t *testing.T) { + repo := t.TempDir() + + t.Run("run_architecture_mapper", func(t *testing.T) { + fake := newScanFake() + got, err := RunArchitectureMapper(context.Background(), fake, RepoPathInput{RepoPath: repo}) + if err != nil { + t.Fatalf("RunArchitectureMapper: %v", err) + } + assertNote(t, fake, "Architecture mapper starting", "recon", "architecture") + assertHasKeys(t, got, "app_type", "modules", "entry_points", "api_surface", "trust_boundaries") + }) + + t.Run("run_dependency_auditor", func(t *testing.T) { + fake := newScanFake() + got, err := RunDependencyAuditor(context.Background(), fake, RepoPathInput{RepoPath: repo}) + if err != nil { + t.Fatalf("RunDependencyAuditor: %v", err) + } + assertNote(t, fake, "Dependency auditor starting", "recon", "dependencies") + assertHasKeys(t, got, "direct_count", "transitive_count", "known_cves") + }) + + t.Run("run_config_scanner", func(t *testing.T) { + fake := newScanFake() + got, err := RunConfigScanner(context.Background(), fake, RepoPathInput{RepoPath: repo}) + if err != nil { + t.Fatalf("RunConfigScanner: %v", err) + } + assertNote(t, fake, "Config scanner starting", "recon", "config") + assertHasKeys(t, got, "secrets", "misconfigs") + }) + + t.Run("run_data_flow_mapper", func(t *testing.T) { + fake := newScanFake() + got, err := RunDataFlowMapper(context.Background(), fake, ArchitectureInput{ + RepoPath: repo, + Architecture: map[string]any{}, + }) + if err != nil { + t.Fatalf("RunDataFlowMapper: %v", err) + } + assertNote(t, fake, "Data flow mapper starting", "recon", "data-flow") + assertHasKeys(t, got, "flows", "sanitization_points") + }) + + t.Run("run_security_context_profiler", func(t *testing.T) { + fake := newScanFake() + got, err := RunSecurityContextProfiler(context.Background(), fake, ArchitectureInput{ + RepoPath: repo, + Architecture: map[string]any{}, + }) + if err != nil { + t.Fatalf("RunSecurityContextProfiler: %v", err) + } + assertNote(t, fake, "Security context profiler starting", "recon", "security-context") + assertHasKeys(t, got, "auth_model", "auth_details", "crypto_usage", "framework_security") + }) +} + +// TestDeepReconAcceptsAbsentArchitecture pins the "no required field" half of +// the contract: `ArchitectureMap(**{})` succeeds in Python, so a request that +// omits `architecture` entirely must not fail. +func TestDeepReconAcceptsAbsentArchitecture(t *testing.T) { + fake := newScanFake() + if _, err := RunDataFlowMapper(context.Background(), fake, ArchitectureInput{RepoPath: t.TempDir()}); err != nil { + t.Fatalf("RunDataFlowMapper with no architecture: %v", err) + } +} + +func assertHasKeys(t *testing.T, got map[string]any, keys ...string) { + t.Helper() + for _, key := range keys { + if _, ok := got[key]; !ok { + t.Errorf("result is missing the %q key", key) + } + } +} + +var _ appx.App = (*appx.Fake)(nil) diff --git a/go/internal/reasoners/register.go b/go/internal/reasoners/register.go new file mode 100644 index 0000000..b96681e --- /dev/null +++ b/go/internal/reasoners/register.go @@ -0,0 +1,180 @@ +package reasoners + +import ( + "context" + "net/http" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + + "github.com/Agent-Field/sec-af/go/internal/afx" + "github.com/Agent-Field/sec-af/go/internal/appx" + "github.com/Agent-Field/sec-af/go/internal/phases" +) + +// register.go mounts the 33 router reasoners onto an *agent.Router, which is +// the Go shape of Python's module-level +// +// router = AgentRouter(tags=["security", "audit", "red-team"]) +// @router.reasoner() +// async def run_x(...): ... +// # app.py: app.include_router(reasoner_router) +// +// The caller (internal/node) does the include with +// agent.RouterOptions{Tags: RouterTags}, so the tags are applied exactly once, +// by the SDK, to every handler in the router. + +// RegisterAll registers every router reasoner on r, in DESIGN.md §3 order, and +// returns the ordered names it registered. +// +// The returned slice IS the registration bookkeeping: the SDK keeps +// Agent.reasoners unexported and its /discover payload hardcodes an empty tag +// list per reasoner, so there is no way to read back the ordered surface from +// the SDK. Returning it here (rather than hiding it in a package variable) +// keeps the parity test honest — it asserts what RegisterAll ACTUALLY did, not +// what Names says it should do, and compares the two. +// +// nodeID is read ONCE, here, from phases.NodeID() — Python's module-level +// `NODE_ID = os.getenv("NODE_ID", "sec-af")`, evaluated at import. Every +// `*_phase` handler closes over that value, so the `.call` targets are fixed +// for the life of the process. +func RegisterAll(r *agent.Router, app appx.App) []string { + nodeID := phases.NodeID() + reg := ®istrar{router: r} + + // --- reasoners/recon.py ------------------------------------------------- + register(reg, app, NameRunArchitectureMapper, RunArchitectureMapper) + register(reg, app, NameRunDependencyAuditor, RunDependencyAuditor) + register(reg, app, NameRunConfigScanner, RunConfigScanner) + register(reg, app, NameRunDataFlowMapper, RunDataFlowMapper) + register(reg, app, NameRunSecurityContextProfiler, RunSecurityContextProfiler) + + // --- reasoners/hunt.py -------------------------------------------------- + register(reg, app, NameRunInjectionHunter, RunInjectionHunter) + register(reg, app, NameRunDosHunter, RunDosHunter) + register(reg, app, NameRunSSRFHunter, RunSSRFHunter) + register(reg, app, NameRunAuthHunter, RunAuthHunter) + register(reg, app, NameRunXSSHunter, RunXSSHunter) + register(reg, app, NameRunCryptoHunter, RunCryptoHunter) + register(reg, app, NameRunBusinessLogicHunter, RunBusinessLogicHunter) + register(reg, app, NameRunLogicBugsHunter, RunLogicBugsHunter) + register(reg, app, NameRunDataExposureHunter, RunDataExposureHunter) + register(reg, app, NameRunSupplyChainHunter, RunSupplyChainHunter) + register(reg, app, NameRunConfigSecretsHunter, RunConfigSecretsHunter) + register(reg, app, NameRunAPISecurityHunter, RunAPISecurityHunter) + register(reg, app, NameRunDeduplicator, RunDeduplicator) + + // --- reasoners/prove.py ------------------------------------------------- + register(reg, app, NameRunDepReachability, RunDepReachability) + register(reg, app, NameRunVerifier, RunVerifier) + register(reg, app, NameRunTracer, RunTracer) + register(reg, app, NameRunSanitizationAnalyzer, RunSanitizationAnalyzer) + register(reg, app, NameRunExploitHypothesizer, RunExploitHypothesizer) + register(reg, app, NameRunVerdictAgent, RunVerdictAgent) + register(reg, app, NameRunRemediation, RunRemediation) + register(reg, app, NameRunRemediationAgent, RunRemediationAgent) + register(reg, app, NameRunDastVerifier, RunDastVerifier) + register(reg, app, NameRunCrossServiceAnalyzer, RunCrossServiceAnalyzer) + + // --- reasoners/phases.py ------------------------------------------------ + register(reg, app, NameRunCWEExpansion, RunCWEExpansion) + registerPhase(reg, app, nodeID, NameReconPhase, ReconPhase) + registerPhase(reg, app, nodeID, NameHuntPhase, HuntPhase) + registerPhase(reg, app, nodeID, NameProvePhase, ProvePhase) + registerPhase(reg, app, nodeID, NameRemediationPhase, RemediationPhase) + + return reg.names +} + +// registrar is the single registration path: everything that reaches the router +// is recorded, so the bookkeeping cannot drift from what the SDK receives. +type registrar struct { + router *agent.Router + names []string +} + +// add mounts one reasoner, recording it, wrapping it in the SDK-level input +// validation Python performs, and attaching the input schema the Python node +// publishes for that name. +// +// The schema is NOT optional: the Go SDK defaults every reasoner to +// `{"type":"object","additionalProperties":true}`, which would make this node's +// discovery payload strictly less informative than the Python one it replaces. +// InputSchema panics on a name the capture does not know, so a reasoner added +// here without regenerating testdata/python_input_schemas.json fails at +// registration rather than shipping the placeholder. See input_schemas.go. +func (r *registrar) add(name string, h agent.HandlerFunc) { + r.names = append(r.names, name) + r.router.RegisterReasoner(name, ValidateHandler(name, h), agent.WithInputSchema(InputSchema(name))) +} + +// ValidateHandler wraps h with `_validate_handler_input` for the reasoner +// called name — the check the Python SDK runs on every request body BEFORE the +// handler function is entered (agent.py:3120-3134). +// +// Exported because internal/node registers the `audit` reasoner on the Agent +// itself rather than on this router, and it is the same layer: all 34 handlers +// on the node's surface are validated, or the port has a hole exactly where a +// control-plane caller reaches in. +// +// Python answers a failure with `JSONResponse(422, {"detail": safe_message})` +// from the endpoint, so the handler never runs. The Go SDK has no pre-handler +// hook, so the check runs as the first thing INSIDE the handler and reports the +// same status through agent.ExecuteError — the SDK writes +// `{"error": }` with that code (agent.go:1259-1273). Same status, same +// message, different body key; the execution is additionally recorded as a +// failure, which Python's earlier rejection avoids. +func ValidateHandler(name string, h agent.HandlerFunc) agent.HandlerFunc { + // Resolve the spec at REGISTRATION time so an unknown name panics at boot, + // not on the first request — the same loud-drift contract InputSchema has. + _ = handlerSpecFor(name) + return func(ctx context.Context, input map[string]any) (any, error) { + validated, err := ValidateHandlerInput(name, input) + if err != nil { + return nil, HandlerInputExecuteError(err) + } + return h(ctx, validated) + } +} + +// HandlerInputExecuteError maps a *HandlerInputError onto the SDK error that +// makes the node answer 422, the status Python's endpoint returns. +func HandlerInputExecuteError(err error) error { + return &agent.ExecuteError{StatusCode: http.StatusUnprocessableEntity, Message: err.Error()} +} + +// register adapts a typed reasoner function to the SDK HandlerFunc: afx.Bind +// the untyped request map into T (running T's default-seeding UnmarshalJSON, so +// the Python keyword defaults apply to absent keys) and hand it to fn. T is +// inferred from fn. +func register[T any]( + r *registrar, + app appx.App, + name string, + fn func(context.Context, appx.App, T) (map[string]any, error), +) { + r.add(name, func(ctx context.Context, input map[string]any) (any, error) { + in, err := afx.Bind[T](input) + if err != nil { + return nil, err + } + return fn(ctx, app, in) + }) +} + +// registerPhase is register for the four `*_phase` reasoners, which additionally +// need the node id their `.call` targets are prefixed with. +func registerPhase[T any]( + r *registrar, + app appx.App, + nodeID string, + name string, + fn func(context.Context, appx.App, string, T) (map[string]any, error), +) { + r.add(name, func(ctx context.Context, input map[string]any) (any, error) { + in, err := afx.Bind[T](input) + if err != nil { + return nil, err + } + return fn(ctx, app, nodeID, in) + }) +} diff --git a/go/internal/reasoners/register_test.go b/go/internal/reasoners/register_test.go new file mode 100644 index 0000000..f35c397 --- /dev/null +++ b/go/internal/reasoners/register_test.go @@ -0,0 +1,198 @@ +package reasoners + +// Registration parity for the router surface. +// +// Validation contract (behaviour, derived from src/sec_af/reasoners/*.py and +// DESIGN.md §3 — NOT from register.go): +// +// - the router carries exactly 33 reasoners, with the exact names and in the +// exact order DESIGN.md §3 lists (which is the Python import + decorator +// order); +// - no name is registered twice (a collision would be invisible otherwise — +// the SDK's reasoner table is a map, so the second registration silently +// wins); +// - the tag set applied to the whole router is ["security","audit","red-team"]; +// - every registered name is actually reachable on a real *agent.Agent once +// the router is included — read back from the SDK, not from our own +// bookkeeping, so the two cannot drift together. + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + + "github.com/Agent-Field/sec-af/go/internal/appx" +) + +// pythonSurface is the independent parity checklist: the 33 router reasoner +// names in DESIGN.md §3 order, written out from the Python inventory rather +// than derived from Names, so drift in either direction fails the test. +var pythonSurface = []string{ + // reasoners/recon.py + "run_architecture_mapper", + "run_dependency_auditor", + "run_config_scanner", + "run_data_flow_mapper", + "run_security_context_profiler", + // reasoners/hunt.py + "run_injection_hunter", + "run_dos_hunter", + "run_ssrf_hunter", + "run_auth_hunter", + "run_xss_hunter", + "run_crypto_hunter", + "run_business_logic_hunter", + "run_logic_bugs_hunter", + "run_data_exposure_hunter", + "run_supply_chain_hunter", + "run_config_secrets_hunter", + "run_api_security_hunter", + "run_deduplicator", + // reasoners/prove.py + "run_dep_reachability", + "run_verifier", + "run_tracer", + "run_sanitization_analyzer", + "run_exploit_hypothesizer", + "run_verdict_agent", + "run_remediation", + "run_remediation_agent", + "run_dast_verifier", + "run_cross_service_analyzer", + // reasoners/phases.py + "run_cwe_expansion", + "recon_phase", + "hunt_phase", + "prove_phase", + "remediation_phase", +} + +func TestNamesMatchPythonSurface(t *testing.T) { + if !reflect.DeepEqual(Names, pythonSurface) { + t.Fatalf("Names mismatch:\n got = %v\n want = %v", Names, pythonSurface) + } + if len(Names) != 33 { + t.Fatalf("surface size = %d, want 33", len(Names)) + } +} + +func TestRouterTagsMatchPython(t *testing.T) { + want := []string{"security", "audit", "red-team"} + if !reflect.DeepEqual(RouterTags, want) { + t.Fatalf("RouterTags = %v, want %v", RouterTags, want) + } +} + +func TestRegisterAllExactOrderedSurface(t *testing.T) { + got := RegisterAll(agent.NewRouter(), &appx.Fake{}) + + if !reflect.DeepEqual(got, pythonSurface) { + t.Fatalf("registered surface mismatch:\n got = %v\n want = %v", got, pythonSurface) + } + + seen := map[string]int{} + for _, name := range got { + seen[name]++ + } + for name, count := range seen { + if count > 1 { + t.Errorf("reasoner %q registered %d times (collision)", name, count) + } + } +} + +// TestRegisterAllReachableOnAgent reads the surface back OUT of the SDK: it +// mounts the router on a real agent and asks the agent's own /discover handler +// which reasoners exist. Agent.reasoners is unexported and there is no +// accessor, so /discover is the only SDK-side read-back — and it is the same +// payload the control plane receives at registration. +// +// The SDK's discovery payload hardcodes `"tags": []` per reasoner, so tags +// cannot be read back this way; they are asserted through the node's own +// bookkeeping in internal/node. +func TestRegisterAllReachableOnAgent(t *testing.T) { + t.Setenv("NODE_ID", "sec-af") + + a, err := agent.New(agent.Config{ + NodeID: "sec-af", + Version: "0.1.0", + AgentFieldURL: "http://127.0.0.1:1", // never dialled: no Initialize here + ListenAddress: ":0", + }) + if err != nil { + t.Fatalf("agent.New: %v", err) + } + + router := agent.NewRouter() + names := RegisterAll(router, &appx.Fake{}) + a.IncludeRouter(router, agent.RouterOptions{Tags: RouterTags}) + + rec := httptest.NewRecorder() + a.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/discover", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("/discover status = %d, want 200", rec.Code) + } + + var payload struct { + Reasoners []struct { + ID string `json:"id"` + } `json:"reasoners"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode /discover: %v", err) + } + + registered := map[string]bool{} + for _, r := range payload.Reasoners { + registered[r.ID] = true + } + if len(payload.Reasoners) != len(names) { + t.Errorf("/discover reports %d reasoners, want %d", len(payload.Reasoners), len(names)) + } + for _, name := range names { + if !registered[name] { + t.Errorf("reasoner %q is not registered on the agent", name) + } + } +} + +// TestRegisterAllHandlersBindDefaults proves the registration path really runs +// afx.Bind into the typed input (and therefore the default-seeding +// UnmarshalJSON): run_config_secrets_hunter is invoked through its REGISTERED +// handler with a body that omits max_files_without_signal, and the prompt the +// harness receives must still carry the Python default of 30. +func TestRegisterAllHandlersBindDefaults(t *testing.T) { + fake := newScanFake() + + router := agent.NewRouter() + RegisterAll(router, fake) + + a, err := agent.New(agent.Config{ + NodeID: "sec-af", + Version: "0.1.0", + AgentFieldURL: "http://127.0.0.1:1", + ListenAddress: ":0", + }) + if err != nil { + t.Fatalf("agent.New: %v", err) + } + a.IncludeRouter(router, agent.RouterOptions{Tags: RouterTags}) + + if _, err := a.Execute(context.Background(), "run_config_secrets_hunter", map[string]any{ + "repo_path": t.TempDir(), + "recon_context": map[string]any{}, + "depth": "standard", + }); err != nil { + t.Fatalf("Execute: %v", err) + } + + if len(fake.Harnesses) != 1 { + t.Fatalf("harness calls = %d, want 1", len(fake.Harnesses)) + } + assertPromptHasFileBudget(t, fake.Harnesses[0].Prompt, "30") +} diff --git a/go/internal/reasoners/testdata/python_input_schemas.json b/go/internal/reasoners/testdata/python_input_schemas.json new file mode 100644 index 0000000..502853a --- /dev/null +++ b/go/internal/reasoners/testdata/python_input_schemas.json @@ -0,0 +1,774 @@ +{ + "audit": { + "properties": { + "base_commit_sha": { + "type": "object" + }, + "branch": { + "type": "string" + }, + "commit_sha": { + "type": "object" + }, + "compliance_frameworks": { + "type": "object" + }, + "depth": { + "type": "string" + }, + "enable_dast": { + "type": "boolean" + }, + "exclude_paths": { + "type": "object" + }, + "fail_on_findings": { + "type": "boolean" + }, + "include_paths": { + "type": "object" + }, + "is_pr": { + "type": "boolean" + }, + "max_cost_usd": { + "type": "object" + }, + "max_duration_seconds": { + "type": "object" + }, + "max_provers": { + "type": "object" + }, + "output_formats": { + "type": "object" + }, + "post_pr_comments": { + "type": "boolean" + }, + "pr_id": { + "type": "object" + }, + "repo_url": { + "type": "string" + }, + "resume_from_checkpoint": { + "type": "object" + }, + "scan_types": { + "type": "object" + }, + "severity_threshold": { + "type": "string" + } + }, + "required": [ + "repo_url" + ], + "type": "object" + }, + "hunt_phase": { + "properties": { + "ai_gate": { + "type": "object" + }, + "depth": { + "type": "string" + }, + "early_stop_file_threshold": { + "type": "integer" + }, + "max_concurrent_hunters": { + "type": "integer" + }, + "recon_context": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "recon_context" + ], + "type": "object" + }, + "prove_phase": { + "properties": { + "depth": { + "type": "string" + }, + "hunt_result": { + "additionalProperties": true, + "type": "object" + }, + "max_concurrent_provers": { + "type": "integer" + }, + "max_provers": { + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "hunt_result" + ], + "type": "object" + }, + "recon_phase": { + "properties": { + "depth": { + "type": "string" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path" + ], + "type": "object" + }, + "remediation_phase": { + "properties": { + "max_concurrent_remediations": { + "type": "integer" + }, + "repo_path": { + "type": "string" + }, + "verified_findings": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "repo_path", + "verified_findings" + ], + "type": "object" + }, + "run_api_security_hunter": { + "properties": { + "depth": { + "type": "string" + }, + "max_files_without_signal": { + "type": "integer" + }, + "recon_context": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "recon_context", + "depth" + ], + "type": "object" + }, + "run_architecture_mapper": { + "properties": { + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path" + ], + "type": "object" + }, + "run_auth_hunter": { + "properties": { + "depth": { + "type": "string" + }, + "max_files_without_signal": { + "type": "integer" + }, + "recon_context": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "recon_context", + "depth" + ], + "type": "object" + }, + "run_business_logic_hunter": { + "properties": { + "depth": { + "type": "string" + }, + "max_files_without_signal": { + "type": "integer" + }, + "recon_context": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "recon_context", + "depth" + ], + "type": "object" + }, + "run_config_scanner": { + "properties": { + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path" + ], + "type": "object" + }, + "run_config_secrets_hunter": { + "properties": { + "depth": { + "type": "string" + }, + "max_files_without_signal": { + "type": "integer" + }, + "recon_context": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "recon_context", + "depth" + ], + "type": "object" + }, + "run_cross_service_analyzer": { + "properties": { + "depth": { + "type": "string" + }, + "findings_summary": { + "type": "string" + }, + "repo_path": { + "type": "string" + }, + "services": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "repo_path", + "services", + "findings_summary", + "depth" + ], + "type": "object" + }, + "run_crypto_hunter": { + "properties": { + "depth": { + "type": "string" + }, + "max_files_without_signal": { + "type": "integer" + }, + "recon_context": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "recon_context", + "depth" + ], + "type": "object" + }, + "run_cwe_expansion": { + "properties": { + "recon_summary": { + "type": "string" + }, + "strategies": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "recon_summary", + "strategies" + ], + "type": "object" + }, + "run_dast_verifier": { + "properties": { + "depth": { + "type": "string" + }, + "exploit_payload": { + "type": "string" + }, + "finding": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "finding", + "exploit_payload", + "depth" + ], + "type": "object" + }, + "run_data_exposure_hunter": { + "properties": { + "depth": { + "type": "string" + }, + "max_files_without_signal": { + "type": "integer" + }, + "recon_context": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "recon_context", + "depth" + ], + "type": "object" + }, + "run_data_flow_mapper": { + "properties": { + "architecture": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "architecture" + ], + "type": "object" + }, + "run_deduplicator": { + "properties": { + "findings": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "recon_context": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "findings", + "recon_context", + "repo_path" + ], + "type": "object" + }, + "run_dep_reachability": { + "properties": { + "depth": { + "type": "string" + }, + "finding": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "finding", + "depth" + ], + "type": "object" + }, + "run_dependency_auditor": { + "properties": { + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path" + ], + "type": "object" + }, + "run_dos_hunter": { + "properties": { + "depth": { + "type": "string" + }, + "max_files_without_signal": { + "type": "integer" + }, + "recon_context": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "recon_context", + "depth" + ], + "type": "object" + }, + "run_exploit_hypothesizer": { + "properties": { + "data_flow": { + "additionalProperties": true, + "type": "object" + }, + "depth": { + "type": "string" + }, + "finding": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + }, + "sanitization": { + "additionalProperties": true, + "type": "object" + } + }, + "required": [ + "repo_path", + "finding", + "data_flow", + "sanitization", + "depth" + ], + "type": "object" + }, + "run_injection_hunter": { + "properties": { + "depth": { + "type": "string" + }, + "max_files_without_signal": { + "type": "integer" + }, + "recon_context": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "recon_context", + "depth" + ], + "type": "object" + }, + "run_logic_bugs_hunter": { + "properties": { + "depth": { + "type": "string" + }, + "max_files_without_signal": { + "type": "integer" + }, + "recon_context": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "recon_context", + "depth" + ], + "type": "object" + }, + "run_remediation": { + "properties": { + "finding": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "finding" + ], + "type": "object" + }, + "run_remediation_agent": { + "properties": { + "finding": { + "additionalProperties": true, + "type": "object" + }, + "rationale": { + "type": "string" + }, + "repo_path": { + "type": "string" + }, + "verdict": { + "type": "string" + } + }, + "required": [ + "repo_path", + "finding", + "verdict", + "rationale" + ], + "type": "object" + }, + "run_sanitization_analyzer": { + "properties": { + "data_flow": { + "additionalProperties": true, + "type": "object" + }, + "depth": { + "type": "string" + }, + "finding": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "finding", + "data_flow", + "depth" + ], + "type": "object" + }, + "run_security_context_profiler": { + "properties": { + "architecture": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "architecture" + ], + "type": "object" + }, + "run_ssrf_hunter": { + "properties": { + "depth": { + "type": "string" + }, + "max_files_without_signal": { + "type": "integer" + }, + "recon_context": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "recon_context", + "depth" + ], + "type": "object" + }, + "run_supply_chain_hunter": { + "properties": { + "depth": { + "type": "string" + }, + "max_files_without_signal": { + "type": "integer" + }, + "recon_context": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "recon_context", + "depth" + ], + "type": "object" + }, + "run_tracer": { + "properties": { + "depth": { + "type": "string" + }, + "finding": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "finding", + "depth" + ], + "type": "object" + }, + "run_verdict_agent": { + "properties": { + "data_flow": { + "additionalProperties": true, + "type": "object" + }, + "exploit": { + "additionalProperties": true, + "type": "object" + }, + "finding": { + "additionalProperties": true, + "type": "object" + }, + "sanitization": { + "additionalProperties": true, + "type": "object" + } + }, + "required": [ + "finding", + "data_flow", + "sanitization", + "exploit" + ], + "type": "object" + }, + "run_verifier": { + "properties": { + "depth": { + "type": "string" + }, + "finding": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "finding", + "depth" + ], + "type": "object" + }, + "run_xss_hunter": { + "properties": { + "depth": { + "type": "string" + }, + "max_files_without_signal": { + "type": "integer" + }, + "recon_context": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "recon_context", + "depth" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/go/internal/reasoners/testdata/python_input_types.json b/go/internal/reasoners/testdata/python_input_types.json new file mode 100644 index 0000000..21de1bd --- /dev/null +++ b/go/internal/reasoners/testdata/python_input_types.json @@ -0,0 +1,902 @@ +{ + "audit": [ + { + "name": "repo_url", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": false, + "default": "standard" + }, + { + "name": "branch", + "kind": "str", + "annotation": "", + "required": false, + "default": "main" + }, + { + "name": "commit_sha", + "kind": "any", + "annotation": "str | None", + "required": false, + "default": null + }, + { + "name": "base_commit_sha", + "kind": "any", + "annotation": "str | None", + "required": false, + "default": null + }, + { + "name": "severity_threshold", + "kind": "str", + "annotation": "", + "required": false, + "default": "low" + }, + { + "name": "scan_types", + "kind": "any", + "annotation": "list[str] | None", + "required": false, + "default": null + }, + { + "name": "output_formats", + "kind": "any", + "annotation": "list[str] | None", + "required": false, + "default": null + }, + { + "name": "compliance_frameworks", + "kind": "any", + "annotation": "list[str] | None", + "required": false, + "default": null + }, + { + "name": "max_cost_usd", + "kind": "any", + "annotation": "float | None", + "required": false, + "default": null + }, + { + "name": "max_provers", + "kind": "any", + "annotation": "int | None", + "required": false, + "default": null + }, + { + "name": "max_duration_seconds", + "kind": "any", + "annotation": "int | None", + "required": false, + "default": null + }, + { + "name": "include_paths", + "kind": "any", + "annotation": "list[str] | None", + "required": false, + "default": null + }, + { + "name": "exclude_paths", + "kind": "any", + "annotation": "list[str] | None", + "required": false, + "default": null + }, + { + "name": "is_pr", + "kind": "bool", + "annotation": "", + "required": false, + "default": false + }, + { + "name": "pr_id", + "kind": "any", + "annotation": "str | None", + "required": false, + "default": null + }, + { + "name": "post_pr_comments", + "kind": "bool", + "annotation": "", + "required": false, + "default": false + }, + { + "name": "fail_on_findings", + "kind": "bool", + "annotation": "", + "required": false, + "default": false + }, + { + "name": "enable_dast", + "kind": "bool", + "annotation": "", + "required": false, + "default": false + }, + { + "name": "resume_from_checkpoint", + "kind": "any", + "annotation": "str | None", + "required": false, + "default": null + } + ], + "hunt_phase": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "recon_context", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": false, + "default": "standard" + }, + { + "name": "ai_gate", + "kind": "any", + "annotation": "typing.Any | None", + "required": false, + "default": null + }, + { + "name": "max_concurrent_hunters", + "kind": "int", + "annotation": "", + "required": false, + "default": 4 + }, + { + "name": "early_stop_file_threshold", + "kind": "int", + "annotation": "", + "required": false, + "default": 30 + } + ], + "prove_phase": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "hunt_result", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": false, + "default": "standard" + }, + { + "name": "max_provers", + "kind": "any", + "annotation": "int | None", + "required": false, + "default": null + }, + { + "name": "max_concurrent_provers", + "kind": "int", + "annotation": "", + "required": false, + "default": 3 + } + ], + "recon_phase": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": false, + "default": "standard" + } + ], + "remediation_phase": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "verified_findings", + "kind": "list", + "annotation": "list[dict[str, typing.Any]]", + "required": true + }, + { + "name": "max_concurrent_remediations", + "kind": "int", + "annotation": "", + "required": false, + "default": 3 + } + ], + "run_api_security_hunter": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "recon_context", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "max_files_without_signal", + "kind": "int", + "annotation": "", + "required": false, + "default": 30 + } + ], + "run_architecture_mapper": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + } + ], + "run_auth_hunter": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "recon_context", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "max_files_without_signal", + "kind": "int", + "annotation": "", + "required": false, + "default": 30 + } + ], + "run_business_logic_hunter": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "recon_context", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "max_files_without_signal", + "kind": "int", + "annotation": "", + "required": false, + "default": 30 + } + ], + "run_config_scanner": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + } + ], + "run_config_secrets_hunter": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "recon_context", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "max_files_without_signal", + "kind": "int", + "annotation": "", + "required": false, + "default": 30 + } + ], + "run_cross_service_analyzer": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "services", + "kind": "list", + "annotation": "list[str]", + "required": true + }, + { + "name": "findings_summary", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": true + } + ], + "run_crypto_hunter": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "recon_context", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "max_files_without_signal", + "kind": "int", + "annotation": "", + "required": false, + "default": 30 + } + ], + "run_cwe_expansion": [ + { + "name": "recon_summary", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "strategies", + "kind": "list", + "annotation": "list[str]", + "required": true + } + ], + "run_dast_verifier": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "finding", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "exploit_payload", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": true + } + ], + "run_data_exposure_hunter": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "recon_context", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "max_files_without_signal", + "kind": "int", + "annotation": "", + "required": false, + "default": 30 + } + ], + "run_data_flow_mapper": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "architecture", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + } + ], + "run_deduplicator": [ + { + "name": "findings", + "kind": "list", + "annotation": "list[dict[str, typing.Any]]", + "required": true + }, + { + "name": "recon_context", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + } + ], + "run_dep_reachability": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "finding", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": true + } + ], + "run_dependency_auditor": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + } + ], + "run_dos_hunter": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "recon_context", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "max_files_without_signal", + "kind": "int", + "annotation": "", + "required": false, + "default": 30 + } + ], + "run_exploit_hypothesizer": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "finding", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "data_flow", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "sanitization", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": true + } + ], + "run_injection_hunter": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "recon_context", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "max_files_without_signal", + "kind": "int", + "annotation": "", + "required": false, + "default": 30 + } + ], + "run_logic_bugs_hunter": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "recon_context", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "max_files_without_signal", + "kind": "int", + "annotation": "", + "required": false, + "default": 30 + } + ], + "run_remediation": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "finding", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + } + ], + "run_remediation_agent": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "finding", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "verdict", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "rationale", + "kind": "str", + "annotation": "", + "required": true + } + ], + "run_sanitization_analyzer": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "finding", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "data_flow", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": true + } + ], + "run_security_context_profiler": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "architecture", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + } + ], + "run_ssrf_hunter": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "recon_context", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "max_files_without_signal", + "kind": "int", + "annotation": "", + "required": false, + "default": 30 + } + ], + "run_supply_chain_hunter": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "recon_context", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "max_files_without_signal", + "kind": "int", + "annotation": "", + "required": false, + "default": 30 + } + ], + "run_tracer": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "finding", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": true + } + ], + "run_verdict_agent": [ + { + "name": "finding", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "data_flow", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "sanitization", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "exploit", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + } + ], + "run_verifier": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "finding", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": true + } + ], + "run_xss_hunter": [ + { + "name": "repo_path", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "recon_context", + "kind": "dict", + "annotation": "dict[str, typing.Any]", + "required": true + }, + { + "name": "depth", + "kind": "str", + "annotation": "", + "required": true + }, + { + "name": "max_files_without_signal", + "kind": "int", + "annotation": "", + "required": false, + "default": 30 + } + ] +} diff --git a/go/internal/reasoners/validate.go b/go/internal/reasoners/validate.go new file mode 100644 index 0000000..2be5140 --- /dev/null +++ b/go/internal/reasoners/validate.go @@ -0,0 +1,68 @@ +package reasoners + +import ( + "github.com/Agent-Field/sec-af/go/internal/phases" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// validate.go supplies what afx.Bind cannot: pydantic's REQUIRED-field, NULL +// and nested-model checks. +// +// Every adapter in this package materializes a request dict with a pydantic +// constructor — `RawFinding(**finding)`, `DataFlowTrace(**data_flow)`, +// `ArchitectureMap(**architecture)`, `ReconResult(**recon_context)` — and those +// RAISE on a payload that omits a required field, hands a non-Optional field an +// explicit null, or carries a nested element missing its OWN required fields. +// json.Unmarshal does none of that: it leaves the Go zero value in place, the +// schemas package's UnmarshalJSON actively seeds pydantic's DEFAULTS (right for +// an optional field, wrong for a required one), and a null even WIPES a seeded +// slice back to nil. +// +// The difference is BEHAVIORAL, not cosmetic, in exactly one place: +// `_coerce_verifier_finding` (prove.py:28) decides between two models by +// whether `RawFinding.model_validate` RAISES. prove_phase always feeds it a +// `FindingForVerifier` projection, which is missing nine of RawFinding's twelve +// required fields — so without a real check every verifier call would take the +// wrong branch and lose the projection's phase_boundary_projection marker, +// its CWE severity floor and its fingerprint. Everywhere else the check simply +// turns a malformed payload into an error, as Python does — and since every +// reasoner here is registered on the router, a control-plane caller can send +// such a payload directly. +// +// internal/phases owns the machinery — the modelSpec table (one entry per +// pydantic class, generated ground truth for the required/non-nullable lists), +// the error type (phases.ValidationError) and the binders. Every wrapper below +// delegates there so that each model's validation surface is transcribed ONCE: +// a second copy could drift and silently change which branch +// `_coerce_verifier_finding` takes, or leave a nested subtree unvalidated. + +// bindRawFinding is `RawFinding(**finding)` / `RawFinding.model_validate(finding)`. +func bindRawFinding(payload map[string]any) (schemas.RawFinding, error) { + return phases.BindRawFinding(payload) +} + +// bindDataFlowTrace is `DataFlowTrace(**data_flow)`. +func bindDataFlowTrace(payload map[string]any) (schemas.DataFlowTrace, error) { + return phases.BindDataFlowTrace(payload) +} + +// bindSanitizationResult is `SanitizationResult(**sanitization)`. +func bindSanitizationResult(payload map[string]any) (schemas.SanitizationResult, error) { + return phases.BindSanitizationResult(payload) +} + +// bindExploitHypothesis is `ExploitHypothesis(**exploit)`. +func bindExploitHypothesis(payload map[string]any) (schemas.ExploitHypothesis, error) { + return phases.BindExploitHypothesis(payload) +} + +// bindFindingForVerifier is `FindingForVerifier.model_validate(finding)` — the +// fallback branch of _coerce_verifier_finding. +func bindFindingForVerifier(payload map[string]any) (schemas.FindingForVerifier, error) { + return phases.BindFindingForVerifier(payload) +} + +// bindArchitectureMap is `ArchitectureMap(**architecture)` (recon.py:43, :52). +func bindArchitectureMap(payload map[string]any) (schemas.ArchitectureMap, error) { + return phases.BindArchitectureMap(payload) +} diff --git a/go/internal/reasoners/validate_test.go b/go/internal/reasoners/validate_test.go new file mode 100644 index 0000000..e57a642 --- /dev/null +++ b/go/internal/reasoners/validate_test.go @@ -0,0 +1,186 @@ +package reasoners + +// Tests for the request binds every adapter performs (validate.go). +// +// Validation contract, taken from pydantic on the pinned interpreter +// (`PYTHONPATH=src ~/.agentfield/packages/sec-af/venv/bin/python`), not from +// the Go code. Each reasoner here is registered on the router (register.go), so +// a control-plane caller can send these payloads DIRECTLY — the DAG's own +// producers are not the only source. +// +// - `ArchitectureMap(**{"modules": [{"name": "x"}]})` raises 2 errors +// (`modules.0.path`, `modules.0.language` missing), so +// run_data_flow_mapper / run_security_context_profiler answer 500 in Python. +// - `ReconResult(**{... "architecture": {"modules": [{"name": "x"}]} ...})` +// raises, so run_deduplicator answers 500 in Python. +// - `DataFlowTrace(source=None, sink="s", steps=None, sink_reached=True)` and +// `ExploitHypothesis(hypothesis=None, expected_outcome=None)` VALIDATE (the +// `mode="before"` validators coerce None to "unknown"/[]), so +// run_sanitization_analyzer / run_exploit_hypothesizer / run_verdict_agent +// answer 200 in Python. + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/sec-af/go/internal/appx" +) + +// nestedMalformedArchitecture is the payload whose element is missing `path` +// and `language` — valid at the top level, invalid one layer down. +func nestedMalformedArchitecture() map[string]any { + return map[string]any{"modules": []any{map[string]any{"name": "x"}}} +} + +// TestDeepReconValidatesNestedArchitecture covers the two reasoners that bind +// `ArchitectureMap(**architecture)`. ArchitectureMap has no required field of +// its own — TestDeepReconAcceptsAbsentArchitecture pins that an absent one is +// fine — but its list elements do. +func TestDeepReconValidatesNestedArchitecture(t *testing.T) { + repo := t.TempDir() + for _, tc := range []struct { + name string + run func(context.Context, appx.App, ArchitectureInput) (map[string]any, error) + }{ + {"run_data_flow_mapper", RunDataFlowMapper}, + {"run_security_context_profiler", RunSecurityContextProfiler}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := tc.run(context.Background(), newScanFake(), ArchitectureInput{ + RepoPath: repo, Architecture: nestedMalformedArchitecture(), + }) + if err == nil { + t.Fatal("want a validation error: ArchitectureMap(**architecture) raises on this payload") + } + if !strings.Contains(err.Error(), "modules.0.path: field required") { + t.Errorf("error %q does not name the missing nested field", err.Error()) + } + }) + } +} + +// TestRunDeduplicatorValidatesNestedRecon is the same gap on the STRICT +// `ReconResult(**recon_context)` bind run_deduplicator performs (hunt.py:287). +// Its sibling test pins the top-level check; this one pins the four subtrees +// below it. +func TestRunDeduplicatorValidatesNestedRecon(t *testing.T) { + for _, tc := range []struct { + name string + key string + value any + problem string + }{ + {"architecture", "architecture", nestedMalformedArchitecture(), + "architecture.modules.0.path: field required"}, + {"data_flows", "data_flows", + map[string]any{"flows": []any{map[string]any{"source": "a"}}}, + "data_flows.flows.0.sink: field required"}, + {"dependencies", "dependencies", + map[string]any{"sbom": []any{map[string]any{"name": "d"}}}, + "dependencies.sbom.0.version: field required"}, + {"config", "config", + map[string]any{"secrets": []any{map[string]any{"secret_type": "t"}}}, + "config.secrets.0.file_path: field required"}, + } { + t.Run(tc.name, func(t *testing.T) { + recon := fullReconContext() + recon[tc.key] = tc.value + _, err := RunDeduplicator(context.Background(), newScanFake(), DeduplicatorInput{ + Findings: []map[string]any{rawFindingPayload()}, + ReconContext: recon, + RepoPath: t.TempDir(), + }) + if err == nil { + t.Fatal("want a validation error: ReconResult(**recon_context) raises on this payload") + } + if !strings.Contains(err.Error(), tc.problem) { + t.Errorf("error %q does not mention %q", err.Error(), tc.problem) + } + }) + } +} + +// nullDataFlowPayload is `DataFlowTrace(source=None, sink="s", steps=None, +// sink_reached=True)` — VERIFIED to validate as +// `{"source": "unknown", "sink": "s", "steps": [], "sink_reached": true}`. +func nullDataFlowPayload() map[string]any { + return map[string]any{"source": nil, "sink": "s", "steps": nil, "sink_reached": true} +} + +// nullExploitPayload is `ExploitHypothesis(hypothesis=None, +// expected_outcome=None)` — VERIFIED to validate as +// `{"hypothesis": "unknown", "payload": null, "expected_outcome": "unknown"}`. +func nullExploitPayload() map[string]any { + return map[string]any{"hypothesis": nil, "payload": nil, "expected_outcome": nil} +} + +// TestBeforeValidatedNullsAreAcceptedByTheProveAdapters pins that the three +// reasoners taking a DataFlowTrace / ExploitHypothesis kwarg return a RESULT +// for a payload whose before-validated fields are explicitly null, as the +// Python node does — rather than a reasoner error. +func TestBeforeValidatedNullsAreAcceptedByTheProveAdapters(t *testing.T) { + repo := t.TempDir() + finding := rawFindingPayload() + + t.Run("run_sanitization_analyzer", func(t *testing.T) { + got, err := RunSanitizationAnalyzer(context.Background(), newScanFake(), SanitizationInput{ + RepoPath: repo, Finding: finding, DataFlow: nullDataFlowPayload(), Depth: "standard", + }) + if err != nil { + t.Fatalf("RunSanitizationAnalyzer: %v", err) + } + if _, ok := got["found"]; !ok { + t.Errorf("result is missing the %q key: %v", "found", got) + } + }) + + t.Run("run_exploit_hypothesizer", func(t *testing.T) { + got, err := RunExploitHypothesizer(context.Background(), newScanFake(), ExploitInput{ + RepoPath: repo, Finding: finding, DataFlow: nullDataFlowPayload(), + Sanitization: sanitizationPayload(), Depth: "standard", + }) + if err != nil { + t.Fatalf("RunExploitHypothesizer: %v", err) + } + if _, ok := got["hypothesis"]; !ok { + t.Errorf("result is missing the %q key: %v", "hypothesis", got) + } + }) + + t.Run("run_verdict_agent", func(t *testing.T) { + fake := newScanFake() + fake.AIFn = appx.AIJSON(func(string) (json.RawMessage, error) { + return json.RawMessage( + `{"verdict":"likely","evidence_level":2,"rationale":"r","confidence":"medium"}`), nil + }) + got, err := RunVerdictAgent(context.Background(), fake, VerdictInput{ + Finding: finding, DataFlow: nullDataFlowPayload(), + Sanitization: sanitizationPayload(), Exploit: nullExploitPayload(), + }) + if err != nil { + t.Fatalf("RunVerdictAgent: %v", err) + } + if got["verdict"] != "likely" { + t.Errorf("verdict = %v, want likely", got["verdict"]) + } + }) +} + +// TestBeforeValidatedFieldsStillRequireTheirKey is the other half of the same +// rule: a `mode="before"` validator does NOT run for an ABSENT field, so a +// missing `source` is still 1 validation error in Python. +func TestBeforeValidatedFieldsStillRequireTheirKey(t *testing.T) { + flow := nullDataFlowPayload() + delete(flow, "source") + _, err := RunSanitizationAnalyzer(context.Background(), newScanFake(), SanitizationInput{ + RepoPath: t.TempDir(), Finding: rawFindingPayload(), DataFlow: flow, Depth: "standard", + }) + if err == nil { + t.Fatal("want a validation error: a MISSING source is still `missing` in pydantic") + } + if !strings.Contains(err.Error(), "source: field required") { + t.Errorf("error %q does not name the missing field", err.Error()) + } +} diff --git a/go/internal/recontext/context.go b/go/internal/recontext/context.go new file mode 100644 index 0000000..0a1ea63 --- /dev/null +++ b/go/internal/recontext/context.go @@ -0,0 +1,826 @@ +// Package recontext ports src/sec_af/context.py — the strategy-aware RECON +// projections that every HUNT prompt embeds. +// +// The package is named `recontext` rather than `context` for the obvious +// reason: a package called `context` inside this module would shadow the +// standard library's in every file that imports both, and every reasoner in the +// port takes a context.Context. +// +// Two kinds of output live here and they are used very differently: +// +// - prune_recon_for_strategy returns a DICT that travels over the wire as the +// `recon_context` kwarg of a `.call` (reasoners/phases.py:294), so only its +// key SET matters, not its rendering; +// - every recon_context_for_* / *_hints_for_context function returns a STRING +// that is substituted into a prompt template and shipped to the model, so +// its bytes matter exactly. Those are golden-tested against the Python +// functions run over the same fixture (testdata/recon_fixture.json). +package recontext + +import ( + "sort" + "strconv" + "strings" + + "github.com/Agent-Field/sec-af/go/internal/afx" + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// Truncation limits, ported from context.py's module constants. +const ( + MaxPrimaryItems = 15 // _MAX_PRIMARY_ITEMS + MaxSecondaryItems = 10 // _MAX_SECONDARY_ITEMS +) + +// BaseReconFields ports _BASE_RECON_FIELDS: the ReconResult fields every +// strategy projection keeps regardless of which sections it asked for. +var BaseReconFields = []string{"languages", "frameworks", "lines_of_code", "file_count"} + +// StrategyContextMap ports STRATEGY_CONTEXT_MAP: strategy VALUE (the +// HuntStrategy enum's string, e.g. "business_logic") -> the ReconResult field +// names its hunter needs. +// +// Python parity: strategies absent from this table — "logic_bugs" is not a +// separate value (it is an alias of "business_logic"), but "python_specific" +// and "javascript_specific" genuinely are missing — fall through to the full +// model_dump() in PruneReconForStrategy. +var StrategyContextMap = map[string][]string{ + "injection": {"architecture", "data_flows", "security_context"}, + "xss": {"architecture", "data_flows", "security_context"}, + "ssrf": {"architecture", "data_flows", "security_context"}, + "auth": {"architecture", "security_context"}, + "crypto": {"security_context"}, + "dos": {"architecture", "data_flows"}, + "data_exposure": {"architecture", "data_flows", "config"}, + "supply_chain": {"dependencies"}, + "config_secrets": {"config", "architecture"}, + "api_security": {"architecture", "security_context", "data_flows"}, + "business_logic": {"architecture", "data_flows", "security_context"}, +} + +// PruneReconForStrategy ports prune_recon_for_strategy: +// +// required_fields = STRATEGY_CONTEXT_MAP.get(strategy) +// if required_fields is None: +// return recon.model_dump() +// include_fields = set(_BASE_RECON_FIELDS) +// include_fields.update(required_fields) +// return recon.model_dump(include=include_fields) +// +// The returned map is the pydantic model_dump() shape: keys are the snake_case +// pydantic field names (== the Go json tags), values are the nested models. +// afx.ToMap keeps the values TYPED rather than round-tripping them through +// JSON, which matters for two reasons: a float field with an integral value +// stays a float (`10.0`, not `10`) when pyfmt.Dumps renders it, and the nested +// structs keep their declaration order for anything that renders them. +// +// Python parity notes: +// +// - KEY ORDER. `model_dump(include={...})` emits the surviving keys in +// ReconResult DECLARATION order (architecture, data_flows, dependencies, +// config, security_context, languages, frameworks, lines_of_code, +// file_count, recon_duration_seconds), not in the include-set's order. A Go +// map has no order at all, which is immaterial here: the only consumer is +// `recon_context=strategy_context` on a `.call`, i.e. a JSON object where +// member order carries no meaning. +// - `recon_duration_seconds` is in NO strategy's projection and is not a base +// field, so it survives only on the unmapped-strategy path that returns the +// full dump. +// - The error return has no Python counterpart; afx.ToMap only fails on a +// non-struct argument, which cannot happen for a ReconResult. +func PruneReconForStrategy(recon schemas.ReconResult, strategy string) (map[string]any, error) { + dump, err := afx.ToMap(recon) + if err != nil { + return nil, err + } + requiredFields, ok := StrategyContextMap[strategy] + if !ok { + return dump, nil + } + + include := make(map[string]struct{}, len(BaseReconFields)+len(requiredFields)) + for _, f := range BaseReconFields { + include[f] = struct{}{} + } + for _, f := range requiredFields { + include[f] = struct{}{} + } + + out := make(map[string]any, len(include)) + for field := range include { + if v, present := dump[field]; present { + out[field] = v + } + } + return out, nil +} + +// --------------------------------------------------------------------------- +// list rendering +// --------------------------------------------------------------------------- + +// limit ports _limit: +// +// rows = [item for item in items if item] +// return rows[:max_items], len(rows) +// +// Python parity: the filter is TRUTHINESS, so empty strings are dropped BEFORE +// the count is taken — a list of ten items with three blanks reports "7 total". +func limit(items []string, maxItems int) ([]string, int) { + rows := make([]string, 0, len(items)) + for _, item := range items { + if item != "" { + rows = append(rows, item) + } + } + if len(rows) > maxItems { + return rows[:maxItems], len(rows) + } + return rows, len(rows) +} + +// renderList ports _render_list: +// +// trimmed, total = _limit(items, max_items) +// if total == 0: return f"{title}: none identified in recon." +// lines = [f"{title}: {total} total, showing top {len(trimmed)}:"] +// lines.extend(f"- {item}" for item in trimmed) +// return "\n".join(lines) +func renderList(title string, items []string, maxItems int) string { + trimmed, total := limit(items, maxItems) + if total == 0 { + return title + ": none identified in recon." + } + var b strings.Builder + b.WriteString(title) + b.WriteString(": ") + b.WriteString(strconv.Itoa(total)) + b.WriteString(" total, showing top ") + b.WriteString(strconv.Itoa(len(trimmed))) + b.WriteString(":") + for _, item := range trimmed { + b.WriteString("\n- ") + b.WriteString(item) + } + return b.String() +} + +// sections joins the top-level blocks the way every builder does: +// "\n\n".join([...]). +func sections(parts ...string) string { return strings.Join(parts, "\n\n") } + +// --------------------------------------------------------------------------- +// small Python-semantics helpers +// --------------------------------------------------------------------------- + +// joinOr reproduces `', '.join(items) or fallback` — an empty join result is +// falsy in Python, so an empty list (and a list of empty strings) yields the +// fallback. +func joinOr(items []string, fallback string) string { + joined := strings.Join(items, ", ") + if joined == "" { + return fallback + } + return joined +} + +// head reproduces Python's `seq[:n]`, which never panics on a short sequence. +func head(items []string, n int) []string { + if len(items) > n { + return items[:n] + } + return items +} + +// str renders an `X | None` scalar the way an f-string does: str(None) is the +// literal "None", str(True) is "True", str(1.0) is "1.0". pyfmt.Str handles the +// pointer deref and the Python spellings for bool/int/float. +func str(v any) string { return pyfmt.Str(v) } + +// strPtr is str() for a `str | None` field. It cannot go through pyfmt.Str, +// which would fall into Repr and QUOTE the string; an f-string interpolating a +// str emits it bare. +func strPtr(p *string) string { + if p == nil { + return "None" + } + return *p +} + +// orStr reproduces `value or fallback` for a `str | None` field: None AND the +// empty string are both falsy and both take the fallback. +func orStr(p *string, fallback string) string { + if p == nil || *p == "" { + return fallback + } + return *p +} + +// orZeroInt reproduces `value or 0` for an `int | None` field. 0 is falsy in +// Python too, so an explicit zero and a missing value render identically. +func orZeroInt(p *int) int { + if p == nil { + return 0 + } + return *p +} + +// truthyBool reproduces Python truthiness for a `bool | None` field: None and +// False are falsy, True is truthy. +func truthyBool(p *bool) bool { return p != nil && *p } + +// isFalse reproduces the `x is False` identity test used by _endpoint_rank_key: +// ONLY an explicit False satisfies it — None does not. +func isFalse(p *bool) bool { return p != nil && !*p } + +// containsAnyLower reports whether any token appears in strings.ToLower(haystack), +// reproducing `any(token in haystack.lower() for token in tokens)`. +// +// Python parity: str.lower() and strings.ToLower are both full Unicode +// lowercasers and agree on everything except a few code points whose lowercase +// form changes length (U+0130). Repository identifiers do not contain those. +func containsAnyLower(haystack string, tokens ...string) bool { + lowered := strings.ToLower(haystack) + for _, token := range tokens { + if strings.Contains(lowered, token) { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// ranking helpers +// --------------------------------------------------------------------------- + +// endpointRankKey ports _endpoint_rank_key: +// +// return (0 if endpoint_auth_required is False else 1, +// 0 if endpoint_rate_limited is False else 1) +// +// Python parity: this sorts endpoints that EXPLICITLY declare "no auth" / +// "no rate limit" to the front. An UNKNOWN (None) endpoint ranks with the +// protected ones, not with the unprotected ones — `is False` is an identity +// test, not a truthiness test. +func endpointRankKey(authRequired, rateLimited *bool) (int, int) { + first, second := 1, 1 + if isFalse(authRequired) { + first = 0 + } + if isFalse(rateLimited) { + second = 0 + } + return first, second +} + +// rankedEndpoints reproduces `sorted(api_surface, key=_endpoint_rank_key)`. +// +// Python's sort is STABLE, so endpoints sharing a rank keep their recon order; +// sort.SliceStable is the matching Go primitive. The input slice is copied so +// the caller's ReconResult is not reordered — Python's sorted() also returns a +// new list. +func rankedEndpoints(endpoints []schemas.APIEndpoint) []schemas.APIEndpoint { + out := make([]schemas.APIEndpoint, len(endpoints)) + copy(out, endpoints) + sort.SliceStable(out, func(i, j int) bool { + ai, bi := endpointRankKey(out[i].AuthRequired, out[i].RateLimited) + aj, bj := endpointRankKey(out[j].AuthRequired, out[j].RateLimited) + if ai != aj { + return ai < aj + } + return bi < bj + }) + return out +} + +// cvePriority ports _cve_priority: +// +// reachable_rank = 0 if cve.reachable else 1 +// cvss = cve.cvss_v4_score if cve.cvss_v4_score is not None else -1.0 +// epss = cve.epss_score if cve.epss_score is not None else -1.0 +// direct_rank = 0 if cve.direct else 1 +// return (reachable_rank, -cvss, -epss, direct_rank) +// +// Python parity: reachable uses TRUTHINESS (None ranks with False), while the +// score defaults use an explicit `is not None` check — so a CVE with no CVSS +// sorts BELOW one scored 0.0 (its key component is +1.0 versus -0.0). +func cvePriority(cve schemas.KnownCVE) (int, float64, float64, int) { + reachableRank := 1 + if truthyBool(cve.Reachable) { + reachableRank = 0 + } + cvss := -1.0 + if cve.CvssV4Score != nil { + cvss = *cve.CvssV4Score + } + epss := -1.0 + if cve.EpssScore != nil { + epss = *cve.EpssScore + } + directRank := 1 + if cve.Direct { + directRank = 0 + } + return reachableRank, -cvss, -epss, directRank +} + +// prioritizedCVEs reproduces `sorted(known_cves, key=_cve_priority)` — a stable +// sort on the 4-tuple, compared component by component. +func prioritizedCVEs(cves []schemas.KnownCVE) []schemas.KnownCVE { + out := make([]schemas.KnownCVE, len(cves)) + copy(out, cves) + sort.SliceStable(out, func(i, j int) bool { + ai, bi, ci, di := cvePriority(out[i]) + aj, bj, cj, dj := cvePriority(out[j]) + if ai != aj { + return ai < aj + } + if bi != bj { + return bi < bj + } + if ci != cj { + return ci < cj + } + return di < dj + }) + return out +} + +// weakCryptoFirst reproduces `sorted(crypto_usage, key=lambda u: 0 if u.is_weak else 1)` +// — truthiness again, so an UNKNOWN (None) is_weak sorts with the strong +// algorithms. +func weakCryptoFirst(usages []schemas.CryptoUsage) []schemas.CryptoUsage { + out := make([]schemas.CryptoUsage, len(usages)) + copy(out, usages) + sort.SliceStable(out, func(i, j int) bool { + return truthyBool(out[i].IsWeak) && !truthyBool(out[j].IsWeak) + }) + return out +} + +// --------------------------------------------------------------------------- +// row builders shared by several projections +// --------------------------------------------------------------------------- + +// entryPointRow renders `f"{entry.kind} {entry.route or entry.identifier} ({entry.file_path}:{entry.line})"`. +func entryPointRow(entry schemas.EntryPoint) string { + return entry.Kind + " " + orStr(entry.Route, entry.Identifier) + + " (" + entry.FilePath + ":" + strconv.Itoa(entry.Line) + ")" +} + +// moduleRow renders +// `f"{module.path} ({module.language})" + (f" - {module.description}" if module.description else "")`. +func moduleRow(module schemas.Module) string { + row := module.Path + " (" + module.Language + ")" + if module.Description != nil && *module.Description != "" { + row += " - " + *module.Description + } + return row +} + +// rankedEndpointRow renders the long endpoint line shared by +// recon_context_for_auth and recon_context_for_api_security. +func rankedEndpointRow(endpoint schemas.APIEndpoint) string { + return endpoint.Method + " " + endpoint.Path + " -> " + endpoint.Handler + + " (" + endpoint.FilePath + ":" + strconv.Itoa(endpoint.Line) + + ", auth_required=" + str(endpoint.AuthRequired) + + ", rate_limited=" + str(endpoint.RateLimited) + ")" +} + +// misconfigRow renders +// `f"{m.category} at {m.file_path}:{m.line or 0}; risk={m.risk}; key={m.key or 'n/a'}"`. +func misconfigRow(misconfig schemas.MisconfigFinding) string { + return misconfig.Category + " at " + misconfig.FilePath + ":" + + strconv.Itoa(orZeroInt(misconfig.Line)) + "; risk=" + misconfig.Risk + + "; key=" + orStr(misconfig.Key, "n/a") +} + +// codebaseProfile renders the "N files, N LOC, languages=..., frameworks=..." +// clause that the injection and generic summaries share (with different +// leading words). +func codebaseProfile(recon schemas.ReconResult) string { + return strconv.Itoa(recon.FileCount) + " files, " + strconv.Itoa(recon.LinesOfCode) + + " LOC, languages=" + joinOr(recon.Languages, "unknown") + + ", frameworks=" + joinOr(recon.Frameworks, "unknown") + "." +} + +// --------------------------------------------------------------------------- +// the eight strategy projections + the generic fallback +// --------------------------------------------------------------------------- + +// ReconContextForInjection ports recon_context_for_injection. +// +// Python parity: the flow list prefers UNSANITIZED flows and only falls back to +// every flow when there are none — `unsanitized_flows if unsanitized_flows else +// recon.data_flows.flows`, a truthiness test on the filtered list. +func ReconContextForInjection(recon schemas.ReconResult) string { + flowCandidates := make([]schemas.DataFlow, 0, len(recon.DataFlows.Flows)) + for _, flow := range recon.DataFlows.Flows { + if !flow.Sanitized { + flowCandidates = append(flowCandidates, flow) + } + } + if len(flowCandidates) == 0 { + flowCandidates = recon.DataFlows.Flows + } + + entryPointRows := make([]string, 0, len(recon.Architecture.EntryPoints)) + for _, entry := range recon.Architecture.EntryPoints { + entryPointRows = append(entryPointRows, entryPointRow(entry)) + } + + sinkRows := make([]string, 0, len(recon.DataFlows.Sinks)) + for _, sink := range recon.DataFlows.Sinks { + row := sink.SinkType + " at " + sink.FilePath + ":" + strconv.Itoa(sink.Line) + if sink.FunctionName != nil && *sink.FunctionName != "" { + row += " (" + *sink.FunctionName + ")" + } + sinkRows = append(sinkRows, row) + } + + flowRows := make([]string, 0, len(flowCandidates)) + for _, flow := range flowCandidates { + flowRows = append(flowRows, flow.Source+" -> "+flow.Sink+"; sanitized="+str(flow.Sanitized)+ + "; files="+strings.Join(head(flow.Files, 3), ", ")) + } + + sanitizationRows := make([]string, 0, len(recon.DataFlows.SanitizationPoints)) + for _, point := range recon.DataFlows.SanitizationPoints { + sanitizationRows = append(sanitizationRows, point.FilePath+":"+strconv.Itoa(point.Line)+ + " type="+point.SanitizationType+" protects="+joinOr(point.ProtectsAgainst, "unspecified")) + } + + return sections( + "Injection-focused recon summary.", + "Codebase profile: "+codebaseProfile(recon), + renderList("Entry points likely to receive untrusted input", entryPointRows, MaxPrimaryItems), + renderList("High-value sinks", sinkRows, MaxPrimaryItems), + renderList("Source-to-sink flow candidates (unsanitized first)", flowRows, MaxPrimaryItems), + renderList("Known sanitization points", sanitizationRows, MaxSecondaryItems), + ) +} + +// authModuleTokens is the token tuple recon_context_for_auth scans module +// name/path/description for, in Python order (order is immaterial to `any`, but +// the transcription is kept literal). +var authModuleTokens = []string{"auth", "session", "rbac", "permission", "role", "guard", "middleware", "csrf", "jwt"} + +// authFlowTokens is the token tuple recon_context_for_auth scans data flows for. +var authFlowTokens = []string{"auth", "token", "jwt", "session", "cookie", "csrf", "role", "permission", "scope"} + +// ReconContextForAuth ports recon_context_for_auth. +func ReconContextForAuth(recon schemas.ReconResult) string { + authRelatedModules := make([]string, 0, len(recon.Architecture.Modules)) + for _, module := range recon.Architecture.Modules { + // Python: f"{module.name} {module.path} {(module.description or '')}".lower() + haystack := module.Name + " " + module.Path + " " + orStr(module.Description, "") + if containsAnyLower(haystack, authModuleTokens...) { + authRelatedModules = append(authRelatedModules, moduleRow(module)) + } + } + + endpointRows := make([]string, 0, len(recon.Architecture.APISurface)) + for _, endpoint := range rankedEndpoints(recon.Architecture.APISurface) { + endpointRows = append(endpointRows, rankedEndpointRow(endpoint)) + } + + flowRows := make([]string, 0, len(recon.DataFlows.Flows)) + for _, flow := range recon.DataFlows.Flows { + haystack := flow.Source + " " + flow.Sink + " " + strings.Join(flow.Files, " ") + if !containsAnyLower(haystack, authFlowTokens...) { + continue + } + flowRows = append(flowRows, flow.Source+" -> "+flow.Sink+ + " (files="+strings.Join(head(flow.Files, 3), ", ")+", sanitized="+str(flow.Sanitized)+")") + } + + signals := make([]string, 0, len(recon.SecurityContext.SecurityHeaders)+len(recon.SecurityContext.FrameworkSecurity)) + signals = append(signals, recon.SecurityContext.SecurityHeaders...) + signals = append(signals, recon.SecurityContext.FrameworkSecurity...) + + return sections( + "Authentication/authorization-focused recon summary.", + "Auth model: "+recon.SecurityContext.AuthModel+". Details: "+ + orStr(&recon.SecurityContext.AuthDetails, "none provided")+".", + renderList("Auth/session/RBAC modules and middleware candidates", authRelatedModules, MaxPrimaryItems), + renderList("API endpoints to validate for auth/authz coverage", endpointRows, MaxPrimaryItems), + renderList("Auth/session-relevant data flows", flowRows, MaxSecondaryItems), + renderList("Security headers and framework security signals", signals, MaxSecondaryItems), + ) +} + +// ReconContextForCrypto ports recon_context_for_crypto. +func ReconContextForCrypto(recon schemas.ReconResult) string { + usageRows := make([]string, 0, len(recon.SecurityContext.CryptoUsage)) + for _, usage := range weakCryptoFirst(recon.SecurityContext.CryptoUsage) { + usageRows = append(usageRows, "algorithm="+usage.Algorithm+ + ", key_size="+str(usage.KeySize)+ + ", mode="+strPtr(usage.Mode)+ + ", context="+orStr(usage.UsageContext, "unspecified")+ + ", is_weak="+str(usage.IsWeak)) + } + + secretRows := make([]string, 0, len(recon.Config.Secrets)) + for _, secret := range recon.Config.Secrets { + secretRows = append(secretRows, secret.SecretType+" at "+secret.FilePath+":"+ + strconv.Itoa(secret.Line)+" (confidence="+secret.Confidence+")") + } + + signals := make([]string, 0, len(recon.SecurityContext.DeploymentSignals)+len(recon.SecurityContext.SecurityHeaders)) + signals = append(signals, recon.SecurityContext.DeploymentSignals...) + signals = append(signals, recon.SecurityContext.SecurityHeaders...) + + return sections( + "Cryptography-focused recon summary.", + "Crypto usage entries: "+strconv.Itoa(len(recon.SecurityContext.CryptoUsage))+" total.", + renderList("Algorithms and key handling (weak entries first)", usageRows, MaxPrimaryItems), + renderList("Potential secret/key findings from config scan", secretRows, MaxSecondaryItems), + renderList("Deployment/TLS/security header signals", signals, MaxSecondaryItems), + ) +} + +// dataExposureFlowTokens is the sensitive-domain token tuple +// recon_context_for_data_exposure scans data flows for. +var dataExposureFlowTokens = []string{ + "password", "token", "secret", "credential", "session", "cookie", + "email", "phone", "pii", "ssn", "card", "auth", "user", +} + +// dataExposureMisconfigTokens is the token tuple the same function scans +// misconfigs for. +var dataExposureMisconfigTokens = []string{"log", "debug", "trace", "error", "verbose", "tls", "http", "exposure"} + +// ReconContextForDataExposure ports recon_context_for_data_exposure. +func ReconContextForDataExposure(recon schemas.ReconResult) string { + flowRows := make([]string, 0, len(recon.DataFlows.Flows)) + for _, flow := range recon.DataFlows.Flows { + haystack := flow.Source + " " + flow.Sink + " " + strings.Join(flow.Files, " ") + if !containsAnyLower(haystack, dataExposureFlowTokens...) { + continue + } + flowRows = append(flowRows, flow.Source+" -> "+flow.Sink+"; sanitized="+str(flow.Sanitized)+ + "; files="+strings.Join(head(flow.Files, 3), ", ")) + } + + misconfigRows := make([]string, 0, len(recon.Config.Misconfigs)) + for _, misconfig := range recon.Config.Misconfigs { + // Python: f"{m.category} {m.key or ''} {m.value or ''} {m.risk}".lower() + haystack := misconfig.Category + " " + orStr(misconfig.Key, "") + " " + + orStr(misconfig.Value, "") + " " + misconfig.Risk + if !containsAnyLower(haystack, dataExposureMisconfigTokens...) { + continue + } + misconfigRows = append(misconfigRows, misconfigRow(misconfig)) + } + + endpointRows := make([]string, 0, len(recon.Architecture.APISurface)) + for _, endpoint := range recon.Architecture.APISurface { + endpointRows = append(endpointRows, endpoint.Method+" "+endpoint.Path+ + " ("+endpoint.FilePath+":"+strconv.Itoa(endpoint.Line)+ + ", auth_required="+str(endpoint.AuthRequired)+")") + } + + return sections( + "Data exposure-focused recon summary.", + renderList("Data flows touching likely sensitive domains", flowRows, MaxPrimaryItems), + renderList("Logging/exposure-related misconfig signals", misconfigRows, MaxSecondaryItems), + renderList("Entry points and API surface with exposure risk", endpointRows, MaxSecondaryItems), + ) +} + +// ReconContextForConfigSecrets ports recon_context_for_config_secrets. +func ReconContextForConfigSecrets(recon schemas.ReconResult) string { + secretRows := make([]string, 0, len(recon.Config.Secrets)) + for _, secret := range recon.Config.Secrets { + secretRows = append(secretRows, secret.SecretType+" at "+secret.FilePath+":"+ + strconv.Itoa(secret.Line)+"; confidence="+secret.Confidence+ + "; is_test_value="+str(secret.IsTestValue)) + } + + misconfigRows := make([]string, 0, len(recon.Config.Misconfigs)) + for _, misconfig := range recon.Config.Misconfigs { + misconfigRows = append(misconfigRows, misconfigRow(misconfig)) + } + + signals := make([]string, 0, len(recon.SecurityContext.DeploymentSignals)+len(recon.SecurityContext.FrameworkSecurity)) + signals = append(signals, recon.SecurityContext.DeploymentSignals...) + signals = append(signals, recon.SecurityContext.FrameworkSecurity...) + + return sections( + "Config and secrets-focused recon summary.", + renderList("Detected secret-like findings", secretRows, MaxPrimaryItems), + renderList("Configuration weaknesses from recon", misconfigRows, MaxPrimaryItems), + renderList("Security/deployment context affecting config risk", signals, MaxSecondaryItems), + ) +} + +// ReconContextForSupplyChain ports recon_context_for_supply_chain. +func ReconContextForSupplyChain(recon schemas.ReconResult) string { + cveRows := make([]string, 0, len(recon.Dependencies.KnownCves)) + for _, cve := range prioritizedCVEs(recon.Dependencies.KnownCves) { + cveRows = append(cveRows, cve.CveID+" in "+cve.Package+" "+cve.InstalledVersion+ + " (fixed="+orStr(cve.FixedVersion, "unknown")+ + ", cvss="+str(cve.CvssV4Score)+ + ", epss="+str(cve.EpssScore)+ + ", direct="+str(cve.Direct)+ + ", reachable="+str(cve.Reachable)+")") + } + + outdatedRows := make([]string, 0, len(recon.Dependencies.Outdated)) + for _, dep := range recon.Dependencies.Outdated { + outdatedRows = append(outdatedRows, dep.Package+": "+dep.CurrentVersion+" -> "+ + dep.LatestVersion+" (direct="+str(dep.Direct)+")") + } + + // Python: sorted({f"{dep.ecosystem}: {dep.name}@{dep.version}" for dep in sbom}) + // — a SET comprehension, so duplicate entries collapse, then a lexicographic + // sort. Go's sort.Strings compares bytes, which for UTF-8 is the same order + // as Python's code-point comparison. + seen := make(map[string]struct{}, len(recon.Dependencies.Sbom)) + ecosystems := make([]string, 0, len(recon.Dependencies.Sbom)) + for _, dep := range recon.Dependencies.Sbom { + row := dep.Ecosystem + ": " + dep.Name + "@" + dep.Version + if _, dup := seen[row]; dup { + continue + } + seen[row] = struct{}{} + ecosystems = append(ecosystems, row) + } + sort.Strings(ecosystems) + + return sections( + "Supply-chain-focused recon summary.", + "Dependency inventory: direct="+strconv.Itoa(recon.Dependencies.DirectCount)+ + ", transitive="+strconv.Itoa(recon.Dependencies.TransitiveCount)+ + ", SBOM entries="+strconv.Itoa(len(recon.Dependencies.Sbom))+".", + renderList("Known CVE exposure (reachable/high severity first)", cveRows, MaxPrimaryItems), + renderList("Outdated dependencies", outdatedRows, MaxSecondaryItems), + renderList("Primary dependency ecosystems in this repo", ecosystems, MaxSecondaryItems), + ) +} + +// apiEntryKinds is the entry-point kind allowlist recon_context_for_api_security +// filters on (`entry.kind.lower() in {...}`). +var apiEntryKinds = map[string]struct{}{ + "http": {}, "api": {}, "graphql": {}, "rpc": {}, "route": {}, +} + +// ReconContextForAPISecurity ports recon_context_for_api_security. +func ReconContextForAPISecurity(recon schemas.ReconResult) string { + endpointRows := make([]string, 0, len(recon.Architecture.APISurface)) + for _, endpoint := range rankedEndpoints(recon.Architecture.APISurface) { + endpointRows = append(endpointRows, rankedEndpointRow(endpoint)) + } + + entryRows := make([]string, 0, len(recon.Architecture.EntryPoints)) + for _, entry := range recon.Architecture.EntryPoints { + if _, ok := apiEntryKinds[strings.ToLower(entry.Kind)]; !ok { + continue + } + entryRows = append(entryRows, entry.Kind+" "+orStr(entry.Route, entry.Identifier)+ + " ("+entry.FilePath+":"+strconv.Itoa(entry.Line)+ + ", auth_required="+str(entry.AuthRequired)+")") + } + + boundaryRows := make([]string, 0, len(recon.Architecture.TrustBoundaries)) + for _, boundary := range recon.Architecture.TrustBoundaries { + boundaryRows = append(boundaryRows, boundary.Name+": "+boundary.SourceZone+" -> "+ + boundary.TargetZone+"; enforcement="+joinOr(boundary.Enforcement, "none")) + } + + signals := make([]string, 0, len(recon.SecurityContext.FrameworkSecurity)+len(recon.SecurityContext.DeploymentSignals)) + signals = append(signals, recon.SecurityContext.FrameworkSecurity...) + signals = append(signals, recon.SecurityContext.DeploymentSignals...) + + return sections( + "API security-focused recon summary.", + renderList("API endpoints prioritized by missing auth/rate-limits", endpointRows, MaxPrimaryItems), + renderList("HTTP/API entry points", entryRows, MaxSecondaryItems), + renderList("Trust boundaries relevant to API calls", boundaryRows, MaxSecondaryItems), + renderList("Framework/deployment API security signals", signals, MaxSecondaryItems), + ) +} + +// ReconContextForLogic ports recon_context_for_logic — the projection +// HuntStrategy.BUSINESS_LOGIC (a.k.a. LOGIC_BUGS) uses. +func ReconContextForLogic(recon schemas.ReconResult) string { + moduleRows := make([]string, 0, len(recon.Architecture.Modules)) + for _, module := range recon.Architecture.Modules { + moduleRows = append(moduleRows, moduleRow(module)) + } + + entryRows := make([]string, 0, len(recon.Architecture.EntryPoints)) + for _, entry := range recon.Architecture.EntryPoints { + entryRows = append(entryRows, entryPointRow(entry)) + } + + // Python parity: this one slices five files, not three. + flowRows := make([]string, 0, len(recon.DataFlows.Flows)) + for _, flow := range recon.DataFlows.Flows { + flowRows = append(flowRows, flow.Source+" -> "+flow.Sink+ + "; files="+strings.Join(head(flow.Files, 5), ", ")+ + "; sanitized="+str(flow.Sanitized)) + } + + // Python parity: boundaries and services are unpacked into ONE list, so the + // 10-item cap is shared between them and every boundary row precedes every + // service row. + transitions := make([]string, 0, len(recon.Architecture.TrustBoundaries)+len(recon.Architecture.Services)) + for _, boundary := range recon.Architecture.TrustBoundaries { + transitions = append(transitions, "boundary "+boundary.Name+": "+ + boundary.SourceZone+"->"+boundary.TargetZone) + } + for _, service := range recon.Architecture.Services { + transitions = append(transitions, "service "+service.Name+": type="+service.ServiceType+ + ", endpoint="+orStr(service.Endpoint, "n/a")+ + ", auth="+orStr(service.AuthMechanism, "n/a")) + } + + return sections( + "Business-logic-focused recon summary.", + renderList("Core modules likely to implement workflows and state transitions", moduleRows, MaxPrimaryItems), + renderList("Workflow entry points", entryRows, MaxSecondaryItems), + renderList("Cross-file data/control flow candidates", flowRows, MaxPrimaryItems), + renderList("Trust boundaries and external service transitions", transitions, MaxSecondaryItems), + ) +} + +// ReconContextGeneric ports recon_context_generic — the fallback projection for +// every strategy without a dedicated builder, and the recon summary +// hunt_phase feeds to the CWE-expansion gate (reasoners/phases.py:277). +func ReconContextGeneric(recon schemas.ReconResult) string { + entryRows := make([]string, 0, len(recon.Architecture.EntryPoints)) + for _, entry := range recon.Architecture.EntryPoints { + entryRows = append(entryRows, entryPointRow(entry)) + } + + endpointRows := make([]string, 0, len(recon.Architecture.APISurface)) + for _, endpoint := range recon.Architecture.APISurface { + endpointRows = append(endpointRows, endpoint.Method+" "+endpoint.Path+ + " ("+endpoint.FilePath+":"+strconv.Itoa(endpoint.Line)+")") + } + + flowRows := make([]string, 0, len(recon.DataFlows.Flows)) + for _, flow := range recon.DataFlows.Flows { + flowRows = append(flowRows, flow.Source+" -> "+flow.Sink+"; sanitized="+str(flow.Sanitized)) + } + + return sections( + "General recon summary.", + "Profile: "+codebaseProfile(recon), + renderList("Top entry points", entryRows, MaxSecondaryItems), + renderList("Top API endpoints", endpointRows, MaxSecondaryItems), + renderList("Top data-flow candidates", flowRows, MaxSecondaryItems), + ) +} + +// --------------------------------------------------------------------------- +// hint wrappers and the strategy dispatch +// --------------------------------------------------------------------------- + +// LanguageHintsForContext ports language_hints_for_context — "Build +// language-specific hints from recon-detected languages." +func LanguageHintsForContext(recon schemas.ReconResult) string { + return GetLanguageHints(recon.Languages) +} + +// FrameworkHintsForContext ports framework_hints_for_context — "Build +// framework-specific hints from recon-detected frameworks." +func FrameworkHintsForContext(recon schemas.ReconResult) string { + return GetFrameworkHints(recon.Frameworks) +} + +// strategyBuilders ports the `builders` dict inside get_context_for_strategy. +// +// Python parity: the dict has eight entries but nine strategies reach it, +// because HuntStrategy.LOGIC_BUGS IS HuntStrategy.BUSINESS_LOGIC (an enum value +// alias). Go's schemas.HuntStrategyLogicBugs and schemas.HuntStrategyBusinessLogic +// are likewise the same constant "business_logic", so listing both here would +// be a duplicate-key compile error — the single entry covers both spellings. +var strategyBuilders = map[schemas.HuntStrategy]func(schemas.ReconResult) string{ + schemas.HuntStrategyInjection: ReconContextForInjection, + schemas.HuntStrategyAuth: ReconContextForAuth, + schemas.HuntStrategyCrypto: ReconContextForCrypto, + schemas.HuntStrategyDataExposure: ReconContextForDataExposure, + schemas.HuntStrategyConfigSecrets: ReconContextForConfigSecrets, + schemas.HuntStrategySupplyChain: ReconContextForSupplyChain, + schemas.HuntStrategyAPISecurity: ReconContextForAPISecurity, + schemas.HuntStrategyBusinessLogic: ReconContextForLogic, +} + +// GetContextForStrategy ports get_context_for_strategy: +// +// builder = builders.get(strategy, recon_context_generic) +// return builder(recon) +// +// XSS, SSRF and DOS have entries in STRATEGY_CONTEXT_MAP but NOT here, so their +// prompt context is the generic summary while their pruned dict still drops the +// sections they do not need. (Their hunter modules build their own JSON context +// block instead — agents/hunt/{xss,ssrf,dos}.py.) +func GetContextForStrategy(strategy schemas.HuntStrategy, recon schemas.ReconResult) string { + if builder, ok := strategyBuilders[strategy]; ok { + return builder(recon) + } + return ReconContextGeneric(recon) +} diff --git a/go/internal/recontext/context_test.go b/go/internal/recontext/context_test.go new file mode 100644 index 0000000..bcd2829 --- /dev/null +++ b/go/internal/recontext/context_test.go @@ -0,0 +1,542 @@ +package recontext + +// Parity tests for src/sec_af/context.py. +// +// Every expectation is a COMMITTED GOLDEN produced by running the real Python +// function over testdata/recon_fixture.json: +// +// PYTHONPATH=src ~/.agentfield/packages/sec-af/venv/bin/python go/scripts/gen_golden.py +// +// The fixture is deliberately oversized — 19 modules, 17 entry points, 20 API +// endpoints, 20 data flows, 18 sinks, 18 CVEs, 17 secrets, 16 misconfigs, 16 +// crypto entries — so that every _MAX_PRIMARY_ITEMS (15) and +// _MAX_SECONDARY_ITEMS (10) truncation fires, every stable-sort tie is +// exercised, and the "N total, showing top M" counters differ from each other. +// It also carries the awkward cases on purpose: None routes, None +// auth_required/rate_limited/reachable/is_weak, a CVE with no CVSS and no EPSS, +// empty enforcement/protects_against lists, empty-string security signals (which +// _limit's truthiness filter must DROP before counting), a misconfig with +// line=None and one with line=0, duplicate SBOM entries (which the set +// comprehension must collapse), and repeated/aliased/padded language and +// framework names. + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +func loadReconFixture(t *testing.T) schemas.ReconResult { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", "recon_fixture.json")) + if err != nil { + t.Fatalf("read recon_fixture.json: %v", err) + } + var recon schemas.ReconResult + if err := json.Unmarshal(raw, &recon); err != nil { + t.Fatalf("unmarshal ReconResult: %v", err) + } + return recon +} + +func golden(t *testing.T, name string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", "golden", name)) + if err != nil { + t.Fatalf("read golden %s: %v (regenerate with go/scripts/gen_golden.py)", name, err) + } + return string(raw) +} + +func goldenJSON(t *testing.T, name string, dest any) { + t.Helper() + if err := json.Unmarshal([]byte(golden(t, name)), dest); err != nil { + t.Fatalf("parse golden %s: %v", name, err) + } +} + +// firstDiff points at the first differing line of two prompt-sized strings. +func firstDiff(want, got string) string { + wantLines, gotLines := splitLines(want), splitLines(got) + n := len(wantLines) + if len(gotLines) < n { + n = len(gotLines) + } + for i := 0; i < n; i++ { + if wantLines[i] != gotLines[i] { + w, _ := json.Marshal(wantLines[i]) + g, _ := json.Marshal(gotLines[i]) + return "first difference at line " + itoa(i+1) + "\n want: " + string(w) + "\n got: " + string(g) + } + } + if len(wantLines) != len(gotLines) { + return "line counts differ: want " + itoa(len(wantLines)) + ", got " + itoa(len(gotLines)) + } + return "(no line differs; check trailing bytes)" +} + +func splitLines(s string) []string { + out := []string{} + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + out = append(out, s[start:i]) + start = i + 1 + } + } + return append(out, s[start:]) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var buf [24]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} + +// TestReconContextBuildersMatchPython is the headline parity assertion: all +// eight strategy projections plus the generic fallback, byte for byte. +func TestReconContextBuildersMatchPython(t *testing.T) { + recon := loadReconFixture(t) + + cases := []struct { + golden string + builder func(schemas.ReconResult) string + }{ + {"injection.txt", ReconContextForInjection}, + {"auth.txt", ReconContextForAuth}, + {"crypto.txt", ReconContextForCrypto}, + {"data_exposure.txt", ReconContextForDataExposure}, + {"config_secrets.txt", ReconContextForConfigSecrets}, + {"supply_chain.txt", ReconContextForSupplyChain}, + {"api_security.txt", ReconContextForAPISecurity}, + {"logic.txt", ReconContextForLogic}, + {"generic.txt", ReconContextGeneric}, + } + + for _, tc := range cases { + t.Run(tc.golden, func(t *testing.T) { + want := golden(t, tc.golden) + if got := tc.builder(recon); got != want { + t.Errorf("%s mismatch:\n%s", tc.golden, firstDiff(want, got)) + } + }) + } +} + +// TestGetContextForStrategyDispatch pins which builder every HuntStrategy value +// lands on. The golden stores the SHA-256 of the rendered text rather than the +// text itself (13 strategies x ~5 KB would bloat testdata), which is enough to +// catch a mis-wired dispatch: a strategy routed to the wrong builder produces a +// different digest, and the builders themselves are pinned byte-for-byte above. +// +// Python parity: xss, ssrf and dos have STRATEGY_CONTEXT_MAP entries but no +// builder, so they must hash to the same value as the generic summary; and +// business_logic (a.k.a. LOGIC_BUGS) must hash to recon_context_for_logic's. +func TestGetContextForStrategyDispatch(t *testing.T) { + recon := loadReconFixture(t) + + var want map[string]string + goldenJSON(t, "strategy_dispatch.json", &want) + + if len(want) != len(schemas.AllHuntStrategies) { + t.Fatalf("golden covers %d strategies, schemas.AllHuntStrategies has %d", + len(want), len(schemas.AllHuntStrategies)) + } + + for _, strategy := range schemas.AllHuntStrategies { + value := string(strategy) + sum := sha256.Sum256([]byte(GetContextForStrategy(strategy, recon))) + got := hex.EncodeToString(sum[:]) + if want[value] != got { + t.Errorf("GetContextForStrategy(%q) digest = %s, want %s", value, got, want[value]) + } + } + + // The three mapped-but-builderless strategies fall through to the generic + // summary, and business_logic does not. + genericSum := sha256.Sum256([]byte(ReconContextGeneric(recon))) + generic := hex.EncodeToString(genericSum[:]) + for _, value := range []string{"xss", "ssrf", "dos", "python_specific", "javascript_specific"} { + if want[value] != generic { + t.Errorf("strategy %q should render the generic summary", value) + } + } + logicSum := sha256.Sum256([]byte(ReconContextForLogic(recon))) + if want["business_logic"] != hex.EncodeToString(logicSum[:]) { + t.Error("business_logic should render recon_context_for_logic") + } + // The LOGIC_BUGS alias is the same constant, so it must dispatch identically. + if GetContextForStrategy(schemas.HuntStrategyLogicBugs, recon) != ReconContextForLogic(recon) { + t.Error("HuntStrategy.LOGIC_BUGS must alias BUSINESS_LOGIC") + } +} + +// TestPruneReconForStrategyKeys pins the surviving key set for every strategy +// value plus a miss and the empty string, against +// `sorted(prune_recon_for_strategy(recon, s))` in Python. +func TestPruneReconForStrategyKeys(t *testing.T) { + recon := loadReconFixture(t) + + var want map[string][]string + goldenJSON(t, "prune_keys.json", &want) + + for strategy, wantKeys := range want { + pruned, err := PruneReconForStrategy(recon, strategy) + if err != nil { + t.Fatalf("PruneReconForStrategy(%q): %v", strategy, err) + } + gotKeys := make([]string, 0, len(pruned)) + for key := range pruned { + gotKeys = append(gotKeys, key) + } + sort.Strings(gotKeys) + if len(gotKeys) != len(wantKeys) { + t.Errorf("strategy %q: keys = %v, want %v", strategy, gotKeys, wantKeys) + continue + } + for i := range gotKeys { + if gotKeys[i] != wantKeys[i] { + t.Errorf("strategy %q: keys = %v, want %v", strategy, gotKeys, wantKeys) + break + } + } + } +} + +// TestPruneReconForStrategyRendering compares the full pruned document — every +// nested value, not just the key set — with Python's json.dumps of the same +// dict. +// +// The golden is generated with only the TOP LEVEL sorted, because that is +// exactly the document pyfmt.Dumps produces here: the pruned value is a Go map +// (sorted keys — pyfmt's documented deviation) whose values are typed structs +// (declaration order, which is pydantic's model_dump() order). So a byte match +// proves the nested rendering is identical, including int-vs-float spelling +// (cvss_v4_score 10.0 stays "10.0") and every nullable field. +func TestPruneReconForStrategyRendering(t *testing.T) { + recon := loadReconFixture(t) + + for _, strategy := range []string{"injection", "crypto", "supply_chain", "config_secrets", "unknown_strategy"} { + t.Run(strategy, func(t *testing.T) { + pruned, err := PruneReconForStrategy(recon, strategy) + if err != nil { + t.Fatalf("PruneReconForStrategy: %v", err) + } + want := golden(t, "prune_"+strategy+".json") + if got := pyfmt.Dumps(pruned, 2); got != want { + t.Errorf("prune_%s.json mismatch:\n%s", strategy, firstDiff(want, got)) + } + }) + } +} + +// TestStrategyContextMapMatchesPython pins the table itself, so a typo in a +// field name is caught even for a strategy whose pruned golden is not committed. +func TestStrategyContextMapMatchesPython(t *testing.T) { + want := map[string][]string{ + "injection": {"architecture", "data_flows", "security_context"}, + "xss": {"architecture", "data_flows", "security_context"}, + "ssrf": {"architecture", "data_flows", "security_context"}, + "auth": {"architecture", "security_context"}, + "crypto": {"security_context"}, + "dos": {"architecture", "data_flows"}, + "data_exposure": {"architecture", "data_flows", "config"}, + "supply_chain": {"dependencies"}, + "config_secrets": {"config", "architecture"}, + "api_security": {"architecture", "security_context", "data_flows"}, + "business_logic": {"architecture", "data_flows", "security_context"}, + } + if len(StrategyContextMap) != len(want) { + t.Fatalf("StrategyContextMap has %d entries, want %d", len(StrategyContextMap), len(want)) + } + for key, wantFields := range want { + gotFields, ok := StrategyContextMap[key] + if !ok { + t.Errorf("StrategyContextMap missing %q", key) + continue + } + if len(gotFields) != len(wantFields) { + t.Errorf("%q: %v, want %v", key, gotFields, wantFields) + continue + } + for i := range wantFields { + if gotFields[i] != wantFields[i] { + t.Errorf("%q: %v, want %v", key, gotFields, wantFields) + break + } + } + } + + wantBase := []string{"languages", "frameworks", "lines_of_code", "file_count"} + if len(BaseReconFields) != len(wantBase) { + t.Fatalf("BaseReconFields = %v, want %v", BaseReconFields, wantBase) + } + for i := range wantBase { + if BaseReconFields[i] != wantBase[i] { + t.Fatalf("BaseReconFields = %v, want %v", BaseReconFields, wantBase) + } + } +} + +// TestLimitFiltersFalsyBeforeCounting pins _limit's least obvious behavior: the +// truthiness filter runs BEFORE len() is taken, so blanks never appear in the +// "N total" figure. +func TestLimitFiltersFalsyBeforeCounting(t *testing.T) { + rows, total := limit([]string{"a", "", "b", "", "c"}, 2) + if total != 3 { + t.Errorf("total = %d, want 3 (blanks are dropped before counting)", total) + } + if len(rows) != 2 || rows[0] != "a" || rows[1] != "b" { + t.Errorf("rows = %v, want [a b]", rows) + } + + if got, want := renderList("Title", nil, 5), "Title: none identified in recon."; got != want { + t.Errorf("empty renderList = %q, want %q", got, want) + } + if got, want := renderList("Title", []string{"", ""}, 5), "Title: none identified in recon."; got != want { + t.Errorf("all-blank renderList = %q, want %q", got, want) + } + if got, want := renderList("T", []string{"a", "b"}, 1), "T: 2 total, showing top 1:\n- a"; got != want { + t.Errorf("truncated renderList = %q, want %q", got, want) + } +} + +// TestEndpointRankKeyIsIdentityTest pins the `is False` semantics: an UNKNOWN +// (None) auth_required ranks with the protected endpoints, not the unprotected +// ones. A truthiness test would sort them together and reorder the prompt. +func TestEndpointRankKeyIsIdentityTest(t *testing.T) { + yes, no := true, false + cases := []struct { + auth, rate *bool + wantA int + wantB int + }{ + {&no, &no, 0, 0}, + {&no, &yes, 0, 1}, + {&no, nil, 0, 1}, + {nil, &no, 1, 0}, + {&yes, &yes, 1, 1}, + {nil, nil, 1, 1}, + } + for _, tc := range cases { + a, b := endpointRankKey(tc.auth, tc.rate) + if a != tc.wantA || b != tc.wantB { + t.Errorf("endpointRankKey(%v, %v) = (%d,%d), want (%d,%d)", + tc.auth, tc.rate, a, b, tc.wantA, tc.wantB) + } + } +} + +// TestRankedEndpointsIsStable pins that ties keep recon order — Python's +// sorted() is stable and the prompt's endpoint order is observable output. +func TestRankedEndpointsIsStable(t *testing.T) { + no := false + endpoints := []schemas.APIEndpoint{ + {Path: "/a"}, // rank (1,1) + {Path: "/b", AuthRequired: &no}, // rank (0,1) + {Path: "/c"}, // rank (1,1) + {Path: "/d", AuthRequired: &no, RateLimited: &no}, // rank (0,0) + {Path: "/e", AuthRequired: &no}, // rank (0,1) + } + want := []string{"/d", "/b", "/e", "/a", "/c"} + ranked := rankedEndpoints(endpoints) + for i, endpoint := range ranked { + if endpoint.Path != want[i] { + t.Fatalf("ranked order = %v, want %v", pathsOf(ranked), want) + } + } + // sorted() returns a NEW list; the caller's slice must be untouched. + if endpoints[0].Path != "/a" { + t.Error("rankedEndpoints mutated its input") + } +} + +func pathsOf(endpoints []schemas.APIEndpoint) []string { + out := make([]string, len(endpoints)) + for i, endpoint := range endpoints { + out[i] = endpoint.Path + } + return out +} + +// TestCVEPriorityDefaults pins the asymmetry between the reachable rank +// (truthiness — None ranks with False) and the score defaults (an explicit +// `is not None` check, so a missing CVSS sorts BELOW a 0.0 CVSS). +func TestCVEPriorityDefaults(t *testing.T) { + zero := 0.0 + yes := true + + _, missingCvss, missingEpss, _ := cvePriority(schemas.KnownCVE{}) + if missingCvss != 1.0 || missingEpss != 1.0 { + t.Errorf("missing scores => (%v,%v), want (1,1) — i.e. -(-1.0)", missingCvss, missingEpss) + } + _, zeroCvss, _, _ := cvePriority(schemas.KnownCVE{CvssV4Score: &zero}) + if zeroCvss != 0.0 { + t.Errorf("cvss 0.0 => %v, want 0", zeroCvss) + } + if missingCvss <= zeroCvss { + t.Error("a CVE with no CVSS must sort after one scored 0.0") + } + + rankNone, _, _, directNone := cvePriority(schemas.KnownCVE{}) + if rankNone != 1 || directNone != 1 { + t.Errorf("reachable=None/direct=false => (%d,%d), want (1,1)", rankNone, directNone) + } + rankTrue, _, _, directTrue := cvePriority(schemas.KnownCVE{Reachable: &yes, Direct: true}) + if rankTrue != 0 || directTrue != 0 { + t.Errorf("reachable=True/direct=true => (%d,%d), want (0,0)", rankTrue, directTrue) + } +} + +// TestWeakCryptoFirstIsTruthiness pins that an UNKNOWN is_weak sorts with the +// strong algorithms, and that the sort is stable. +func TestWeakCryptoFirstIsTruthiness(t *testing.T) { + yes, no := true, false + usages := []schemas.CryptoUsage{ + {Algorithm: "AES", IsWeak: &no}, + {Algorithm: "MD5", IsWeak: &yes}, + {Algorithm: "ECDSA", IsWeak: nil}, + {Algorithm: "RC4", IsWeak: &yes}, + } + want := []string{"MD5", "RC4", "AES", "ECDSA"} + for i, usage := range weakCryptoFirst(usages) { + if usage.Algorithm != want[i] { + t.Fatalf("weakCryptoFirst order = %v, want %v", algosOf(weakCryptoFirst(usages)), want) + } + } + if usages[0].Algorithm != "AES" { + t.Error("weakCryptoFirst mutated its input") + } +} + +func algosOf(usages []schemas.CryptoUsage) []string { + out := make([]string, len(usages)) + for i, usage := range usages { + out[i] = usage.Algorithm + } + return out +} + +// TestInjectionPrefersUnsanitizedFlows pins the `unsanitized or all` fallback: +// with at least one unsanitized flow only those are listed; with none, every +// flow is. +func TestInjectionPrefersUnsanitizedFlows(t *testing.T) { + recon := schemas.NewReconResult() + recon.DataFlows.Flows = []schemas.DataFlow{ + {Source: "s1", Sink: "k1", Sanitized: true, Files: []string{}}, + {Source: "s2", Sink: "k2", Sanitized: false, Files: []string{}}, + } + out := ReconContextForInjection(recon) + if !contains(out, "s2 -> k2") || contains(out, "s1 -> k1") { + t.Errorf("with an unsanitized flow present, only it should be listed:\n%s", out) + } + + recon.DataFlows.Flows = []schemas.DataFlow{ + {Source: "s1", Sink: "k1", Sanitized: true, Files: []string{}}, + } + out = ReconContextForInjection(recon) + if !contains(out, "s1 -> k1") { + t.Errorf("with no unsanitized flow, every flow should be listed:\n%s", out) + } +} + +func contains(haystack, needle string) bool { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false +} + +// TestOptionalRenderings pins the f-string spellings of nullable scalars, the +// place a naive port silently swaps "None" for "" or quotes a string. +func TestOptionalRenderings(t *testing.T) { + yes := true + num := 1.0 + n := 7 + s := "text" + + cases := []struct { + name string + got string + want string + }{ + {"nil bool", str((*bool)(nil)), "None"}, + {"true", str(&yes), "True"}, + {"plain bool", str(false), "False"}, + {"nil float", str((*float64)(nil)), "None"}, + {"integral float", str(&num), "1.0"}, + {"nil int", str((*int)(nil)), "None"}, + {"int", str(&n), "7"}, + {"nil str stays bare", strPtr(nil), "None"}, + {"str stays bare (not quoted)", strPtr(&s), "text"}, + } + for _, tc := range cases { + if tc.got != tc.want { + t.Errorf("%s = %q, want %q", tc.name, tc.got, tc.want) + } + } + + empty := "" + if got := orStr(&empty, "n/a"); got != "n/a" { + t.Errorf("orStr(\"\") = %q, want the fallback (empty strings are falsy)", got) + } + zero := 0 + if got := orZeroInt(&zero); got != 0 { + t.Errorf("orZeroInt(0) = %d, want 0", got) + } + if got := orZeroInt(nil); got != 0 { + t.Errorf("orZeroInt(nil) = %d, want 0", got) + } + if got := joinOr(nil, "unknown"); got != "unknown" { + t.Errorf("joinOr(nil) = %q, want unknown", got) + } + if got := joinOr([]string{""}, "unknown"); got != "unknown" { + t.Errorf("joinOr([\"\"]) = %q, want unknown (the join is empty, hence falsy)", got) + } +} + +// TestPruneReconForStrategyDoesNotAliasBaseFields guards the include-set build: +// a mapped strategy must never drop a base field, and must never leak +// recon_duration_seconds. +func TestPruneReconForStrategyDoesNotAliasBaseFields(t *testing.T) { + recon := loadReconFixture(t) + for strategy := range StrategyContextMap { + pruned, err := PruneReconForStrategy(recon, strategy) + if err != nil { + t.Fatalf("PruneReconForStrategy(%q): %v", strategy, err) + } + for _, base := range BaseReconFields { + if _, ok := pruned[base]; !ok { + t.Errorf("strategy %q dropped base field %q", strategy, base) + } + } + if _, ok := pruned["recon_duration_seconds"]; ok { + t.Errorf("strategy %q leaked recon_duration_seconds", strategy) + } + } +} diff --git a/go/internal/recontext/hints.go b/go/internal/recontext/hints.go new file mode 100644 index 0000000..e45d567 --- /dev/null +++ b/go/internal/recontext/hints.go @@ -0,0 +1,510 @@ +package recontext + +import "strings" + +// This file ports two Python modules verbatim: +// +// src/sec_af/agents/hunt/_language_hints.py (LANGUAGE_PATTERNS, get_language_hints) +// src/sec_af/agents/hunt/_framework_hints.py (FRAMEWORK_PATTERNS, _FRAMEWORK_ALIASES, +// _normalize_framework, get_framework_hints) +// +// WHY THEY LIVE HERE AND NOT IN internal/agents/hunt +// ------------------------------------------------- +// In Python the dependency runs hunt -> context: `sec_af/context.py` imports +// `get_framework_hints`/`get_language_hints` out of the `sec_af.agents.hunt` +// package, and each hunter module imports `language_hints_for_context` back out +// of `sec_af.context`. Python tolerates that because `agents/hunt/__init__.py` +// is not executed by importing the two leaf hint modules. +// +// Go has no such escape hatch: `internal/agents/hunt` will import +// `internal/recontext` (every hunter needs recon_context_for_*), so if the hint +// tables lived in the hunt package the two packages would import each other and +// the build would fail with an import cycle. The tables are therefore hosted +// here — the package that both sides already depend on — and exported as +// GetLanguageHints / GetFrameworkHints so `internal/agents/hunt` can call them +// directly wherever Python calls them directly. +// +// The table CONTENT is byte-exact with the Python source; every string below is +// a transcription, and the golden tests in hints_test.go compare the rendered +// output against the Python functions run over the same inputs. + +// LanguagePattern is one entry of Python's LANGUAGE_PATTERNS. +// +// Python types it `dict[str, list[str] | str]` and reads it with `.get(key)`, +// so a missing key and an empty list behave identically (both falsy, both skip +// the line). A Go struct with zero values reproduces that exactly. +type LanguagePattern struct { + InjectionSinks []string + SafePatterns []string + DoNotFlag []string + FrameworkSpecifics string +} + +// LanguagePatterns ports _language_hints.py LANGUAGE_PATTERNS. +// +// Keyed by the LOWERCASE language name, because get_language_hints lowercases +// every detected language before the lookup. +var LanguagePatterns = map[string]LanguagePattern{ + "python": { + InjectionSinks: []string{"cursor.execute", "os.system", "subprocess.run", "eval", "exec", "pickle.loads"}, + SafePatterns: []string{ + "parameterized queries with %s placeholders", + "subprocess.run with list args (no shell=True)", + }, + DoNotFlag: []string{ + "Django ORM queries (uses parameterized queries internally)", + "SQLAlchemy text() with bound params", + }, + FrameworkSpecifics: "Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes.", + }, + "javascript": { + InjectionSinks: []string{"eval", "Function()", "innerHTML", "document.write", "child_process.exec", "new Function"}, + SafePatterns: []string{"DOMPurify.sanitize()", "textContent assignment", "parameterized pg queries"}, + DoNotFlag: []string{"React JSX expressions (auto-escaped)", "Angular template bindings (sanitized by default)"}, + FrameworkSpecifics: "Check for React/Vue/Angular-specific XSS patterns. React auto-escapes JSX. Vue v-html is dangerous.", + }, + "typescript": { + InjectionSinks: []string{"eval", "Function()", "innerHTML", "document.write", "child_process.exec"}, + SafePatterns: []string{"DOMPurify.sanitize()", "textContent assignment", "Prisma parameterized queries"}, + DoNotFlag: []string{"React JSX expressions", "Angular template bindings", "Prisma ORM queries"}, + FrameworkSpecifics: "TypeScript adds type safety but doesn't prevent injection. Check for any type assertions near user input.", + }, + "go": { + InjectionSinks: []string{"fmt.Sprintf into SQL", "exec.Command with user input", "template.HTML()", "os.Exec"}, + SafePatterns: []string{ + "database/sql with ? placeholders", + "html/template (auto-escapes)", + "exec.Command with separate args", + }, + DoNotFlag: []string{ + "GORM parameterized queries", + "html/template default escaping", + "database/sql prepared statements", + }, + FrameworkSpecifics: "Go's html/template auto-escapes. text/template does NOT. Check for text/template serving HTML.", + }, + "java": { + InjectionSinks: []string{ + "Statement.execute", + "Runtime.exec", + "ProcessBuilder with concatenated strings", + "ScriptEngine.eval", + }, + SafePatterns: []string{"PreparedStatement with ?", "JNDI lookup with allowlist", "OWASP ESAPI encoding"}, + DoNotFlag: []string{"JPA/Hibernate named parameters", "Spring Security CSRF protection", "PreparedStatement usage"}, + FrameworkSpecifics: "Check Spring Boot auto-config. Thymeleaf auto-escapes. JSP needs explicit escaping.", + }, + "ruby": { + InjectionSinks: []string{"eval", "system", "exec", "send", "public_send", "ERB.new with user input"}, + SafePatterns: []string{"ActiveRecord parameterized queries", "Rack::Utils.escape_html", "sanitize helper in Rails"}, + DoNotFlag: []string{ + "ActiveRecord where with hash conditions", + "Rails CSRF protection", + "Rails html_safe on constants", + }, + FrameworkSpecifics: "Rails auto-escapes ERB templates. raw/html_safe bypasses escaping. Check for mass assignment.", + }, + "csharp": { + InjectionSinks: []string{"SqlCommand with concatenation", "Process.Start with user input", "Razor @Html.Raw()"}, + SafePatterns: []string{"SqlParameter", "Entity Framework LINQ", "Razor auto-encoding"}, + DoNotFlag: []string{"Entity Framework LINQ queries", "ASP.NET anti-forgery tokens", "Razor default encoding"}, + FrameworkSpecifics: "ASP.NET Core Razor auto-encodes. @Html.Raw() is dangerous. Check for [ValidateAntiForgeryToken].", + }, +} + +// GetLanguageHints ports _language_hints.py get_language_hints: +// +// detected = [lang.lower() for lang in languages] +// hints = [] +// for lang in detected: +// patterns = LANGUAGE_PATTERNS.get(lang) +// if patterns is None: continue +// section = [f"Language: {lang.upper()}"] +// ... four optional lines ... +// hints.append("\n".join(section)) +// if not hints: return "No language-specific hints available for detected languages." +// return "LANGUAGE-SPECIFIC GUIDANCE:\n" + "\n\n".join(hints) +// +// Python parity notes: +// +// - There is NO deduplication, unlike get_framework_hints. `["Python", +// "python"]` emits the PYTHON section twice, and this reproduces that. +// - The heading uses `lang.upper()` on the ALREADY-LOWERCASED name, so the +// spelling always comes from the table key, never from the caller's casing. +// - Go's strings.ToLower/ToUpper are Unicode-aware like Python's +// str.lower()/str.upper(), with the usual rare divergences on characters +// whose case mapping changes length (Python maps U+0130 to two code points, +// Go to one). No language name in the table is affected. +func GetLanguageHints(languages []string) string { + hints := make([]string, 0, len(languages)) + for _, raw := range languages { + lang := strings.ToLower(raw) + patterns, ok := LanguagePatterns[lang] + if !ok { + continue + } + section := []string{"Language: " + strings.ToUpper(lang)} + if len(patterns.InjectionSinks) > 0 { + section = append(section, " Key sinks: "+strings.Join(patterns.InjectionSinks, ", ")) + } + if len(patterns.SafePatterns) > 0 { + section = append(section, " Safe patterns (skip these): "+strings.Join(patterns.SafePatterns, ", ")) + } + if len(patterns.DoNotFlag) > 0 { + section = append(section, " DO NOT FLAG: "+strings.Join(patterns.DoNotFlag, ", ")) + } + if patterns.FrameworkSpecifics != "" { + section = append(section, " Framework notes: "+patterns.FrameworkSpecifics) + } + hints = append(hints, strings.Join(section, "\n")) + } + if len(hints) == 0 { + return "No language-specific hints available for detected languages." + } + return "LANGUAGE-SPECIFIC GUIDANCE:\n" + strings.Join(hints, "\n\n") +} + +// FrameworkPattern is one entry of Python's FRAMEWORK_PATTERNS +// (`dict[str, dict[str, list[str]]]` — every entry has all four keys, and +// get_framework_hints indexes them directly rather than using .get()). +type FrameworkPattern struct { + SecurityFeatures []string + DoNotFlag []string + WatchFor []string + CommonVulns []string +} + +// FrameworkPatterns ports _framework_hints.py FRAMEWORK_PATTERNS, keyed by the +// NORMALIZED framework name (see normalizeFramework). +var FrameworkPatterns = map[string]FrameworkPattern{ + "django": { + SecurityFeatures: []string{ + "ORM queries are parameterized by default", + "Templates auto-escape variables by default", + "CsrfViewMiddleware enforces CSRF tokens for unsafe methods", + }, + DoNotFlag: []string{ + "Django ORM filter/exclude (parameterized)", + "CSRF with CsrfViewMiddleware active", + "XSS in Django templates (auto-escaped)", + }, + WatchFor: []string{"raw() queries", "mark_safe()", "|safe filter", "CSRF_COOKIE_SECURE=False"}, + CommonVulns: []string{ + "SQL injection via raw SQL and string formatting", + "XSS via unsafe template escape bypasses", + "CSRF weakening via middleware/settings overrides", + }, + }, + "flask": { + SecurityFeatures: []string{ + "Jinja2 auto-escapes in HTML templates", + "Werkzeug handles request parsing safely by default", + "Blueprint/middleware patterns can centralize auth checks", + }, + DoNotFlag: []string{ + "Jinja2 auto-escaped template variables", + "Parameterized SQLAlchemy query usage", + "Server-side session signing with strong SECRET_KEY", + }, + WatchFor: []string{ + "render_template_string() with user input", + "debug=True in production paths", + "string-concatenated SQL in execute()", + "hardcoded SECRET_KEY", + }, + CommonVulns: []string{ + "SSTI through dynamic template rendering", + "XSS when auto-escaping is bypassed", + "session tampering risk with weak secret/config", + }, + }, + "fastapi": { + SecurityFeatures: []string{ + "Pydantic request validation reduces malformed input", + "Dependency injection supports reusable auth guards", + "OpenAPI schema generation improves contract visibility", + }, + DoNotFlag: []string{ + "Pydantic model validation errors", + "Dependency-based auth checks clearly enforced", + "Parameterized SQLAlchemy/async driver usage", + }, + WatchFor: []string{ + "Depends() omitted on privileged routes", + "raw SQL in text()/execute() with interpolation", + "CORS allow_origins=['*'] with credentials", + "unsafe deserialization in background tasks", + }, + CommonVulns: []string{ + "Auth bypass on unprotected routes", + "SQL injection in manually assembled queries", + "CORS misconfiguration exposing credentialed APIs", + }, + }, + "express": { + SecurityFeatures: []string{ + "Router middleware can enforce auth and rate limits", + "helmet can apply secure HTTP headers", + "Validated schema middleware can constrain input", + }, + DoNotFlag: []string{ + "Parameterized ORM/database queries", + "helmet defaults correctly applied", + "router-level auth middleware consistently enforced", + }, + WatchFor: []string{ + "res.send()/res.json() leaking sensitive internals", + "string-built SQL in query()", + "trust proxy misconfiguration", + "open CORS with credentials", + }, + CommonVulns: []string{ + "Authz gaps from missing middleware on routes", + "NoSQL/SQL injection from unsanitized request bodies", + "Open redirect and SSRF through unvalidated URLs", + }, + }, + "nextjs": { + SecurityFeatures: []string{ + "React JSX auto-escapes output by default", + "API routes can share centralized auth middleware", + "Server/client boundaries reduce accidental secret exposure", + }, + DoNotFlag: []string{ + "React JSX escaped rendering", + "getServerSideProps/getServerSession with proper auth checks", + "Typed route handlers with validated schema guards", + }, + WatchFor: []string{ + "dangerouslySetInnerHTML", + "unprotected API routes under /api", + "secret leakage to client bundles", + "rewrites/redirects from untrusted user input", + }, + CommonVulns: []string{ + "XSS through dangerous HTML rendering", + "IDOR/auth bypass in API handlers", + "Sensitive env/config exposure in client-side code", + }, + }, + "spring": { + SecurityFeatures: []string{ + "Spring Security provides auth, CSRF, and filter chain defaults", + "JPA/Hibernate prepared parameter binding by default", + "Bean validation can enforce request constraints", + }, + DoNotFlag: []string{ + "PreparedStatement/JPA named parameter usage", + "Spring Security CSRF/auth filters clearly active", + "Thymeleaf auto-escaped output", + }, + WatchFor: []string{ + "@PreAuthorize missing on sensitive methods", + "JdbcTemplate/raw Statement with concatenation", + "csrf().disable() on browser session flows", + "Actuator endpoints exposed without auth", + }, + CommonVulns: []string{ + "Authz bypass from weak method-level security", + "SQL injection in raw JDBC queries", + "Sensitive management endpoint exposure", + }, + }, + "rails": { + SecurityFeatures: []string{ + "ActiveRecord parameterization for hash/array queries", + "ERB templates auto-escape by default", + "Built-in CSRF protection for non-GET requests", + }, + DoNotFlag: []string{ + "ActiveRecord where with hash conditions", + "Rails protect_from_forgery active", + "ERB escaped output without raw/html_safe", + }, + WatchFor: []string{ + "where/order/find_by_sql with string interpolation", + "raw()/html_safe on untrusted content", + "skip_before_action on auth filters", + "mass assignment via permit!", + }, + CommonVulns: []string{ + "SQL injection in manual query fragments", + "XSS via unsafe output helpers", + "Authz bypass from skipped controller guards", + }, + }, + "aspnet": { + SecurityFeatures: []string{ + "Razor encodes output by default", + "Model binding and data annotations aid validation", + "Anti-forgery tokens support CSRF defense", + }, + DoNotFlag: []string{ + "Entity Framework LINQ parameterized queries", + "Razor default HTML encoding", + "ValidateAntiForgeryToken in state-changing MVC actions", + }, + WatchFor: []string{ + "Html.Raw() on untrusted input", + "FromBody models without validation", + "Authorize missing on privileged endpoints", + "custom SQL built via string interpolation", + }, + CommonVulns: []string{ + "XSS through Html.Raw and unencoded output", + "Auth/authz bypass on unsecured controllers", + "SQL injection in handcrafted query strings", + }, + }, + "react": { + SecurityFeatures: []string{ + "JSX escapes strings before DOM rendering", + "Component model encourages explicit data flow", + "Framework discourages direct DOM mutation", + }, + DoNotFlag: []string{ + "Standard JSX expression rendering", + "textContent assignment for untrusted text", + "sanitized HTML via trusted DOMPurify policy", + }, + WatchFor: []string{ + "dangerouslySetInnerHTML", + "untrusted URL assignment to href/src", + "eval/new Function in client code", + "token/secret exposure in bundles", + }, + CommonVulns: []string{ + "DOM XSS through unsafe HTML injection", + "Open redirect via unvalidated navigation targets", + "Sensitive data exposure in frontend artifacts", + }, + }, + "vue": { + SecurityFeatures: []string{ + "Mustache template interpolation escapes HTML", + "Component props/events provide explicit boundaries", + "Router guards can enforce auth flows", + }, + DoNotFlag: []string{ + "escaped template interpolation {{ value }}", + "validated route guards protecting private routes", + "sanitized content rendered through safe components", + }, + WatchFor: []string{ + "v-html with untrusted data", + "dynamic component/template compilation", + "unsafe URL bindings in href/src", + "client-side auth checks without server enforcement", + }, + CommonVulns: []string{ + "XSS through v-html and unsafe render paths", + "Auth bypass from client-only route protection", + "Open redirect patterns in router navigation", + }, + }, + "angular": { + SecurityFeatures: []string{ + "Template binding sanitization for HTML/URL contexts", + "HttpClient and interceptor patterns support central controls", + "AOT compilation limits runtime template injection vectors", + }, + DoNotFlag: []string{ + "default Angular template binding sanitization", + "HttpClient usage with validated request schemas", + "route guards consistently applied", + }, + WatchFor: []string{ + "bypassSecurityTrustHtml/Url/Script", + "[innerHTML] with unsanitized input", + "direct DOM APIs via ElementRef/nativeElement", + "auth only in client guard without server checks", + }, + CommonVulns: []string{ + "XSS when sanitizer is explicitly bypassed", + "Token leakage in local storage/logging", + "Authorization gaps due to client-only enforcement", + }, + }, +} + +// frameworkAliases ports _framework_hints.py _FRAMEWORK_ALIASES. +var frameworkAliases = map[string]string{ + "next": "nextjs", + "next.js": "nextjs", + "springboot": "spring", + "spring-boot": "spring", + "spring boot": "spring", + "asp.net": "aspnet", + "asp.net core": "aspnet", + "aspnetcore": "aspnet", + "asp net": "aspnet", + "ruby on rails": "rails", +} + +// normalizeFramework ports _normalize_framework: +// +// lowered = value.strip().lower() +// return _FRAMEWORK_ALIASES.get(lowered, lowered) +// +// Python parity: `str.strip()` with no argument strips Unicode whitespace; +// strings.TrimSpace strips the same class (unicode.IsSpace). The two differ +// only on a handful of exotic code points that no framework name contains. +func normalizeFramework(value string) string { + lowered := strings.ToLower(strings.TrimSpace(value)) + if alias, ok := frameworkAliases[lowered]; ok { + return alias + } + return lowered +} + +// GetFrameworkHints ports _framework_hints.py get_framework_hints. +// +// Python parity: the normalized names are deduplicated while PRESERVING FIRST +// APPEARANCE ORDER (Python builds `ordered_unique` with a linear `in` check on +// a list, not a set), so `["Next", "next.js"]` yields one NEXTJS section, and +// unknown frameworks silently drop out of the output but still consume their +// slot in the dedup list. +func GetFrameworkHints(frameworks []string) string { + orderedUnique := make([]string, 0, len(frameworks)) + for _, framework := range frameworks { + normalized := normalizeFramework(framework) + seen := false + for _, existing := range orderedUnique { + if existing == normalized { + seen = true + break + } + } + if seen { + continue + } + orderedUnique = append(orderedUnique, normalized) + } + + sections := make([]string, 0, len(orderedUnique)) + for _, framework := range orderedUnique { + patterns, ok := FrameworkPatterns[framework] + if !ok { + continue + } + section := []string{"Framework: " + strings.ToUpper(framework)} + if len(patterns.SecurityFeatures) > 0 { + section = append(section, " Security features: "+strings.Join(patterns.SecurityFeatures, ", ")) + } + if len(patterns.DoNotFlag) > 0 { + section = append(section, " DO NOT FLAG: "+strings.Join(patterns.DoNotFlag, ", ")) + } + if len(patterns.WatchFor) > 0 { + section = append(section, " Watch for: "+strings.Join(patterns.WatchFor, ", ")) + } + if len(patterns.CommonVulns) > 0 { + section = append(section, " Common vulns: "+strings.Join(patterns.CommonVulns, ", ")) + } + sections = append(sections, strings.Join(section, "\n")) + } + + if len(sections) == 0 { + return "No framework-specific hints available for detected frameworks." + } + return "FRAMEWORK-SPECIFIC GUIDANCE:\n" + strings.Join(sections, "\n\n") +} diff --git a/go/internal/recontext/hints_test.go b/go/internal/recontext/hints_test.go new file mode 100644 index 0000000..2af6dec --- /dev/null +++ b/go/internal/recontext/hints_test.go @@ -0,0 +1,153 @@ +package recontext + +// Parity tests for the two hint tables ported from +// src/sec_af/agents/hunt/_language_hints.py and _framework_hints.py. +// +// The input vectors below are the SAME literals go/scripts/gen_golden.py uses +// (LANGUAGE_HINT_CASES / FRAMEWORK_HINT_CASES); if the two drift apart the +// golden comparison fails, which is the intended alarm. + +import "testing" + +// languageHintCases mirrors gen_golden.py's LANGUAGE_HINT_CASES. +var languageHintCases = []struct { + name string + languages []string +}{ + {"empty", []string{}}, + {"unknown_only", []string{"Rust", "haskell"}}, + {"single", []string{"Python"}}, + {"mixed_case_and_repeat", []string{"Python", "python", "JavaScript", "Rust", "GO"}}, + {"all_known", []string{"python", "javascript", "typescript", "go", "java", "ruby", "csharp"}}, +} + +// frameworkHintCases mirrors gen_golden.py's FRAMEWORK_HINT_CASES. +var frameworkHintCases = []struct { + name string + frameworks []string +}{ + {"empty", []string{}}, + {"unknown_only", []string{"hanami", "phoenix"}}, + {"aliases", []string{"Next", "next.js", "NEXTJS", "Spring Boot", "spring-boot", "ASP.NET Core"}}, + {"padded", []string{" React ", "\tvue\n", "Django"}}, + {"all_known", []string{ + "django", "flask", "fastapi", "express", "nextjs", + "spring", "rails", "aspnet", "react", "vue", "angular", + }}, +} + +// TestGetLanguageHintsMatchesPython pins the whole LANGUAGE_PATTERNS table +// through its rendering — every sink, safe pattern, do-not-flag entry and +// framework note of all seven languages appears in the all_known golden. +// +// The mixed_case_and_repeat case is the interesting one: get_language_hints +// does NOT deduplicate, so "Python" and "python" each emit a PYTHON section, +// and "Rust" (no table entry) contributes nothing. +func TestGetLanguageHintsMatchesPython(t *testing.T) { + for _, tc := range languageHintCases { + t.Run(tc.name, func(t *testing.T) { + want := golden(t, "language_hints_"+tc.name+".txt") + if got := GetLanguageHints(tc.languages); got != want { + t.Errorf("GetLanguageHints(%v) mismatch:\n%s", tc.languages, firstDiff(want, got)) + } + }) + } +} + +// TestGetFrameworkHintsMatchesPython pins FRAMEWORK_PATTERNS and the alias +// table. The aliases case proves Next/next.js/NEXTJS collapse to ONE NEXTJS +// section and Spring Boot/spring-boot to one SPRING; the padded case proves +// `.strip().lower()` runs before the alias lookup. +func TestGetFrameworkHintsMatchesPython(t *testing.T) { + for _, tc := range frameworkHintCases { + t.Run(tc.name, func(t *testing.T) { + want := golden(t, "framework_hints_"+tc.name+".txt") + if got := GetFrameworkHints(tc.frameworks); got != want { + t.Errorf("GetFrameworkHints(%v) mismatch:\n%s", tc.frameworks, firstDiff(want, got)) + } + }) + } +} + +// TestHintsForContextWrappers pins language_hints_for_context / +// framework_hints_for_context, which are the functions every hunter actually +// calls: they feed the ReconResult's own language and framework lists — which +// in the fixture are deliberately messy ("Python" twice, " React " padded, +// "next.js" and "NEXT" aliasing to the same entry, "unknown-fw" unmatched). +func TestHintsForContextWrappers(t *testing.T) { + recon := loadReconFixture(t) + + if got, want := LanguageHintsForContext(recon), golden(t, "language_hints_for_context.txt"); got != want { + t.Errorf("LanguageHintsForContext mismatch:\n%s", firstDiff(want, got)) + } + if got, want := FrameworkHintsForContext(recon), golden(t, "framework_hints_for_context.txt"); got != want { + t.Errorf("FrameworkHintsForContext mismatch:\n%s", firstDiff(want, got)) + } +} + +// TestHintTableKeys guards the table membership itself, so a dropped or +// misspelled key is caught even when no golden covers it. +func TestHintTableKeys(t *testing.T) { + wantLanguages := []string{"python", "javascript", "typescript", "go", "java", "ruby", "csharp"} + if len(LanguagePatterns) != len(wantLanguages) { + t.Errorf("LanguagePatterns has %d entries, want %d", len(LanguagePatterns), len(wantLanguages)) + } + for _, key := range wantLanguages { + if _, ok := LanguagePatterns[key]; !ok { + t.Errorf("LanguagePatterns missing %q", key) + } + } + + wantFrameworks := []string{ + "django", "flask", "fastapi", "express", "nextjs", + "spring", "rails", "aspnet", "react", "vue", "angular", + } + if len(FrameworkPatterns) != len(wantFrameworks) { + t.Errorf("FrameworkPatterns has %d entries, want %d", len(FrameworkPatterns), len(wantFrameworks)) + } + for _, key := range wantFrameworks { + if _, ok := FrameworkPatterns[key]; !ok { + t.Errorf("FrameworkPatterns missing %q", key) + } + } +} + +// TestNormalizeFramework pins _normalize_framework's alias table one entry at a +// time, including the identity case for a name that is not an alias. +func TestNormalizeFramework(t *testing.T) { + cases := map[string]string{ + "next": "nextjs", + "Next": "nextjs", + "next.js": "nextjs", + " NEXT.JS ": "nextjs", + "springboot": "spring", + "spring-boot": "spring", + "spring boot": "spring", + "asp.net": "aspnet", + "asp.net core": "aspnet", + "aspnetcore": "aspnet", + "asp net": "aspnet", + "ruby on rails": "rails", + "django": "django", + "Django": "django", + "unknown-fw": "unknown-fw", + "": "", + } + for in, want := range cases { + if got := normalizeFramework(in); got != want { + t.Errorf("normalizeFramework(%q) = %q, want %q", in, got, want) + } + } +} + +// TestNoHintsFallbacks pins the two "nothing matched" sentences, which are the +// literal strings the prompt templates end up carrying for an unrecognized +// stack. +func TestNoHintsFallbacks(t *testing.T) { + if got, want := GetLanguageHints(nil), "No language-specific hints available for detected languages."; got != want { + t.Errorf("GetLanguageHints(nil) = %q, want %q", got, want) + } + if got, want := GetFrameworkHints(nil), "No framework-specific hints available for detected frameworks."; got != want { + t.Errorf("GetFrameworkHints(nil) = %q, want %q", got, want) + } +} diff --git a/go/internal/recontext/testdata/golden/api_security.txt b/go/internal/recontext/testdata/golden/api_security.txt new file mode 100644 index 0000000..1481253 --- /dev/null +++ b/go/internal/recontext/testdata/golden/api_security.txt @@ -0,0 +1,54 @@ +API security-focused recon summary. + +API endpoints prioritized by missing auth/rate-limits: 20 total, showing top 15: +- POST /api/v1/thing/1 -> ThingController.action1 (app/api/thing_1.py:103, auth_required=False, rate_limited=False) +- DELETE /api/v1/thing/3 -> ThingController.action3 (app/api/thing_3.py:109, auth_required=False, rate_limited=False) +- PUT /api/v1/thing/7 -> ThingController.action7 (app/api/thing_7.py:121, auth_required=False, rate_limited=False) +- PATCH /api/v1/thing/9 -> ThingController.action9 (app/api/thing_9.py:127, auth_required=False, rate_limited=False) +- DELETE /api/v1/thing/13 -> ThingController.action13 (app/api/thing_13.py:139, auth_required=False, rate_limited=False) +- GET /api/v1/thing/15 -> ThingController.action15 (app/api/thing_15.py:145, auth_required=False, rate_limited=False) +- PATCH /api/v1/thing/19 -> ThingController.action19 (app/api/thing_19.py:157, auth_required=False, rate_limited=False) +- GET /api/v1/thing/0 -> ThingController.action0 (app/api/thing_0.py:100, auth_required=True, rate_limited=None) +- PUT /api/v1/thing/2 -> ThingController.action2 (app/api/thing_2.py:106, auth_required=None, rate_limited=True) +- PATCH /api/v1/thing/4 -> ThingController.action4 (app/api/thing_4.py:112, auth_required=True, rate_limited=None) +- GET /api/v1/thing/5 -> ThingController.action5 (app/api/thing_5.py:115, auth_required=None, rate_limited=True) +- POST /api/v1/thing/6 -> ThingController.action6 (app/api/thing_6.py:118, auth_required=True, rate_limited=None) +- DELETE /api/v1/thing/8 -> ThingController.action8 (app/api/thing_8.py:124, auth_required=None, rate_limited=True) +- GET /api/v1/thing/10 -> ThingController.action10 (app/api/thing_10.py:130, auth_required=True, rate_limited=None) +- POST /api/v1/thing/11 -> ThingController.action11 (app/api/thing_11.py:133, auth_required=None, rate_limited=True) + +HTTP/API entry points: 11 total, showing top 10: +- http handler_0 (app/entry/e0.py:10, auth_required=True) +- HTTP /v1/resource/1 (app/entry/e1.py:17, auth_required=False) +- api handler_3 (app/entry/e3.py:31, auth_required=True) +- graphql /v1/resource/4 (app/entry/e4.py:38, auth_required=False) +- rpc /v1/resource/5 (app/entry/e5.py:45, auth_required=None) +- route handler_6 (app/entry/e6.py:52, auth_required=True) +- http handler_9 (app/entry/e9.py:73, auth_required=True) +- api /v1/resource/10 (app/entry/e10.py:80, auth_required=False) +- http handler_12 (app/entry/e12.py:94, auth_required=True) +- route /v1/resource/13 (app/entry/e13.py:101, auth_required=False) + +Trust boundaries relevant to API calls: 12 total, showing top 10: +- boundary_0: internet -> app; enforcement=none +- boundary_1: dmz -> db; enforcement=waf_1, mtls_1 +- boundary_2: vpc -> cache; enforcement=waf_2, mtls_2 +- boundary_3: worker -> queue; enforcement=waf_3, mtls_3 +- boundary_4: internet -> app; enforcement=none +- boundary_5: dmz -> db; enforcement=waf_5, mtls_5 +- boundary_6: vpc -> cache; enforcement=waf_6, mtls_6 +- boundary_7: worker -> queue; enforcement=waf_7, mtls_7 +- boundary_8: internet -> app; enforcement=none +- boundary_9: dmz -> db; enforcement=waf_9, mtls_9 + +Framework/deployment API security signals: 13 total, showing top 10: +- django.middleware.csrf.CsrfViewMiddleware +- helmet defaults +- SecurityMiddleware +- rack-protection +- spring-security filter chain +- kubernetes ingress with TLS termination +- docker-compose exposes 5432 +- no WAF in front of /api +- secrets mounted from vault +- readiness probe on /healthz \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/auth.txt b/go/internal/recontext/testdata/golden/auth.txt new file mode 100644 index 0000000..5b5f586 --- /dev/null +++ b/go/internal/recontext/testdata/golden/auth.txt @@ -0,0 +1,49 @@ +Authentication/authorization-focused recon summary. + +Auth model: jwt. Details: HS256 JWTs issued by app/auth/service.py, refresh tokens in Redis. + +Auth/session/RBAC modules and middleware candidates: 8 total, showing top 8: +- app/auth/service.py (python) - Session and JWT issuance +- app/auth/rbac.py (python) +- app/web/middleware/csrf.js (javascript) - CSRF middleware +- app/auth/session_store.py (python) - Redis-backed sessions +- app/auth/permissions.py (python) - Role → permission table +- app/common/guard.py (python) - Assorted guard helpers +- db/migrate/2024_add_roles.rb (ruby) - Adds role column +- app/common/jwt_tools.py (python) + +API endpoints to validate for auth/authz coverage: 20 total, showing top 15: +- POST /api/v1/thing/1 -> ThingController.action1 (app/api/thing_1.py:103, auth_required=False, rate_limited=False) +- DELETE /api/v1/thing/3 -> ThingController.action3 (app/api/thing_3.py:109, auth_required=False, rate_limited=False) +- PUT /api/v1/thing/7 -> ThingController.action7 (app/api/thing_7.py:121, auth_required=False, rate_limited=False) +- PATCH /api/v1/thing/9 -> ThingController.action9 (app/api/thing_9.py:127, auth_required=False, rate_limited=False) +- DELETE /api/v1/thing/13 -> ThingController.action13 (app/api/thing_13.py:139, auth_required=False, rate_limited=False) +- GET /api/v1/thing/15 -> ThingController.action15 (app/api/thing_15.py:145, auth_required=False, rate_limited=False) +- PATCH /api/v1/thing/19 -> ThingController.action19 (app/api/thing_19.py:157, auth_required=False, rate_limited=False) +- GET /api/v1/thing/0 -> ThingController.action0 (app/api/thing_0.py:100, auth_required=True, rate_limited=None) +- PUT /api/v1/thing/2 -> ThingController.action2 (app/api/thing_2.py:106, auth_required=None, rate_limited=True) +- PATCH /api/v1/thing/4 -> ThingController.action4 (app/api/thing_4.py:112, auth_required=True, rate_limited=None) +- GET /api/v1/thing/5 -> ThingController.action5 (app/api/thing_5.py:115, auth_required=None, rate_limited=True) +- POST /api/v1/thing/6 -> ThingController.action6 (app/api/thing_6.py:118, auth_required=True, rate_limited=None) +- DELETE /api/v1/thing/8 -> ThingController.action8 (app/api/thing_8.py:124, auth_required=None, rate_limited=True) +- GET /api/v1/thing/10 -> ThingController.action10 (app/api/thing_10.py:130, auth_required=True, rate_limited=None) +- POST /api/v1/thing/11 -> ThingController.action11 (app/api/thing_11.py:133, auth_required=None, rate_limited=True) + +Auth/session-relevant data flows: 5 total, showing top 5: +- request.cookies['session'] -> redis.set (files=app/auth/session_store.py, sanitized=True) +- form['password'] -> logger.info (files=app/auth/service.py, app/obs/telemetry.go, sanitized=False) +- header['Authorization'] -> jwt.decode (files=app/common/jwt_tools.py, sanitized=True) +- session['role'] -> permission_check (files=app/auth/permissions.py, app/auth/rbac.py, sanitized=True) +- token -> cache.set (files=app/common/cache.py, sanitized=True) + +Security headers and framework security signals: 11 total, showing top 10: +- Content-Security-Policy: default-src 'self' +- X-Content-Type-Options: nosniff +- Strict-Transport-Security: max-age=31536000 +- X-Frame-Options: DENY +- Referrer-Policy: no-referrer +- Permissions-Policy: geolocation=() +- django.middleware.csrf.CsrfViewMiddleware +- helmet defaults +- SecurityMiddleware +- rack-protection \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/config_secrets.txt b/go/internal/recontext/testdata/golden/config_secrets.txt new file mode 100644 index 0000000..08d9de2 --- /dev/null +++ b/go/internal/recontext/testdata/golden/config_secrets.txt @@ -0,0 +1,47 @@ +Config and secrets-focused recon summary. + +Detected secret-like findings: 17 total, showing top 15: +- aws_access_key at config/env_0.yaml:3; confidence=high; is_test_value=False +- github_token at config/env_1.yaml:4; confidence=medium; is_test_value=True +- private_key at config/env_2.yaml:5; confidence=low; is_test_value=None +- slack_webhook at config/env_3.yaml:6; confidence=high; is_test_value=False +- generic_api_key at config/env_4.yaml:7; confidence=medium; is_test_value=True +- aws_access_key at config/env_5.yaml:8; confidence=low; is_test_value=None +- github_token at config/env_6.yaml:9; confidence=high; is_test_value=False +- private_key at config/env_7.yaml:10; confidence=medium; is_test_value=True +- slack_webhook at config/env_8.yaml:11; confidence=low; is_test_value=None +- generic_api_key at config/env_9.yaml:12; confidence=high; is_test_value=False +- aws_access_key at config/env_10.yaml:13; confidence=medium; is_test_value=True +- github_token at config/env_11.yaml:14; confidence=low; is_test_value=None +- private_key at config/env_12.yaml:15; confidence=high; is_test_value=False +- slack_webhook at config/env_13.yaml:16; confidence=medium; is_test_value=True +- generic_api_key at config/env_14.yaml:17; confidence=low; is_test_value=None + +Configuration weaknesses from recon: 16 total, showing top 15: +- logging at config/app_0.ini:0; risk=high; key=LOG_LEVEL +- tls at config/app_1.ini:13; risk=critical; key=SSL_VERIFY +- cors at config/app_2.ini:14; risk=high; key=ALLOW_ORIGIN +- headers at config/app_3.ini:0; risk=medium; key=X_FRAME_OPTIONS +- debug at config/app_4.ini:16; risk=critical; key=DJANGO_DEBUG +- storage at config/app_5.ini:0; risk=critical; key=BUCKET_ACL +- auth at config/app_6.ini:18; risk=medium; key=SESSION_TIMEOUT +- http at config/app_7.ini:19; risk=high; key=REDIRECT_HTTPS +- secrets at config/app_8.ini:20; risk=medium; key=n/a +- trace at config/app_9.ini:21; risk=low; key=OTEL_TRACE_ALL +- network at config/app_10.ini:0; risk=medium; key=BIND_ADDR +- errors at config/app_11.ini:23; risk=high; key=SHOW_STACKTRACE +- cache at config/app_12.ini:24; risk=low; key=CACHE_TTL +- db at config/app_13.ini:25; risk=critical; key=SSLMODE +- queue at config/app_14.ini:26; risk=low; key=PREFETCH + +Security/deployment context affecting config risk: 13 total, showing top 10: +- kubernetes ingress with TLS termination +- docker-compose exposes 5432 +- no WAF in front of /api +- secrets mounted from vault +- readiness probe on /healthz +- single replica for the worker +- TLS 1.2 minimum +- internal service mesh mTLS +- django.middleware.csrf.CsrfViewMiddleware +- helmet defaults \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/crypto.txt b/go/internal/recontext/testdata/golden/crypto.txt new file mode 100644 index 0000000..ca7dae7 --- /dev/null +++ b/go/internal/recontext/testdata/golden/crypto.txt @@ -0,0 +1,44 @@ +Cryptography-focused recon summary. + +Crypto usage entries: 16 total. + +Algorithms and key handling (weak entries first): 16 total, showing top 15: +- algorithm=MD5, key_size=None, mode=None, context=password hashing, is_weak=True +- algorithm=RSA, key_size=1024, mode=None, context=token signing, is_weak=True +- algorithm=DES, key_size=56, mode=CBC, context=legacy export, is_weak=True +- algorithm=HMAC-SHA1, key_size=160, mode=None, context=webhook signature, is_weak=True +- algorithm=RC4, key_size=128, mode=None, context=unspecified, is_weak=True +- algorithm=SHA-1, key_size=None, mode=None, context=checksum, is_weak=True +- algorithm=AES, key_size=128, mode=ECB, context=legacy blob, is_weak=True +- algorithm=3DES, key_size=168, mode=CBC, context=legacy tape, is_weak=True +- algorithm=AES, key_size=256, mode=GCM, context=at-rest encryption, is_weak=False +- algorithm=SHA-256, key_size=None, mode=None, context=unspecified, is_weak=False +- algorithm=ChaCha20, key_size=256, mode=Poly1305, context=transport, is_weak=False +- algorithm=bcrypt, key_size=None, mode=None, context=password hashing, is_weak=False +- algorithm=ECDSA, key_size=256, mode=None, context=JWT signing, is_weak=None +- algorithm=PBKDF2, key_size=None, mode=None, context=key derivation, is_weak=False +- algorithm=Ed25519, key_size=256, mode=None, context=package signing, is_weak=False + +Potential secret/key findings from config scan: 17 total, showing top 10: +- aws_access_key at config/env_0.yaml:3 (confidence=high) +- github_token at config/env_1.yaml:4 (confidence=medium) +- private_key at config/env_2.yaml:5 (confidence=low) +- slack_webhook at config/env_3.yaml:6 (confidence=high) +- generic_api_key at config/env_4.yaml:7 (confidence=medium) +- aws_access_key at config/env_5.yaml:8 (confidence=low) +- github_token at config/env_6.yaml:9 (confidence=high) +- private_key at config/env_7.yaml:10 (confidence=medium) +- slack_webhook at config/env_8.yaml:11 (confidence=low) +- generic_api_key at config/env_9.yaml:12 (confidence=high) + +Deployment/TLS/security header signals: 14 total, showing top 10: +- kubernetes ingress with TLS termination +- docker-compose exposes 5432 +- no WAF in front of /api +- secrets mounted from vault +- readiness probe on /healthz +- single replica for the worker +- TLS 1.2 minimum +- internal service mesh mTLS +- Content-Security-Policy: default-src 'self' +- X-Content-Type-Options: nosniff \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/data_exposure.txt b/go/internal/recontext/testdata/golden/data_exposure.txt new file mode 100644 index 0000000..d614c85 --- /dev/null +++ b/go/internal/recontext/testdata/golden/data_exposure.txt @@ -0,0 +1,32 @@ +Data exposure-focused recon summary. + +Data flows touching likely sensitive domains: 8 total, showing top 8: +- request.cookies['session'] -> redis.set; sanitized=True; files=app/auth/session_store.py +- form['password'] -> logger.info; sanitized=False; files=app/auth/service.py, app/obs/telemetry.go +- header['Authorization'] -> jwt.decode; sanitized=True; files=app/common/jwt_tools.py +- query['email'] -> smtp.send; sanitized=False; files=app/notify/mailer.rb +- body['card_number'] -> stripe.Charge.create; sanitized=True; files=app/billing/payments.go, app/billing/core.py +- session['role'] -> permission_check; sanitized=True; files=app/auth/permissions.py, app/auth/rbac.py +- token -> cache.set; sanitized=True; files=app/common/cache.py +- request.headers['X-User-Phone'] -> audit_log; sanitized=False; files=app/obs/audit.py + +Logging/exposure-related misconfig signals: 7 total, showing top 7: +- logging at config/app_0.ini:0; risk=high; key=LOG_LEVEL +- tls at config/app_1.ini:13; risk=critical; key=SSL_VERIFY +- debug at config/app_4.ini:16; risk=critical; key=DJANGO_DEBUG +- http at config/app_7.ini:19; risk=high; key=REDIRECT_HTTPS +- trace at config/app_9.ini:21; risk=low; key=OTEL_TRACE_ALL +- errors at config/app_11.ini:23; risk=high; key=SHOW_STACKTRACE +- exposure at config/app_15.ini:0; risk=medium; key=ADMIN_PATH + +Entry points and API surface with exposure risk: 20 total, showing top 10: +- GET /api/v1/thing/0 (app/api/thing_0.py:100, auth_required=True) +- POST /api/v1/thing/1 (app/api/thing_1.py:103, auth_required=False) +- PUT /api/v1/thing/2 (app/api/thing_2.py:106, auth_required=None) +- DELETE /api/v1/thing/3 (app/api/thing_3.py:109, auth_required=False) +- PATCH /api/v1/thing/4 (app/api/thing_4.py:112, auth_required=True) +- GET /api/v1/thing/5 (app/api/thing_5.py:115, auth_required=None) +- POST /api/v1/thing/6 (app/api/thing_6.py:118, auth_required=True) +- PUT /api/v1/thing/7 (app/api/thing_7.py:121, auth_required=False) +- DELETE /api/v1/thing/8 (app/api/thing_8.py:124, auth_required=None) +- PATCH /api/v1/thing/9 (app/api/thing_9.py:127, auth_required=False) \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/framework_hints_aliases.txt b/go/internal/recontext/testdata/golden/framework_hints_aliases.txt new file mode 100644 index 0000000..a2be8c7 --- /dev/null +++ b/go/internal/recontext/testdata/golden/framework_hints_aliases.txt @@ -0,0 +1,18 @@ +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: NEXTJS + Security features: React JSX auto-escapes output by default, API routes can share centralized auth middleware, Server/client boundaries reduce accidental secret exposure + DO NOT FLAG: React JSX escaped rendering, getServerSideProps/getServerSession with proper auth checks, Typed route handlers with validated schema guards + Watch for: dangerouslySetInnerHTML, unprotected API routes under /api, secret leakage to client bundles, rewrites/redirects from untrusted user input + Common vulns: XSS through dangerous HTML rendering, IDOR/auth bypass in API handlers, Sensitive env/config exposure in client-side code + +Framework: SPRING + Security features: Spring Security provides auth, CSRF, and filter chain defaults, JPA/Hibernate prepared parameter binding by default, Bean validation can enforce request constraints + DO NOT FLAG: PreparedStatement/JPA named parameter usage, Spring Security CSRF/auth filters clearly active, Thymeleaf auto-escaped output + Watch for: @PreAuthorize missing on sensitive methods, JdbcTemplate/raw Statement with concatenation, csrf().disable() on browser session flows, Actuator endpoints exposed without auth + Common vulns: Authz bypass from weak method-level security, SQL injection in raw JDBC queries, Sensitive management endpoint exposure + +Framework: ASPNET + Security features: Razor encodes output by default, Model binding and data annotations aid validation, Anti-forgery tokens support CSRF defense + DO NOT FLAG: Entity Framework LINQ parameterized queries, Razor default HTML encoding, ValidateAntiForgeryToken in state-changing MVC actions + Watch for: Html.Raw() on untrusted input, FromBody models without validation, Authorize missing on privileged endpoints, custom SQL built via string interpolation + Common vulns: XSS through Html.Raw and unencoded output, Auth/authz bypass on unsecured controllers, SQL injection in handcrafted query strings \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/framework_hints_all_known.txt b/go/internal/recontext/testdata/golden/framework_hints_all_known.txt new file mode 100644 index 0000000..f87fa9a --- /dev/null +++ b/go/internal/recontext/testdata/golden/framework_hints_all_known.txt @@ -0,0 +1,66 @@ +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +Framework: FLASK + Security features: Jinja2 auto-escapes in HTML templates, Werkzeug handles request parsing safely by default, Blueprint/middleware patterns can centralize auth checks + DO NOT FLAG: Jinja2 auto-escaped template variables, Parameterized SQLAlchemy query usage, Server-side session signing with strong SECRET_KEY + Watch for: render_template_string() with user input, debug=True in production paths, string-concatenated SQL in execute(), hardcoded SECRET_KEY + Common vulns: SSTI through dynamic template rendering, XSS when auto-escaping is bypassed, session tampering risk with weak secret/config + +Framework: FASTAPI + Security features: Pydantic request validation reduces malformed input, Dependency injection supports reusable auth guards, OpenAPI schema generation improves contract visibility + DO NOT FLAG: Pydantic model validation errors, Dependency-based auth checks clearly enforced, Parameterized SQLAlchemy/async driver usage + Watch for: Depends() omitted on privileged routes, raw SQL in text()/execute() with interpolation, CORS allow_origins=['*'] with credentials, unsafe deserialization in background tasks + Common vulns: Auth bypass on unprotected routes, SQL injection in manually assembled queries, CORS misconfiguration exposing credentialed APIs + +Framework: EXPRESS + Security features: Router middleware can enforce auth and rate limits, helmet can apply secure HTTP headers, Validated schema middleware can constrain input + DO NOT FLAG: Parameterized ORM/database queries, helmet defaults correctly applied, router-level auth middleware consistently enforced + Watch for: res.send()/res.json() leaking sensitive internals, string-built SQL in query(), trust proxy misconfiguration, open CORS with credentials + Common vulns: Authz gaps from missing middleware on routes, NoSQL/SQL injection from unsanitized request bodies, Open redirect and SSRF through unvalidated URLs + +Framework: NEXTJS + Security features: React JSX auto-escapes output by default, API routes can share centralized auth middleware, Server/client boundaries reduce accidental secret exposure + DO NOT FLAG: React JSX escaped rendering, getServerSideProps/getServerSession with proper auth checks, Typed route handlers with validated schema guards + Watch for: dangerouslySetInnerHTML, unprotected API routes under /api, secret leakage to client bundles, rewrites/redirects from untrusted user input + Common vulns: XSS through dangerous HTML rendering, IDOR/auth bypass in API handlers, Sensitive env/config exposure in client-side code + +Framework: SPRING + Security features: Spring Security provides auth, CSRF, and filter chain defaults, JPA/Hibernate prepared parameter binding by default, Bean validation can enforce request constraints + DO NOT FLAG: PreparedStatement/JPA named parameter usage, Spring Security CSRF/auth filters clearly active, Thymeleaf auto-escaped output + Watch for: @PreAuthorize missing on sensitive methods, JdbcTemplate/raw Statement with concatenation, csrf().disable() on browser session flows, Actuator endpoints exposed without auth + Common vulns: Authz bypass from weak method-level security, SQL injection in raw JDBC queries, Sensitive management endpoint exposure + +Framework: RAILS + Security features: ActiveRecord parameterization for hash/array queries, ERB templates auto-escape by default, Built-in CSRF protection for non-GET requests + DO NOT FLAG: ActiveRecord where with hash conditions, Rails protect_from_forgery active, ERB escaped output without raw/html_safe + Watch for: where/order/find_by_sql with string interpolation, raw()/html_safe on untrusted content, skip_before_action on auth filters, mass assignment via permit! + Common vulns: SQL injection in manual query fragments, XSS via unsafe output helpers, Authz bypass from skipped controller guards + +Framework: ASPNET + Security features: Razor encodes output by default, Model binding and data annotations aid validation, Anti-forgery tokens support CSRF defense + DO NOT FLAG: Entity Framework LINQ parameterized queries, Razor default HTML encoding, ValidateAntiForgeryToken in state-changing MVC actions + Watch for: Html.Raw() on untrusted input, FromBody models without validation, Authorize missing on privileged endpoints, custom SQL built via string interpolation + Common vulns: XSS through Html.Raw and unencoded output, Auth/authz bypass on unsecured controllers, SQL injection in handcrafted query strings + +Framework: REACT + Security features: JSX escapes strings before DOM rendering, Component model encourages explicit data flow, Framework discourages direct DOM mutation + DO NOT FLAG: Standard JSX expression rendering, textContent assignment for untrusted text, sanitized HTML via trusted DOMPurify policy + Watch for: dangerouslySetInnerHTML, untrusted URL assignment to href/src, eval/new Function in client code, token/secret exposure in bundles + Common vulns: DOM XSS through unsafe HTML injection, Open redirect via unvalidated navigation targets, Sensitive data exposure in frontend artifacts + +Framework: VUE + Security features: Mustache template interpolation escapes HTML, Component props/events provide explicit boundaries, Router guards can enforce auth flows + DO NOT FLAG: escaped template interpolation {{ value }}, validated route guards protecting private routes, sanitized content rendered through safe components + Watch for: v-html with untrusted data, dynamic component/template compilation, unsafe URL bindings in href/src, client-side auth checks without server enforcement + Common vulns: XSS through v-html and unsafe render paths, Auth bypass from client-only route protection, Open redirect patterns in router navigation + +Framework: ANGULAR + Security features: Template binding sanitization for HTML/URL contexts, HttpClient and interceptor patterns support central controls, AOT compilation limits runtime template injection vectors + DO NOT FLAG: default Angular template binding sanitization, HttpClient usage with validated request schemas, route guards consistently applied + Watch for: bypassSecurityTrustHtml/Url/Script, [innerHTML] with unsanitized input, direct DOM APIs via ElementRef/nativeElement, auth only in client guard without server checks + Common vulns: XSS when sanitizer is explicitly bypassed, Token leakage in local storage/logging, Authorization gaps due to client-only enforcement \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/framework_hints_empty.txt b/go/internal/recontext/testdata/golden/framework_hints_empty.txt new file mode 100644 index 0000000..1757256 --- /dev/null +++ b/go/internal/recontext/testdata/golden/framework_hints_empty.txt @@ -0,0 +1 @@ +No framework-specific hints available for detected frameworks. \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/framework_hints_for_context.txt b/go/internal/recontext/testdata/golden/framework_hints_for_context.txt new file mode 100644 index 0000000..ce24e9f --- /dev/null +++ b/go/internal/recontext/testdata/golden/framework_hints_for_context.txt @@ -0,0 +1,30 @@ +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides + +Framework: NEXTJS + Security features: React JSX auto-escapes output by default, API routes can share centralized auth middleware, Server/client boundaries reduce accidental secret exposure + DO NOT FLAG: React JSX escaped rendering, getServerSideProps/getServerSession with proper auth checks, Typed route handlers with validated schema guards + Watch for: dangerouslySetInnerHTML, unprotected API routes under /api, secret leakage to client bundles, rewrites/redirects from untrusted user input + Common vulns: XSS through dangerous HTML rendering, IDOR/auth bypass in API handlers, Sensitive env/config exposure in client-side code + +Framework: SPRING + Security features: Spring Security provides auth, CSRF, and filter chain defaults, JPA/Hibernate prepared parameter binding by default, Bean validation can enforce request constraints + DO NOT FLAG: PreparedStatement/JPA named parameter usage, Spring Security CSRF/auth filters clearly active, Thymeleaf auto-escaped output + Watch for: @PreAuthorize missing on sensitive methods, JdbcTemplate/raw Statement with concatenation, csrf().disable() on browser session flows, Actuator endpoints exposed without auth + Common vulns: Authz bypass from weak method-level security, SQL injection in raw JDBC queries, Sensitive management endpoint exposure + +Framework: REACT + Security features: JSX escapes strings before DOM rendering, Component model encourages explicit data flow, Framework discourages direct DOM mutation + DO NOT FLAG: Standard JSX expression rendering, textContent assignment for untrusted text, sanitized HTML via trusted DOMPurify policy + Watch for: dangerouslySetInnerHTML, untrusted URL assignment to href/src, eval/new Function in client code, token/secret exposure in bundles + Common vulns: DOM XSS through unsafe HTML injection, Open redirect via unvalidated navigation targets, Sensitive data exposure in frontend artifacts + +Framework: VUE + Security features: Mustache template interpolation escapes HTML, Component props/events provide explicit boundaries, Router guards can enforce auth flows + DO NOT FLAG: escaped template interpolation {{ value }}, validated route guards protecting private routes, sanitized content rendered through safe components + Watch for: v-html with untrusted data, dynamic component/template compilation, unsafe URL bindings in href/src, client-side auth checks without server enforcement + Common vulns: XSS through v-html and unsafe render paths, Auth bypass from client-only route protection, Open redirect patterns in router navigation \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/framework_hints_padded.txt b/go/internal/recontext/testdata/golden/framework_hints_padded.txt new file mode 100644 index 0000000..1b99225 --- /dev/null +++ b/go/internal/recontext/testdata/golden/framework_hints_padded.txt @@ -0,0 +1,18 @@ +FRAMEWORK-SPECIFIC GUIDANCE: +Framework: REACT + Security features: JSX escapes strings before DOM rendering, Component model encourages explicit data flow, Framework discourages direct DOM mutation + DO NOT FLAG: Standard JSX expression rendering, textContent assignment for untrusted text, sanitized HTML via trusted DOMPurify policy + Watch for: dangerouslySetInnerHTML, untrusted URL assignment to href/src, eval/new Function in client code, token/secret exposure in bundles + Common vulns: DOM XSS through unsafe HTML injection, Open redirect via unvalidated navigation targets, Sensitive data exposure in frontend artifacts + +Framework: VUE + Security features: Mustache template interpolation escapes HTML, Component props/events provide explicit boundaries, Router guards can enforce auth flows + DO NOT FLAG: escaped template interpolation {{ value }}, validated route guards protecting private routes, sanitized content rendered through safe components + Watch for: v-html with untrusted data, dynamic component/template compilation, unsafe URL bindings in href/src, client-side auth checks without server enforcement + Common vulns: XSS through v-html and unsafe render paths, Auth bypass from client-only route protection, Open redirect patterns in router navigation + +Framework: DJANGO + Security features: ORM queries are parameterized by default, Templates auto-escape variables by default, CsrfViewMiddleware enforces CSRF tokens for unsafe methods + DO NOT FLAG: Django ORM filter/exclude (parameterized), CSRF with CsrfViewMiddleware active, XSS in Django templates (auto-escaped) + Watch for: raw() queries, mark_safe(), |safe filter, CSRF_COOKIE_SECURE=False + Common vulns: SQL injection via raw SQL and string formatting, XSS via unsafe template escape bypasses, CSRF weakening via middleware/settings overrides \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/framework_hints_unknown_only.txt b/go/internal/recontext/testdata/golden/framework_hints_unknown_only.txt new file mode 100644 index 0000000..1757256 --- /dev/null +++ b/go/internal/recontext/testdata/golden/framework_hints_unknown_only.txt @@ -0,0 +1 @@ +No framework-specific hints available for detected frameworks. \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/generic.txt b/go/internal/recontext/testdata/golden/generic.txt new file mode 100644 index 0000000..5a8d2cf --- /dev/null +++ b/go/internal/recontext/testdata/golden/generic.txt @@ -0,0 +1,39 @@ +General recon summary. + +Profile: 2417 files, 184213 LOC, languages=Python, JavaScript, Go, Ruby, Rust, python, frameworks=Django, next.js, Spring Boot, React , unknown-fw, NEXT, Vue. + +Top entry points: 17 total, showing top 10: +- http handler_0 (app/entry/e0.py:10) +- HTTP /v1/resource/1 (app/entry/e1.py:17) +- cli /v1/resource/2 (app/entry/e2.py:24) +- api handler_3 (app/entry/e3.py:31) +- graphql /v1/resource/4 (app/entry/e4.py:38) +- rpc /v1/resource/5 (app/entry/e5.py:45) +- route handler_6 (app/entry/e6.py:52) +- cron /v1/resource/7 (app/entry/e7.py:59) +- queue /v1/resource/8 (app/entry/e8.py:66) +- http handler_9 (app/entry/e9.py:73) + +Top API endpoints: 20 total, showing top 10: +- GET /api/v1/thing/0 (app/api/thing_0.py:100) +- POST /api/v1/thing/1 (app/api/thing_1.py:103) +- PUT /api/v1/thing/2 (app/api/thing_2.py:106) +- DELETE /api/v1/thing/3 (app/api/thing_3.py:109) +- PATCH /api/v1/thing/4 (app/api/thing_4.py:112) +- GET /api/v1/thing/5 (app/api/thing_5.py:115) +- POST /api/v1/thing/6 (app/api/thing_6.py:118) +- PUT /api/v1/thing/7 (app/api/thing_7.py:121) +- DELETE /api/v1/thing/8 (app/api/thing_8.py:124) +- PATCH /api/v1/thing/9 (app/api/thing_9.py:127) + +Top data-flow candidates: 20 total, showing top 10: +- request.args['q'] -> cursor.execute; sanitized=False +- request.cookies['session'] -> redis.set; sanitized=True +- request.json['url'] -> requests.get; sanitized=False +- form['password'] -> logger.info; sanitized=False +- header['Authorization'] -> jwt.decode; sanitized=True +- query['email'] -> smtp.send; sanitized=False +- body['card_number'] -> stripe.Charge.create; sanitized=True +- path_param['id'] -> orm.filter; sanitized=True +- upload.filename -> open; sanitized=False +- env['DEBUG'] -> template.render; sanitized=False \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/injection.txt b/go/internal/recontext/testdata/golden/injection.txt new file mode 100644 index 0000000..90eaced --- /dev/null +++ b/go/internal/recontext/testdata/golden/injection.txt @@ -0,0 +1,64 @@ +Injection-focused recon summary. + +Codebase profile: 2417 files, 184213 LOC, languages=Python, JavaScript, Go, Ruby, Rust, python, frameworks=Django, next.js, Spring Boot, React , unknown-fw, NEXT, Vue. + +Entry points likely to receive untrusted input: 17 total, showing top 15: +- http handler_0 (app/entry/e0.py:10) +- HTTP /v1/resource/1 (app/entry/e1.py:17) +- cli /v1/resource/2 (app/entry/e2.py:24) +- api handler_3 (app/entry/e3.py:31) +- graphql /v1/resource/4 (app/entry/e4.py:38) +- rpc /v1/resource/5 (app/entry/e5.py:45) +- route handler_6 (app/entry/e6.py:52) +- cron /v1/resource/7 (app/entry/e7.py:59) +- queue /v1/resource/8 (app/entry/e8.py:66) +- http handler_9 (app/entry/e9.py:73) +- api /v1/resource/10 (app/entry/e10.py:80) +- grpc /v1/resource/11 (app/entry/e11.py:87) +- http handler_12 (app/entry/e12.py:94) +- route /v1/resource/13 (app/entry/e13.py:101) +- cli /v1/resource/14 (app/entry/e14.py:108) + +High-value sinks: 18 total, showing top 15: +- sql at app/sink/k0.py:200 +- command at app/sink/k1.py:202 (sink_fn_1) +- template at app/sink/k2.py:204 (sink_fn_2) +- file at app/sink/k3.py:206 (sink_fn_3) +- network at app/sink/k4.py:208 +- deserialization at app/sink/k5.py:210 (sink_fn_5) +- sql at app/sink/k6.py:212 (sink_fn_6) +- command at app/sink/k7.py:214 (sink_fn_7) +- template at app/sink/k8.py:216 +- file at app/sink/k9.py:218 (sink_fn_9) +- network at app/sink/k10.py:220 (sink_fn_10) +- deserialization at app/sink/k11.py:222 (sink_fn_11) +- sql at app/sink/k12.py:224 +- command at app/sink/k13.py:226 (sink_fn_13) +- template at app/sink/k14.py:228 (sink_fn_14) + +Source-to-sink flow candidates (unsanitized first): 13 total, showing top 13: +- request.args['q'] -> cursor.execute; sanitized=False; files=app/search/index.ts, app/db/raw.py, app/util/a.py +- request.json['url'] -> requests.get; sanitized=False; files=app/integrations/webhooks.go, app/net/client.go +- form['password'] -> logger.info; sanitized=False; files=app/auth/service.py, app/obs/telemetry.go +- query['email'] -> smtp.send; sanitized=False; files=app/notify/mailer.rb +- upload.filename -> open; sanitized=False; files=app/media/uploads.py, app/common/cache.py, app/common/guard.py +- env['DEBUG'] -> template.render; sanitized=False; files=app/web/render.py +- request.body -> eval; sanitized=False; files=app/admin/views.py +- websocket.msg -> subprocess.run; sanitized=False; files=app/ws/handler.py, app/ws/exec.py +- cli.argv -> os.system; sanitized=False; files=tools/run.py +- queue.payload -> pickle.loads; sanitized=False; files=app/worker/task.py +- request.args['redirect'] -> HttpResponseRedirect; sanitized=False; files=app/web/redirect.py +- request.headers['X-User-Phone'] -> audit_log; sanitized=False; files=app/obs/audit.py +- graphql.variables -> db.query; sanitized=False; files=app/api/graphql.ts + +Known sanitization points: 12 total, showing top 10: +- app/sanitize/s0.py:5 type=escape protects=unspecified +- app/sanitize/s1.py:9 type=parameterize protects=CWE-80, CWE-90 +- app/sanitize/s2.py:13 type=allowlist protects=CWE-81, CWE-91 +- app/sanitize/s3.py:17 type=encode protects=unspecified +- app/sanitize/s4.py:21 type=escape protects=CWE-83, CWE-93 +- app/sanitize/s5.py:25 type=parameterize protects=CWE-84, CWE-94 +- app/sanitize/s6.py:29 type=allowlist protects=unspecified +- app/sanitize/s7.py:33 type=encode protects=CWE-86, CWE-96 +- app/sanitize/s8.py:37 type=escape protects=CWE-87, CWE-97 +- app/sanitize/s9.py:41 type=parameterize protects=unspecified \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/language_hints_all_known.txt b/go/internal/recontext/testdata/golden/language_hints_all_known.txt new file mode 100644 index 0000000..8d45c2f --- /dev/null +++ b/go/internal/recontext/testdata/golden/language_hints_all_known.txt @@ -0,0 +1,42 @@ +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. + +Language: JAVASCRIPT + Key sinks: eval, Function(), innerHTML, document.write, child_process.exec, new Function + Safe patterns (skip these): DOMPurify.sanitize(), textContent assignment, parameterized pg queries + DO NOT FLAG: React JSX expressions (auto-escaped), Angular template bindings (sanitized by default) + Framework notes: Check for React/Vue/Angular-specific XSS patterns. React auto-escapes JSX. Vue v-html is dangerous. + +Language: TYPESCRIPT + Key sinks: eval, Function(), innerHTML, document.write, child_process.exec + Safe patterns (skip these): DOMPurify.sanitize(), textContent assignment, Prisma parameterized queries + DO NOT FLAG: React JSX expressions, Angular template bindings, Prisma ORM queries + Framework notes: TypeScript adds type safety but doesn't prevent injection. Check for any type assertions near user input. + +Language: GO + Key sinks: fmt.Sprintf into SQL, exec.Command with user input, template.HTML(), os.Exec + Safe patterns (skip these): database/sql with ? placeholders, html/template (auto-escapes), exec.Command with separate args + DO NOT FLAG: GORM parameterized queries, html/template default escaping, database/sql prepared statements + Framework notes: Go's html/template auto-escapes. text/template does NOT. Check for text/template serving HTML. + +Language: JAVA + Key sinks: Statement.execute, Runtime.exec, ProcessBuilder with concatenated strings, ScriptEngine.eval + Safe patterns (skip these): PreparedStatement with ?, JNDI lookup with allowlist, OWASP ESAPI encoding + DO NOT FLAG: JPA/Hibernate named parameters, Spring Security CSRF protection, PreparedStatement usage + Framework notes: Check Spring Boot auto-config. Thymeleaf auto-escapes. JSP needs explicit escaping. + +Language: RUBY + Key sinks: eval, system, exec, send, public_send, ERB.new with user input + Safe patterns (skip these): ActiveRecord parameterized queries, Rack::Utils.escape_html, sanitize helper in Rails + DO NOT FLAG: ActiveRecord where with hash conditions, Rails CSRF protection, Rails html_safe on constants + Framework notes: Rails auto-escapes ERB templates. raw/html_safe bypasses escaping. Check for mass assignment. + +Language: CSHARP + Key sinks: SqlCommand with concatenation, Process.Start with user input, Razor @Html.Raw() + Safe patterns (skip these): SqlParameter, Entity Framework LINQ, Razor auto-encoding + DO NOT FLAG: Entity Framework LINQ queries, ASP.NET anti-forgery tokens, Razor default encoding + Framework notes: ASP.NET Core Razor auto-encodes. @Html.Raw() is dangerous. Check for [ValidateAntiForgeryToken]. \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/language_hints_empty.txt b/go/internal/recontext/testdata/golden/language_hints_empty.txt new file mode 100644 index 0000000..3268b96 --- /dev/null +++ b/go/internal/recontext/testdata/golden/language_hints_empty.txt @@ -0,0 +1 @@ +No language-specific hints available for detected languages. \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/language_hints_for_context.txt b/go/internal/recontext/testdata/golden/language_hints_for_context.txt new file mode 100644 index 0000000..7594084 --- /dev/null +++ b/go/internal/recontext/testdata/golden/language_hints_for_context.txt @@ -0,0 +1,30 @@ +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. + +Language: JAVASCRIPT + Key sinks: eval, Function(), innerHTML, document.write, child_process.exec, new Function + Safe patterns (skip these): DOMPurify.sanitize(), textContent assignment, parameterized pg queries + DO NOT FLAG: React JSX expressions (auto-escaped), Angular template bindings (sanitized by default) + Framework notes: Check for React/Vue/Angular-specific XSS patterns. React auto-escapes JSX. Vue v-html is dangerous. + +Language: GO + Key sinks: fmt.Sprintf into SQL, exec.Command with user input, template.HTML(), os.Exec + Safe patterns (skip these): database/sql with ? placeholders, html/template (auto-escapes), exec.Command with separate args + DO NOT FLAG: GORM parameterized queries, html/template default escaping, database/sql prepared statements + Framework notes: Go's html/template auto-escapes. text/template does NOT. Check for text/template serving HTML. + +Language: RUBY + Key sinks: eval, system, exec, send, public_send, ERB.new with user input + Safe patterns (skip these): ActiveRecord parameterized queries, Rack::Utils.escape_html, sanitize helper in Rails + DO NOT FLAG: ActiveRecord where with hash conditions, Rails CSRF protection, Rails html_safe on constants + Framework notes: Rails auto-escapes ERB templates. raw/html_safe bypasses escaping. Check for mass assignment. + +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/language_hints_mixed_case_and_repeat.txt b/go/internal/recontext/testdata/golden/language_hints_mixed_case_and_repeat.txt new file mode 100644 index 0000000..cbead73 --- /dev/null +++ b/go/internal/recontext/testdata/golden/language_hints_mixed_case_and_repeat.txt @@ -0,0 +1,24 @@ +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. + +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. + +Language: JAVASCRIPT + Key sinks: eval, Function(), innerHTML, document.write, child_process.exec, new Function + Safe patterns (skip these): DOMPurify.sanitize(), textContent assignment, parameterized pg queries + DO NOT FLAG: React JSX expressions (auto-escaped), Angular template bindings (sanitized by default) + Framework notes: Check for React/Vue/Angular-specific XSS patterns. React auto-escapes JSX. Vue v-html is dangerous. + +Language: GO + Key sinks: fmt.Sprintf into SQL, exec.Command with user input, template.HTML(), os.Exec + Safe patterns (skip these): database/sql with ? placeholders, html/template (auto-escapes), exec.Command with separate args + DO NOT FLAG: GORM parameterized queries, html/template default escaping, database/sql prepared statements + Framework notes: Go's html/template auto-escapes. text/template does NOT. Check for text/template serving HTML. \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/language_hints_single.txt b/go/internal/recontext/testdata/golden/language_hints_single.txt new file mode 100644 index 0000000..935dd64 --- /dev/null +++ b/go/internal/recontext/testdata/golden/language_hints_single.txt @@ -0,0 +1,6 @@ +LANGUAGE-SPECIFIC GUIDANCE: +Language: PYTHON + Key sinks: cursor.execute, os.system, subprocess.run, eval, exec, pickle.loads + Safe patterns (skip these): parameterized queries with %s placeholders, subprocess.run with list args (no shell=True) + DO NOT FLAG: Django ORM queries (uses parameterized queries internally), SQLAlchemy text() with bound params + Framework notes: Check for Django/Flask/FastAPI-specific patterns. Django auto-escapes templates. Flask Jinja2 auto-escapes. \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/language_hints_unknown_only.txt b/go/internal/recontext/testdata/golden/language_hints_unknown_only.txt new file mode 100644 index 0000000..3268b96 --- /dev/null +++ b/go/internal/recontext/testdata/golden/language_hints_unknown_only.txt @@ -0,0 +1 @@ +No language-specific hints available for detected languages. \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/logic.txt b/go/internal/recontext/testdata/golden/logic.txt new file mode 100644 index 0000000..bb89d68 --- /dev/null +++ b/go/internal/recontext/testdata/golden/logic.txt @@ -0,0 +1,59 @@ +Business-logic-focused recon summary. + +Core modules likely to implement workflows and state transitions: 19 total, showing top 15: +- app/auth/service.py (python) - Session and JWT issuance +- app/auth/rbac.py (python) +- app/billing/core.py (python) - Invoice state machine — handles refunds +- app/web/middleware/csrf.js (javascript) - CSRF middleware +- app/billing/payments.go (go) - Charge orchestration +- app/reports/render.py (python) - PDF and CSV export +- app/search/index.ts (typescript) - Elasticsearch query builder +- app/auth/session_store.py (python) - Redis-backed sessions +- app/notify/mailer.rb (ruby) +- app/auth/permissions.py (python) - Role → permission table +- app/media/uploads.py (python) - S3 multipart upload helper +- app/integrations/webhooks.go (go) - Outbound webhook dispatcher +- app/api/graphql.ts (typescript) - GraphQL resolvers — naïve depth limit +- app/admin/views.py (python) - Django admin overrides +- app/common/guard.py (python) - Assorted guard helpers + +Workflow entry points: 17 total, showing top 10: +- http handler_0 (app/entry/e0.py:10) +- HTTP /v1/resource/1 (app/entry/e1.py:17) +- cli /v1/resource/2 (app/entry/e2.py:24) +- api handler_3 (app/entry/e3.py:31) +- graphql /v1/resource/4 (app/entry/e4.py:38) +- rpc /v1/resource/5 (app/entry/e5.py:45) +- route handler_6 (app/entry/e6.py:52) +- cron /v1/resource/7 (app/entry/e7.py:59) +- queue /v1/resource/8 (app/entry/e8.py:66) +- http handler_9 (app/entry/e9.py:73) + +Cross-file data/control flow candidates: 20 total, showing top 15: +- request.args['q'] -> cursor.execute; files=app/search/index.ts, app/db/raw.py, app/util/a.py, app/util/b.py; sanitized=False +- request.cookies['session'] -> redis.set; files=app/auth/session_store.py; sanitized=True +- request.json['url'] -> requests.get; files=app/integrations/webhooks.go, app/net/client.go; sanitized=False +- form['password'] -> logger.info; files=app/auth/service.py, app/obs/telemetry.go; sanitized=False +- header['Authorization'] -> jwt.decode; files=app/common/jwt_tools.py; sanitized=True +- query['email'] -> smtp.send; files=app/notify/mailer.rb; sanitized=False +- body['card_number'] -> stripe.Charge.create; files=app/billing/payments.go, app/billing/core.py; sanitized=True +- path_param['id'] -> orm.filter; files=app/api/thing_1.py; sanitized=True +- upload.filename -> open; files=app/media/uploads.py, app/common/cache.py, app/common/guard.py, app/x/y.py, app/x/z.py; sanitized=False +- env['DEBUG'] -> template.render; files=app/web/render.py; sanitized=False +- request.body -> eval; files=app/admin/views.py; sanitized=False +- websocket.msg -> subprocess.run; files=app/ws/handler.py, app/ws/exec.py; sanitized=False +- cli.argv -> os.system; files=tools/run.py; sanitized=False +- queue.payload -> pickle.loads; files=app/worker/task.py; sanitized=False +- request.args['redirect'] -> HttpResponseRedirect; files=app/web/redirect.py; sanitized=False + +Trust boundaries and external service transitions: 17 total, showing top 10: +- boundary boundary_0: internet->app +- boundary boundary_1: dmz->db +- boundary boundary_2: vpc->cache +- boundary boundary_3: worker->queue +- boundary boundary_4: internet->app +- boundary boundary_5: dmz->db +- boundary boundary_6: vpc->cache +- boundary boundary_7: worker->queue +- boundary boundary_8: internet->app +- boundary boundary_9: dmz->db \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/prune_config_secrets.json b/go/internal/recontext/testdata/golden/prune_config_secrets.json new file mode 100644 index 0000000..16fbd90 --- /dev/null +++ b/go/internal/recontext/testdata/golden/prune_config_secrets.json @@ -0,0 +1,988 @@ +{ + "architecture": { + "app_type": "web_api", + "modules": [ + { + "name": "auth_service", + "path": "app/auth/service.py", + "language": "python", + "description": "Session and JWT issuance", + "dependencies": [ + "jwt", + "redis" + ] + }, + { + "name": "rbac", + "path": "app/auth/rbac.py", + "language": "python", + "description": null, + "dependencies": [] + }, + { + "name": "billing", + "path": "app/billing/core.py", + "language": "python", + "description": "Invoice state machine \u2014 handles refunds", + "dependencies": [ + "stripe" + ] + }, + { + "name": "csrf_guard", + "path": "app/web/middleware/csrf.js", + "language": "javascript", + "description": "CSRF middleware", + "dependencies": [] + }, + { + "name": "payments", + "path": "app/billing/payments.go", + "language": "go", + "description": "Charge orchestration", + "dependencies": [ + "stripe-go" + ] + }, + { + "name": "reporting", + "path": "app/reports/render.py", + "language": "python", + "description": "PDF and CSV export", + "dependencies": [ + "weasyprint" + ] + }, + { + "name": "search", + "path": "app/search/index.ts", + "language": "typescript", + "description": "Elasticsearch query builder", + "dependencies": [ + "@elastic/elasticsearch" + ] + }, + { + "name": "session_store", + "path": "app/auth/session_store.py", + "language": "python", + "description": "Redis-backed sessions", + "dependencies": [ + "redis" + ] + }, + { + "name": "notifications", + "path": "app/notify/mailer.rb", + "language": "ruby", + "description": null, + "dependencies": [ + "mail" + ] + }, + { + "name": "permission_matrix", + "path": "app/auth/permissions.py", + "language": "python", + "description": "Role \u2192 permission table", + "dependencies": [] + }, + { + "name": "uploads", + "path": "app/media/uploads.py", + "language": "python", + "description": "S3 multipart upload helper", + "dependencies": [ + "boto3" + ] + }, + { + "name": "webhooks", + "path": "app/integrations/webhooks.go", + "language": "go", + "description": "Outbound webhook dispatcher", + "dependencies": [] + }, + { + "name": "graph_api", + "path": "app/api/graphql.ts", + "language": "typescript", + "description": "GraphQL resolvers \u2014 na\u00efve depth limit", + "dependencies": [ + "graphql" + ] + }, + { + "name": "admin_panel", + "path": "app/admin/views.py", + "language": "python", + "description": "Django admin overrides", + "dependencies": [ + "django" + ] + }, + { + "name": "guard_utils", + "path": "app/common/guard.py", + "language": "python", + "description": "Assorted guard helpers", + "dependencies": [] + }, + { + "name": "cache", + "path": "app/common/cache.py", + "language": "python", + "description": "Memoization wrappers", + "dependencies": [] + }, + { + "name": "migrations", + "path": "db/migrate/2024_add_roles.rb", + "language": "ruby", + "description": "Adds role column", + "dependencies": [] + }, + { + "name": "jwt_tools", + "path": "app/common/jwt_tools.py", + "language": "python", + "description": null, + "dependencies": [ + "pyjwt" + ] + }, + { + "name": "telemetry", + "path": "app/obs/telemetry.go", + "language": "go", + "description": "OTel exporter", + "dependencies": [] + } + ], + "entry_points": [ + { + "kind": "http", + "identifier": "handler_0", + "file_path": "app/entry/e0.py", + "line": 10, + "method": "GET", + "route": null, + "auth_required": true + }, + { + "kind": "HTTP", + "identifier": "handler_1", + "file_path": "app/entry/e1.py", + "line": 17, + "method": "POST", + "route": "/v1/resource/1", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "handler_2", + "file_path": "app/entry/e2.py", + "line": 24, + "method": null, + "route": "/v1/resource/2", + "auth_required": null + }, + { + "kind": "api", + "identifier": "handler_3", + "file_path": "app/entry/e3.py", + "line": 31, + "method": "PUT", + "route": null, + "auth_required": true + }, + { + "kind": "graphql", + "identifier": "handler_4", + "file_path": "app/entry/e4.py", + "line": 38, + "method": "GET", + "route": "/v1/resource/4", + "auth_required": false + }, + { + "kind": "rpc", + "identifier": "handler_5", + "file_path": "app/entry/e5.py", + "line": 45, + "method": "POST", + "route": "/v1/resource/5", + "auth_required": null + }, + { + "kind": "route", + "identifier": "handler_6", + "file_path": "app/entry/e6.py", + "line": 52, + "method": null, + "route": null, + "auth_required": true + }, + { + "kind": "cron", + "identifier": "handler_7", + "file_path": "app/entry/e7.py", + "line": 59, + "method": "PUT", + "route": "/v1/resource/7", + "auth_required": false + }, + { + "kind": "queue", + "identifier": "handler_8", + "file_path": "app/entry/e8.py", + "line": 66, + "method": "GET", + "route": "/v1/resource/8", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_9", + "file_path": "app/entry/e9.py", + "line": 73, + "method": "POST", + "route": null, + "auth_required": true + }, + { + "kind": "api", + "identifier": "handler_10", + "file_path": "app/entry/e10.py", + "line": 80, + "method": null, + "route": "/v1/resource/10", + "auth_required": false + }, + { + "kind": "grpc", + "identifier": "handler_11", + "file_path": "app/entry/e11.py", + "line": 87, + "method": "PUT", + "route": "/v1/resource/11", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_12", + "file_path": "app/entry/e12.py", + "line": 94, + "method": "GET", + "route": null, + "auth_required": true + }, + { + "kind": "route", + "identifier": "handler_13", + "file_path": "app/entry/e13.py", + "line": 101, + "method": "POST", + "route": "/v1/resource/13", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "handler_14", + "file_path": "app/entry/e14.py", + "line": 108, + "method": null, + "route": "/v1/resource/14", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_15", + "file_path": "app/entry/e15.py", + "line": 115, + "method": "PUT", + "route": null, + "auth_required": true + }, + { + "kind": "websocket", + "identifier": "handler_16", + "file_path": "app/entry/e16.py", + "line": 122, + "method": "GET", + "route": "/v1/resource/16", + "auth_required": false + } + ], + "trust_boundaries": [ + { + "name": "boundary_0", + "source_zone": "internet", + "target_zone": "app", + "description": "Transition 0", + "enforcement": [] + }, + { + "name": "boundary_1", + "source_zone": "dmz", + "target_zone": "db", + "description": "Transition 1", + "enforcement": [ + "waf_1", + "mtls_1" + ] + }, + { + "name": "boundary_2", + "source_zone": "vpc", + "target_zone": "cache", + "description": "Transition 2", + "enforcement": [ + "waf_2", + "mtls_2" + ] + }, + { + "name": "boundary_3", + "source_zone": "worker", + "target_zone": "queue", + "description": "Transition 3", + "enforcement": [ + "waf_3", + "mtls_3" + ] + }, + { + "name": "boundary_4", + "source_zone": "internet", + "target_zone": "app", + "description": "Transition 4", + "enforcement": [] + }, + { + "name": "boundary_5", + "source_zone": "dmz", + "target_zone": "db", + "description": "Transition 5", + "enforcement": [ + "waf_5", + "mtls_5" + ] + }, + { + "name": "boundary_6", + "source_zone": "vpc", + "target_zone": "cache", + "description": "Transition 6", + "enforcement": [ + "waf_6", + "mtls_6" + ] + }, + { + "name": "boundary_7", + "source_zone": "worker", + "target_zone": "queue", + "description": "Transition 7", + "enforcement": [ + "waf_7", + "mtls_7" + ] + }, + { + "name": "boundary_8", + "source_zone": "internet", + "target_zone": "app", + "description": "Transition 8", + "enforcement": [] + }, + { + "name": "boundary_9", + "source_zone": "dmz", + "target_zone": "db", + "description": "Transition 9", + "enforcement": [ + "waf_9", + "mtls_9" + ] + }, + { + "name": "boundary_10", + "source_zone": "vpc", + "target_zone": "cache", + "description": "Transition 10", + "enforcement": [ + "waf_10", + "mtls_10" + ] + }, + { + "name": "boundary_11", + "source_zone": "worker", + "target_zone": "queue", + "description": "Transition 11", + "enforcement": [ + "waf_11", + "mtls_11" + ] + } + ], + "services": [ + { + "name": "postgres", + "service_type": "database", + "endpoint": "postgres://db:5432", + "purpose": "primary store", + "auth_mechanism": "password" + }, + { + "name": "redis", + "service_type": "cache", + "endpoint": null, + "purpose": "sessions", + "auth_mechanism": null + }, + { + "name": "stripe", + "service_type": "payments", + "endpoint": "https://api.stripe.com", + "purpose": null, + "auth_mechanism": "api_key" + }, + { + "name": "s3", + "service_type": "object_store", + "endpoint": "https://s3.amazonaws.com", + "purpose": "uploads", + "auth_mechanism": "iam" + }, + { + "name": "smtp", + "service_type": "mail", + "endpoint": null, + "purpose": "transactional email", + "auth_mechanism": null + } + ], + "api_surface": [ + { + "method": "GET", + "path": "/api/v1/thing/0", + "handler": "ThingController.action0", + "file_path": "app/api/thing_0.py", + "line": 100, + "auth_required": true, + "rate_limited": null + }, + { + "method": "POST", + "path": "/api/v1/thing/1", + "handler": "ThingController.action1", + "file_path": "app/api/thing_1.py", + "line": 103, + "auth_required": false, + "rate_limited": false + }, + { + "method": "PUT", + "path": "/api/v1/thing/2", + "handler": "ThingController.action2", + "file_path": "app/api/thing_2.py", + "line": 106, + "auth_required": null, + "rate_limited": true + }, + { + "method": "DELETE", + "path": "/api/v1/thing/3", + "handler": "ThingController.action3", + "file_path": "app/api/thing_3.py", + "line": 109, + "auth_required": false, + "rate_limited": false + }, + { + "method": "PATCH", + "path": "/api/v1/thing/4", + "handler": "ThingController.action4", + "file_path": "app/api/thing_4.py", + "line": 112, + "auth_required": true, + "rate_limited": null + }, + { + "method": "GET", + "path": "/api/v1/thing/5", + "handler": "ThingController.action5", + "file_path": "app/api/thing_5.py", + "line": 115, + "auth_required": null, + "rate_limited": true + }, + { + "method": "POST", + "path": "/api/v1/thing/6", + "handler": "ThingController.action6", + "file_path": "app/api/thing_6.py", + "line": 118, + "auth_required": true, + "rate_limited": null + }, + { + "method": "PUT", + "path": "/api/v1/thing/7", + "handler": "ThingController.action7", + "file_path": "app/api/thing_7.py", + "line": 121, + "auth_required": false, + "rate_limited": false + }, + { + "method": "DELETE", + "path": "/api/v1/thing/8", + "handler": "ThingController.action8", + "file_path": "app/api/thing_8.py", + "line": 124, + "auth_required": null, + "rate_limited": true + }, + { + "method": "PATCH", + "path": "/api/v1/thing/9", + "handler": "ThingController.action9", + "file_path": "app/api/thing_9.py", + "line": 127, + "auth_required": false, + "rate_limited": false + }, + { + "method": "GET", + "path": "/api/v1/thing/10", + "handler": "ThingController.action10", + "file_path": "app/api/thing_10.py", + "line": 130, + "auth_required": true, + "rate_limited": null + }, + { + "method": "POST", + "path": "/api/v1/thing/11", + "handler": "ThingController.action11", + "file_path": "app/api/thing_11.py", + "line": 133, + "auth_required": null, + "rate_limited": true + }, + { + "method": "PUT", + "path": "/api/v1/thing/12", + "handler": "ThingController.action12", + "file_path": "app/api/thing_12.py", + "line": 136, + "auth_required": true, + "rate_limited": null + }, + { + "method": "DELETE", + "path": "/api/v1/thing/13", + "handler": "ThingController.action13", + "file_path": "app/api/thing_13.py", + "line": 139, + "auth_required": false, + "rate_limited": false + }, + { + "method": "PATCH", + "path": "/api/v1/thing/14", + "handler": "ThingController.action14", + "file_path": "app/api/thing_14.py", + "line": 142, + "auth_required": null, + "rate_limited": true + }, + { + "method": "GET", + "path": "/api/v1/thing/15", + "handler": "ThingController.action15", + "file_path": "app/api/thing_15.py", + "line": 145, + "auth_required": false, + "rate_limited": false + }, + { + "method": "POST", + "path": "/api/v1/thing/16", + "handler": "ThingController.action16", + "file_path": "app/api/thing_16.py", + "line": 148, + "auth_required": true, + "rate_limited": null + }, + { + "method": "PUT", + "path": "/api/v1/thing/17", + "handler": "ThingController.action17", + "file_path": "app/api/thing_17.py", + "line": 151, + "auth_required": null, + "rate_limited": true + }, + { + "method": "DELETE", + "path": "/api/v1/thing/18", + "handler": "ThingController.action18", + "file_path": "app/api/thing_18.py", + "line": 154, + "auth_required": true, + "rate_limited": null + }, + { + "method": "PATCH", + "path": "/api/v1/thing/19", + "handler": "ThingController.action19", + "file_path": "app/api/thing_19.py", + "line": 157, + "auth_required": false, + "rate_limited": false + } + ] + }, + "config": { + "secrets": [ + { + "id": "secret-00", + "secret_type": "aws_access_key", + "file_path": "config/env_0.yaml", + "line": 3, + "match": "AKIA****0000", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-01", + "secret_type": "github_token", + "file_path": "config/env_1.yaml", + "line": 4, + "match": "AKIA****0001", + "confidence": "medium", + "is_test_value": true + }, + { + "id": "secret-02", + "secret_type": "private_key", + "file_path": "config/env_2.yaml", + "line": 5, + "match": "AKIA****0002", + "confidence": "low", + "is_test_value": null + }, + { + "id": "secret-03", + "secret_type": "slack_webhook", + "file_path": "config/env_3.yaml", + "line": 6, + "match": "AKIA****0003", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-04", + "secret_type": "generic_api_key", + "file_path": "config/env_4.yaml", + "line": 7, + "match": "AKIA****0004", + "confidence": "medium", + "is_test_value": true + }, + { + "id": "secret-05", + "secret_type": "aws_access_key", + "file_path": "config/env_5.yaml", + "line": 8, + "match": "AKIA****0005", + "confidence": "low", + "is_test_value": null + }, + { + "id": "secret-06", + "secret_type": "github_token", + "file_path": "config/env_6.yaml", + "line": 9, + "match": "AKIA****0006", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-07", + "secret_type": "private_key", + "file_path": "config/env_7.yaml", + "line": 10, + "match": "AKIA****0007", + "confidence": "medium", + "is_test_value": true + }, + { + "id": "secret-08", + "secret_type": "slack_webhook", + "file_path": "config/env_8.yaml", + "line": 11, + "match": "AKIA****0008", + "confidence": "low", + "is_test_value": null + }, + { + "id": "secret-09", + "secret_type": "generic_api_key", + "file_path": "config/env_9.yaml", + "line": 12, + "match": "AKIA****0009", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-10", + "secret_type": "aws_access_key", + "file_path": "config/env_10.yaml", + "line": 13, + "match": "AKIA****0010", + "confidence": "medium", + "is_test_value": true + }, + { + "id": "secret-11", + "secret_type": "github_token", + "file_path": "config/env_11.yaml", + "line": 14, + "match": "AKIA****0011", + "confidence": "low", + "is_test_value": null + }, + { + "id": "secret-12", + "secret_type": "private_key", + "file_path": "config/env_12.yaml", + "line": 15, + "match": "AKIA****0012", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-13", + "secret_type": "slack_webhook", + "file_path": "config/env_13.yaml", + "line": 16, + "match": "AKIA****0013", + "confidence": "medium", + "is_test_value": true + }, + { + "id": "secret-14", + "secret_type": "generic_api_key", + "file_path": "config/env_14.yaml", + "line": 17, + "match": "AKIA****0014", + "confidence": "low", + "is_test_value": null + }, + { + "id": "secret-15", + "secret_type": "aws_access_key", + "file_path": "config/env_15.yaml", + "line": 18, + "match": "AKIA****0015", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-16", + "secret_type": "github_token", + "file_path": "config/env_16.yaml", + "line": 19, + "match": "AKIA****0016", + "confidence": "medium", + "is_test_value": true + } + ], + "misconfigs": [ + { + "id": "misconfig-00", + "category": "logging", + "file_path": "config/app_0.ini", + "line": null, + "key": "LOG_LEVEL", + "value": "DEBUG", + "risk": "high", + "remediation": "Debug logging in production" + }, + { + "id": "misconfig-01", + "category": "tls", + "file_path": "config/app_1.ini", + "line": 13, + "key": "SSL_VERIFY", + "value": "false", + "risk": "critical", + "remediation": "TLS verification disabled" + }, + { + "id": "misconfig-02", + "category": "cors", + "file_path": "config/app_2.ini", + "line": 14, + "key": "ALLOW_ORIGIN", + "value": "*", + "risk": "high", + "remediation": null + }, + { + "id": "misconfig-03", + "category": "headers", + "file_path": "config/app_3.ini", + "line": 0, + "key": "X_FRAME_OPTIONS", + "value": null, + "risk": "medium", + "remediation": "Missing clickjacking header" + }, + { + "id": "misconfig-04", + "category": "debug", + "file_path": "config/app_4.ini", + "line": 16, + "key": "DJANGO_DEBUG", + "value": "True", + "risk": "critical", + "remediation": "Debug mode enabled" + }, + { + "id": "misconfig-05", + "category": "storage", + "file_path": "config/app_5.ini", + "line": null, + "key": "BUCKET_ACL", + "value": "public-read", + "risk": "critical", + "remediation": "Public bucket" + }, + { + "id": "misconfig-06", + "category": "auth", + "file_path": "config/app_6.ini", + "line": 18, + "key": "SESSION_TIMEOUT", + "value": "999999", + "risk": "medium", + "remediation": null + }, + { + "id": "misconfig-07", + "category": "http", + "file_path": "config/app_7.ini", + "line": 19, + "key": "REDIRECT_HTTPS", + "value": "false", + "risk": "high", + "remediation": "Plain HTTP allowed" + }, + { + "id": "misconfig-08", + "category": "secrets", + "file_path": "config/app_8.ini", + "line": 20, + "key": null, + "value": "inline", + "risk": "medium", + "remediation": "Inline secret" + }, + { + "id": "misconfig-09", + "category": "trace", + "file_path": "config/app_9.ini", + "line": 21, + "key": "OTEL_TRACE_ALL", + "value": "true", + "risk": "low", + "remediation": "Verbose tracing" + }, + { + "id": "misconfig-10", + "category": "network", + "file_path": "config/app_10.ini", + "line": null, + "key": "BIND_ADDR", + "value": "0.0.0.0", + "risk": "medium", + "remediation": null + }, + { + "id": "misconfig-11", + "category": "errors", + "file_path": "config/app_11.ini", + "line": 23, + "key": "SHOW_STACKTRACE", + "value": "true", + "risk": "high", + "remediation": "Error stacktraces exposed" + }, + { + "id": "misconfig-12", + "category": "cache", + "file_path": "config/app_12.ini", + "line": 24, + "key": "CACHE_TTL", + "value": "0", + "risk": "low", + "remediation": null + }, + { + "id": "misconfig-13", + "category": "db", + "file_path": "config/app_13.ini", + "line": 25, + "key": "SSLMODE", + "value": "disable", + "risk": "critical", + "remediation": "Database TLS off" + }, + { + "id": "misconfig-14", + "category": "queue", + "file_path": "config/app_14.ini", + "line": 26, + "key": "PREFETCH", + "value": "1000", + "risk": "low", + "remediation": null + }, + { + "id": "misconfig-15", + "category": "exposure", + "file_path": "config/app_15.ini", + "line": null, + "key": "ADMIN_PATH", + "value": "/admin", + "risk": "medium", + "remediation": "Admin surface exposed" + } + ] + }, + "file_count": 2417, + "frameworks": [ + "Django", + "next.js", + "Spring Boot", + " React ", + "unknown-fw", + "NEXT", + "Vue" + ], + "languages": [ + "Python", + "JavaScript", + "Go", + "Ruby", + "Rust", + "python" + ], + "lines_of_code": 184213 +} \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/prune_crypto.json b/go/internal/recontext/testdata/golden/prune_crypto.json new file mode 100644 index 0000000..672f300 --- /dev/null +++ b/go/internal/recontext/testdata/golden/prune_crypto.json @@ -0,0 +1,166 @@ +{ + "file_count": 2417, + "frameworks": [ + "Django", + "next.js", + "Spring Boot", + " React ", + "unknown-fw", + "NEXT", + "Vue" + ], + "languages": [ + "Python", + "JavaScript", + "Go", + "Ruby", + "Rust", + "python" + ], + "lines_of_code": 184213, + "security_context": { + "auth_model": "jwt", + "auth_details": "HS256 JWTs issued by app/auth/service.py, refresh tokens in Redis", + "crypto_usage": [ + { + "algorithm": "AES", + "key_size": 256, + "mode": "GCM", + "usage_context": "at-rest encryption", + "is_weak": false + }, + { + "algorithm": "MD5", + "key_size": null, + "mode": null, + "usage_context": "password hashing", + "is_weak": true + }, + { + "algorithm": "RSA", + "key_size": 1024, + "mode": null, + "usage_context": "token signing", + "is_weak": true + }, + { + "algorithm": "SHA-256", + "key_size": null, + "mode": null, + "usage_context": null, + "is_weak": false + }, + { + "algorithm": "DES", + "key_size": 56, + "mode": "CBC", + "usage_context": "legacy export", + "is_weak": true + }, + { + "algorithm": "HMAC-SHA1", + "key_size": 160, + "mode": null, + "usage_context": "webhook signature", + "is_weak": true + }, + { + "algorithm": "ChaCha20", + "key_size": 256, + "mode": "Poly1305", + "usage_context": "transport", + "is_weak": false + }, + { + "algorithm": "bcrypt", + "key_size": null, + "mode": null, + "usage_context": "password hashing", + "is_weak": false + }, + { + "algorithm": "RC4", + "key_size": 128, + "mode": null, + "usage_context": null, + "is_weak": true + }, + { + "algorithm": "ECDSA", + "key_size": 256, + "mode": null, + "usage_context": "JWT signing", + "is_weak": null + }, + { + "algorithm": "PBKDF2", + "key_size": null, + "mode": null, + "usage_context": "key derivation", + "is_weak": false + }, + { + "algorithm": "SHA-1", + "key_size": null, + "mode": null, + "usage_context": "checksum", + "is_weak": true + }, + { + "algorithm": "AES", + "key_size": 128, + "mode": "ECB", + "usage_context": "legacy blob", + "is_weak": true + }, + { + "algorithm": "Ed25519", + "key_size": 256, + "mode": null, + "usage_context": "package signing", + "is_weak": false + }, + { + "algorithm": "3DES", + "key_size": 168, + "mode": "CBC", + "usage_context": "legacy tape", + "is_weak": true + }, + { + "algorithm": "Argon2id", + "key_size": null, + "mode": null, + "usage_context": "password hashing", + "is_weak": null + } + ], + "framework_security": [ + "django.middleware.csrf.CsrfViewMiddleware", + "", + "helmet defaults", + "SecurityMiddleware", + "rack-protection", + "spring-security filter chain" + ], + "security_headers": [ + "Content-Security-Policy: default-src 'self'", + "X-Content-Type-Options: nosniff", + "Strict-Transport-Security: max-age=31536000", + "X-Frame-Options: DENY", + "Referrer-Policy: no-referrer", + "", + "Permissions-Policy: geolocation=()" + ], + "deployment_signals": [ + "kubernetes ingress with TLS termination", + "docker-compose exposes 5432", + "no WAF in front of /api", + "secrets mounted from vault", + "readiness probe on /healthz", + "single replica for the worker", + "TLS 1.2 minimum", + "internal service mesh mTLS" + ] + } +} \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/prune_injection.json b/go/internal/recontext/testdata/golden/prune_injection.json new file mode 100644 index 0000000..b6b32a5 --- /dev/null +++ b/go/internal/recontext/testdata/golden/prune_injection.json @@ -0,0 +1,1509 @@ +{ + "architecture": { + "app_type": "web_api", + "modules": [ + { + "name": "auth_service", + "path": "app/auth/service.py", + "language": "python", + "description": "Session and JWT issuance", + "dependencies": [ + "jwt", + "redis" + ] + }, + { + "name": "rbac", + "path": "app/auth/rbac.py", + "language": "python", + "description": null, + "dependencies": [] + }, + { + "name": "billing", + "path": "app/billing/core.py", + "language": "python", + "description": "Invoice state machine \u2014 handles refunds", + "dependencies": [ + "stripe" + ] + }, + { + "name": "csrf_guard", + "path": "app/web/middleware/csrf.js", + "language": "javascript", + "description": "CSRF middleware", + "dependencies": [] + }, + { + "name": "payments", + "path": "app/billing/payments.go", + "language": "go", + "description": "Charge orchestration", + "dependencies": [ + "stripe-go" + ] + }, + { + "name": "reporting", + "path": "app/reports/render.py", + "language": "python", + "description": "PDF and CSV export", + "dependencies": [ + "weasyprint" + ] + }, + { + "name": "search", + "path": "app/search/index.ts", + "language": "typescript", + "description": "Elasticsearch query builder", + "dependencies": [ + "@elastic/elasticsearch" + ] + }, + { + "name": "session_store", + "path": "app/auth/session_store.py", + "language": "python", + "description": "Redis-backed sessions", + "dependencies": [ + "redis" + ] + }, + { + "name": "notifications", + "path": "app/notify/mailer.rb", + "language": "ruby", + "description": null, + "dependencies": [ + "mail" + ] + }, + { + "name": "permission_matrix", + "path": "app/auth/permissions.py", + "language": "python", + "description": "Role \u2192 permission table", + "dependencies": [] + }, + { + "name": "uploads", + "path": "app/media/uploads.py", + "language": "python", + "description": "S3 multipart upload helper", + "dependencies": [ + "boto3" + ] + }, + { + "name": "webhooks", + "path": "app/integrations/webhooks.go", + "language": "go", + "description": "Outbound webhook dispatcher", + "dependencies": [] + }, + { + "name": "graph_api", + "path": "app/api/graphql.ts", + "language": "typescript", + "description": "GraphQL resolvers \u2014 na\u00efve depth limit", + "dependencies": [ + "graphql" + ] + }, + { + "name": "admin_panel", + "path": "app/admin/views.py", + "language": "python", + "description": "Django admin overrides", + "dependencies": [ + "django" + ] + }, + { + "name": "guard_utils", + "path": "app/common/guard.py", + "language": "python", + "description": "Assorted guard helpers", + "dependencies": [] + }, + { + "name": "cache", + "path": "app/common/cache.py", + "language": "python", + "description": "Memoization wrappers", + "dependencies": [] + }, + { + "name": "migrations", + "path": "db/migrate/2024_add_roles.rb", + "language": "ruby", + "description": "Adds role column", + "dependencies": [] + }, + { + "name": "jwt_tools", + "path": "app/common/jwt_tools.py", + "language": "python", + "description": null, + "dependencies": [ + "pyjwt" + ] + }, + { + "name": "telemetry", + "path": "app/obs/telemetry.go", + "language": "go", + "description": "OTel exporter", + "dependencies": [] + } + ], + "entry_points": [ + { + "kind": "http", + "identifier": "handler_0", + "file_path": "app/entry/e0.py", + "line": 10, + "method": "GET", + "route": null, + "auth_required": true + }, + { + "kind": "HTTP", + "identifier": "handler_1", + "file_path": "app/entry/e1.py", + "line": 17, + "method": "POST", + "route": "/v1/resource/1", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "handler_2", + "file_path": "app/entry/e2.py", + "line": 24, + "method": null, + "route": "/v1/resource/2", + "auth_required": null + }, + { + "kind": "api", + "identifier": "handler_3", + "file_path": "app/entry/e3.py", + "line": 31, + "method": "PUT", + "route": null, + "auth_required": true + }, + { + "kind": "graphql", + "identifier": "handler_4", + "file_path": "app/entry/e4.py", + "line": 38, + "method": "GET", + "route": "/v1/resource/4", + "auth_required": false + }, + { + "kind": "rpc", + "identifier": "handler_5", + "file_path": "app/entry/e5.py", + "line": 45, + "method": "POST", + "route": "/v1/resource/5", + "auth_required": null + }, + { + "kind": "route", + "identifier": "handler_6", + "file_path": "app/entry/e6.py", + "line": 52, + "method": null, + "route": null, + "auth_required": true + }, + { + "kind": "cron", + "identifier": "handler_7", + "file_path": "app/entry/e7.py", + "line": 59, + "method": "PUT", + "route": "/v1/resource/7", + "auth_required": false + }, + { + "kind": "queue", + "identifier": "handler_8", + "file_path": "app/entry/e8.py", + "line": 66, + "method": "GET", + "route": "/v1/resource/8", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_9", + "file_path": "app/entry/e9.py", + "line": 73, + "method": "POST", + "route": null, + "auth_required": true + }, + { + "kind": "api", + "identifier": "handler_10", + "file_path": "app/entry/e10.py", + "line": 80, + "method": null, + "route": "/v1/resource/10", + "auth_required": false + }, + { + "kind": "grpc", + "identifier": "handler_11", + "file_path": "app/entry/e11.py", + "line": 87, + "method": "PUT", + "route": "/v1/resource/11", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_12", + "file_path": "app/entry/e12.py", + "line": 94, + "method": "GET", + "route": null, + "auth_required": true + }, + { + "kind": "route", + "identifier": "handler_13", + "file_path": "app/entry/e13.py", + "line": 101, + "method": "POST", + "route": "/v1/resource/13", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "handler_14", + "file_path": "app/entry/e14.py", + "line": 108, + "method": null, + "route": "/v1/resource/14", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_15", + "file_path": "app/entry/e15.py", + "line": 115, + "method": "PUT", + "route": null, + "auth_required": true + }, + { + "kind": "websocket", + "identifier": "handler_16", + "file_path": "app/entry/e16.py", + "line": 122, + "method": "GET", + "route": "/v1/resource/16", + "auth_required": false + } + ], + "trust_boundaries": [ + { + "name": "boundary_0", + "source_zone": "internet", + "target_zone": "app", + "description": "Transition 0", + "enforcement": [] + }, + { + "name": "boundary_1", + "source_zone": "dmz", + "target_zone": "db", + "description": "Transition 1", + "enforcement": [ + "waf_1", + "mtls_1" + ] + }, + { + "name": "boundary_2", + "source_zone": "vpc", + "target_zone": "cache", + "description": "Transition 2", + "enforcement": [ + "waf_2", + "mtls_2" + ] + }, + { + "name": "boundary_3", + "source_zone": "worker", + "target_zone": "queue", + "description": "Transition 3", + "enforcement": [ + "waf_3", + "mtls_3" + ] + }, + { + "name": "boundary_4", + "source_zone": "internet", + "target_zone": "app", + "description": "Transition 4", + "enforcement": [] + }, + { + "name": "boundary_5", + "source_zone": "dmz", + "target_zone": "db", + "description": "Transition 5", + "enforcement": [ + "waf_5", + "mtls_5" + ] + }, + { + "name": "boundary_6", + "source_zone": "vpc", + "target_zone": "cache", + "description": "Transition 6", + "enforcement": [ + "waf_6", + "mtls_6" + ] + }, + { + "name": "boundary_7", + "source_zone": "worker", + "target_zone": "queue", + "description": "Transition 7", + "enforcement": [ + "waf_7", + "mtls_7" + ] + }, + { + "name": "boundary_8", + "source_zone": "internet", + "target_zone": "app", + "description": "Transition 8", + "enforcement": [] + }, + { + "name": "boundary_9", + "source_zone": "dmz", + "target_zone": "db", + "description": "Transition 9", + "enforcement": [ + "waf_9", + "mtls_9" + ] + }, + { + "name": "boundary_10", + "source_zone": "vpc", + "target_zone": "cache", + "description": "Transition 10", + "enforcement": [ + "waf_10", + "mtls_10" + ] + }, + { + "name": "boundary_11", + "source_zone": "worker", + "target_zone": "queue", + "description": "Transition 11", + "enforcement": [ + "waf_11", + "mtls_11" + ] + } + ], + "services": [ + { + "name": "postgres", + "service_type": "database", + "endpoint": "postgres://db:5432", + "purpose": "primary store", + "auth_mechanism": "password" + }, + { + "name": "redis", + "service_type": "cache", + "endpoint": null, + "purpose": "sessions", + "auth_mechanism": null + }, + { + "name": "stripe", + "service_type": "payments", + "endpoint": "https://api.stripe.com", + "purpose": null, + "auth_mechanism": "api_key" + }, + { + "name": "s3", + "service_type": "object_store", + "endpoint": "https://s3.amazonaws.com", + "purpose": "uploads", + "auth_mechanism": "iam" + }, + { + "name": "smtp", + "service_type": "mail", + "endpoint": null, + "purpose": "transactional email", + "auth_mechanism": null + } + ], + "api_surface": [ + { + "method": "GET", + "path": "/api/v1/thing/0", + "handler": "ThingController.action0", + "file_path": "app/api/thing_0.py", + "line": 100, + "auth_required": true, + "rate_limited": null + }, + { + "method": "POST", + "path": "/api/v1/thing/1", + "handler": "ThingController.action1", + "file_path": "app/api/thing_1.py", + "line": 103, + "auth_required": false, + "rate_limited": false + }, + { + "method": "PUT", + "path": "/api/v1/thing/2", + "handler": "ThingController.action2", + "file_path": "app/api/thing_2.py", + "line": 106, + "auth_required": null, + "rate_limited": true + }, + { + "method": "DELETE", + "path": "/api/v1/thing/3", + "handler": "ThingController.action3", + "file_path": "app/api/thing_3.py", + "line": 109, + "auth_required": false, + "rate_limited": false + }, + { + "method": "PATCH", + "path": "/api/v1/thing/4", + "handler": "ThingController.action4", + "file_path": "app/api/thing_4.py", + "line": 112, + "auth_required": true, + "rate_limited": null + }, + { + "method": "GET", + "path": "/api/v1/thing/5", + "handler": "ThingController.action5", + "file_path": "app/api/thing_5.py", + "line": 115, + "auth_required": null, + "rate_limited": true + }, + { + "method": "POST", + "path": "/api/v1/thing/6", + "handler": "ThingController.action6", + "file_path": "app/api/thing_6.py", + "line": 118, + "auth_required": true, + "rate_limited": null + }, + { + "method": "PUT", + "path": "/api/v1/thing/7", + "handler": "ThingController.action7", + "file_path": "app/api/thing_7.py", + "line": 121, + "auth_required": false, + "rate_limited": false + }, + { + "method": "DELETE", + "path": "/api/v1/thing/8", + "handler": "ThingController.action8", + "file_path": "app/api/thing_8.py", + "line": 124, + "auth_required": null, + "rate_limited": true + }, + { + "method": "PATCH", + "path": "/api/v1/thing/9", + "handler": "ThingController.action9", + "file_path": "app/api/thing_9.py", + "line": 127, + "auth_required": false, + "rate_limited": false + }, + { + "method": "GET", + "path": "/api/v1/thing/10", + "handler": "ThingController.action10", + "file_path": "app/api/thing_10.py", + "line": 130, + "auth_required": true, + "rate_limited": null + }, + { + "method": "POST", + "path": "/api/v1/thing/11", + "handler": "ThingController.action11", + "file_path": "app/api/thing_11.py", + "line": 133, + "auth_required": null, + "rate_limited": true + }, + { + "method": "PUT", + "path": "/api/v1/thing/12", + "handler": "ThingController.action12", + "file_path": "app/api/thing_12.py", + "line": 136, + "auth_required": true, + "rate_limited": null + }, + { + "method": "DELETE", + "path": "/api/v1/thing/13", + "handler": "ThingController.action13", + "file_path": "app/api/thing_13.py", + "line": 139, + "auth_required": false, + "rate_limited": false + }, + { + "method": "PATCH", + "path": "/api/v1/thing/14", + "handler": "ThingController.action14", + "file_path": "app/api/thing_14.py", + "line": 142, + "auth_required": null, + "rate_limited": true + }, + { + "method": "GET", + "path": "/api/v1/thing/15", + "handler": "ThingController.action15", + "file_path": "app/api/thing_15.py", + "line": 145, + "auth_required": false, + "rate_limited": false + }, + { + "method": "POST", + "path": "/api/v1/thing/16", + "handler": "ThingController.action16", + "file_path": "app/api/thing_16.py", + "line": 148, + "auth_required": true, + "rate_limited": null + }, + { + "method": "PUT", + "path": "/api/v1/thing/17", + "handler": "ThingController.action17", + "file_path": "app/api/thing_17.py", + "line": 151, + "auth_required": null, + "rate_limited": true + }, + { + "method": "DELETE", + "path": "/api/v1/thing/18", + "handler": "ThingController.action18", + "file_path": "app/api/thing_18.py", + "line": 154, + "auth_required": true, + "rate_limited": null + }, + { + "method": "PATCH", + "path": "/api/v1/thing/19", + "handler": "ThingController.action19", + "file_path": "app/api/thing_19.py", + "line": 157, + "auth_required": false, + "rate_limited": false + } + ] + }, + "data_flows": { + "flows": [ + { + "source": "request.args['q']", + "path": [ + { + "file_path": "app/search/index.ts", + "line": 20, + "component": "step_0_a", + "operation": "read" + }, + { + "file_path": "app/util/b.py", + "line": 40, + "component": "step_0_b", + "operation": "write" + } + ], + "sink": "cursor.execute", + "sanitized": false, + "files": [ + "app/search/index.ts", + "app/db/raw.py", + "app/util/a.py", + "app/util/b.py" + ] + }, + { + "source": "request.cookies['session']", + "path": [ + { + "file_path": "app/auth/session_store.py", + "line": 21, + "component": "step_1_a", + "operation": "read" + }, + { + "file_path": "app/auth/session_store.py", + "line": 41, + "component": "step_1_b", + "operation": "write" + } + ], + "sink": "redis.set", + "sanitized": true, + "files": [ + "app/auth/session_store.py" + ] + }, + { + "source": "request.json['url']", + "path": [ + { + "file_path": "app/integrations/webhooks.go", + "line": 22, + "component": "step_2_a", + "operation": "read" + }, + { + "file_path": "app/net/client.go", + "line": 42, + "component": "step_2_b", + "operation": "write" + } + ], + "sink": "requests.get", + "sanitized": false, + "files": [ + "app/integrations/webhooks.go", + "app/net/client.go" + ] + }, + { + "source": "form['password']", + "path": [ + { + "file_path": "app/auth/service.py", + "line": 23, + "component": "step_3_a", + "operation": "read" + }, + { + "file_path": "app/obs/telemetry.go", + "line": 43, + "component": "step_3_b", + "operation": "write" + } + ], + "sink": "logger.info", + "sanitized": false, + "files": [ + "app/auth/service.py", + "app/obs/telemetry.go" + ] + }, + { + "source": "header['Authorization']", + "path": [ + { + "file_path": "app/common/jwt_tools.py", + "line": 24, + "component": "step_4_a", + "operation": "read" + }, + { + "file_path": "app/common/jwt_tools.py", + "line": 44, + "component": "step_4_b", + "operation": "write" + } + ], + "sink": "jwt.decode", + "sanitized": true, + "files": [ + "app/common/jwt_tools.py" + ] + }, + { + "source": "query['email']", + "path": [ + { + "file_path": "app/notify/mailer.rb", + "line": 25, + "component": "step_5_a", + "operation": "read" + }, + { + "file_path": "app/notify/mailer.rb", + "line": 45, + "component": "step_5_b", + "operation": "write" + } + ], + "sink": "smtp.send", + "sanitized": false, + "files": [ + "app/notify/mailer.rb" + ] + }, + { + "source": "body['card_number']", + "path": [ + { + "file_path": "app/billing/payments.go", + "line": 26, + "component": "step_6_a", + "operation": "read" + }, + { + "file_path": "app/billing/core.py", + "line": 46, + "component": "step_6_b", + "operation": "write" + } + ], + "sink": "stripe.Charge.create", + "sanitized": true, + "files": [ + "app/billing/payments.go", + "app/billing/core.py" + ] + }, + { + "source": "path_param['id']", + "path": [ + { + "file_path": "app/api/thing_1.py", + "line": 27, + "component": "step_7_a", + "operation": "read" + }, + { + "file_path": "app/api/thing_1.py", + "line": 47, + "component": "step_7_b", + "operation": "write" + } + ], + "sink": "orm.filter", + "sanitized": true, + "files": [ + "app/api/thing_1.py" + ] + }, + { + "source": "upload.filename", + "path": [ + { + "file_path": "app/media/uploads.py", + "line": 28, + "component": "step_8_a", + "operation": "read" + }, + { + "file_path": "app/x/w.py", + "line": 48, + "component": "step_8_b", + "operation": "write" + } + ], + "sink": "open", + "sanitized": false, + "files": [ + "app/media/uploads.py", + "app/common/cache.py", + "app/common/guard.py", + "app/x/y.py", + "app/x/z.py", + "app/x/w.py" + ] + }, + { + "source": "env['DEBUG']", + "path": [ + { + "file_path": "app/web/render.py", + "line": 29, + "component": "step_9_a", + "operation": "read" + }, + { + "file_path": "app/web/render.py", + "line": 49, + "component": "step_9_b", + "operation": "write" + } + ], + "sink": "template.render", + "sanitized": false, + "files": [ + "app/web/render.py" + ] + }, + { + "source": "request.body", + "path": [ + { + "file_path": "app/admin/views.py", + "line": 30, + "component": "step_10_a", + "operation": "read" + }, + { + "file_path": "app/admin/views.py", + "line": 50, + "component": "step_10_b", + "operation": "write" + } + ], + "sink": "eval", + "sanitized": false, + "files": [ + "app/admin/views.py" + ] + }, + { + "source": "websocket.msg", + "path": [ + { + "file_path": "app/ws/handler.py", + "line": 31, + "component": "step_11_a", + "operation": "read" + }, + { + "file_path": "app/ws/exec.py", + "line": 51, + "component": "step_11_b", + "operation": "write" + } + ], + "sink": "subprocess.run", + "sanitized": false, + "files": [ + "app/ws/handler.py", + "app/ws/exec.py" + ] + }, + { + "source": "cli.argv", + "path": [ + { + "file_path": "tools/run.py", + "line": 32, + "component": "step_12_a", + "operation": "read" + }, + { + "file_path": "tools/run.py", + "line": 52, + "component": "step_12_b", + "operation": "write" + } + ], + "sink": "os.system", + "sanitized": false, + "files": [ + "tools/run.py" + ] + }, + { + "source": "queue.payload", + "path": [ + { + "file_path": "app/worker/task.py", + "line": 33, + "component": "step_13_a", + "operation": "read" + }, + { + "file_path": "app/worker/task.py", + "line": 53, + "component": "step_13_b", + "operation": "write" + } + ], + "sink": "pickle.loads", + "sanitized": false, + "files": [ + "app/worker/task.py" + ] + }, + { + "source": "request.args['redirect']", + "path": [ + { + "file_path": "app/web/redirect.py", + "line": 34, + "component": "step_14_a", + "operation": "read" + }, + { + "file_path": "app/web/redirect.py", + "line": 54, + "component": "step_14_b", + "operation": "write" + } + ], + "sink": "HttpResponseRedirect", + "sanitized": false, + "files": [ + "app/web/redirect.py" + ] + }, + { + "source": "session['role']", + "path": [ + { + "file_path": "app/auth/permissions.py", + "line": 35, + "component": "step_15_a", + "operation": "read" + }, + { + "file_path": "app/auth/rbac.py", + "line": 55, + "component": "step_15_b", + "operation": "write" + } + ], + "sink": "permission_check", + "sanitized": true, + "files": [ + "app/auth/permissions.py", + "app/auth/rbac.py" + ] + }, + { + "source": "request.files['avatar']", + "path": [ + { + "file_path": "app/media/uploads.py", + "line": 36, + "component": "step_16_a", + "operation": "read" + }, + { + "file_path": "app/media/uploads.py", + "line": 56, + "component": "step_16_b", + "operation": "write" + } + ], + "sink": "s3.put_object", + "sanitized": true, + "files": [ + "app/media/uploads.py" + ] + }, + { + "source": "token", + "path": [ + { + "file_path": "app/common/cache.py", + "line": 37, + "component": "step_17_a", + "operation": "read" + }, + { + "file_path": "app/common/cache.py", + "line": 57, + "component": "step_17_b", + "operation": "write" + } + ], + "sink": "cache.set", + "sanitized": true, + "files": [ + "app/common/cache.py" + ] + }, + { + "source": "request.headers['X-User-Phone']", + "path": [ + { + "file_path": "app/obs/audit.py", + "line": 38, + "component": "step_18_a", + "operation": "read" + }, + { + "file_path": "app/obs/audit.py", + "line": 58, + "component": "step_18_b", + "operation": "write" + } + ], + "sink": "audit_log", + "sanitized": false, + "files": [ + "app/obs/audit.py" + ] + }, + { + "source": "graphql.variables", + "path": [ + { + "file_path": "app/api/graphql.ts", + "line": 39, + "component": "step_19_a", + "operation": "read" + }, + { + "file_path": "app/api/graphql.ts", + "line": 59, + "component": "step_19_b", + "operation": "write" + } + ], + "sink": "db.query", + "sanitized": false, + "files": [ + "app/api/graphql.ts" + ] + } + ], + "sanitization_points": [ + { + "file_path": "app/sanitize/s0.py", + "line": 5, + "function_name": null, + "sanitization_type": "escape", + "protects_against": [] + }, + { + "file_path": "app/sanitize/s1.py", + "line": 9, + "function_name": "clean_1", + "sanitization_type": "parameterize", + "protects_against": [ + "CWE-80", + "CWE-90" + ] + }, + { + "file_path": "app/sanitize/s2.py", + "line": 13, + "function_name": "clean_2", + "sanitization_type": "allowlist", + "protects_against": [ + "CWE-81", + "CWE-91" + ] + }, + { + "file_path": "app/sanitize/s3.py", + "line": 17, + "function_name": "clean_3", + "sanitization_type": "encode", + "protects_against": [] + }, + { + "file_path": "app/sanitize/s4.py", + "line": 21, + "function_name": "clean_4", + "sanitization_type": "escape", + "protects_against": [ + "CWE-83", + "CWE-93" + ] + }, + { + "file_path": "app/sanitize/s5.py", + "line": 25, + "function_name": null, + "sanitization_type": "parameterize", + "protects_against": [ + "CWE-84", + "CWE-94" + ] + }, + { + "file_path": "app/sanitize/s6.py", + "line": 29, + "function_name": "clean_6", + "sanitization_type": "allowlist", + "protects_against": [] + }, + { + "file_path": "app/sanitize/s7.py", + "line": 33, + "function_name": "clean_7", + "sanitization_type": "encode", + "protects_against": [ + "CWE-86", + "CWE-96" + ] + }, + { + "file_path": "app/sanitize/s8.py", + "line": 37, + "function_name": "clean_8", + "sanitization_type": "escape", + "protects_against": [ + "CWE-87", + "CWE-97" + ] + }, + { + "file_path": "app/sanitize/s9.py", + "line": 41, + "function_name": "clean_9", + "sanitization_type": "parameterize", + "protects_against": [] + }, + { + "file_path": "app/sanitize/s10.py", + "line": 45, + "function_name": null, + "sanitization_type": "allowlist", + "protects_against": [ + "CWE-89", + "CWE-99" + ] + }, + { + "file_path": "app/sanitize/s11.py", + "line": 49, + "function_name": "clean_11", + "sanitization_type": "encode", + "protects_against": [ + "CWE-90", + "CWE-100" + ] + } + ], + "sinks": [ + { + "sink_type": "sql", + "file_path": "app/sink/k0.py", + "line": 200, + "function_name": null, + "exploitability_notes": null + }, + { + "sink_type": "command", + "file_path": "app/sink/k1.py", + "line": 202, + "function_name": "sink_fn_1", + "exploitability_notes": "note 1" + }, + { + "sink_type": "template", + "file_path": "app/sink/k2.py", + "line": 204, + "function_name": "sink_fn_2", + "exploitability_notes": "note 2" + }, + { + "sink_type": "file", + "file_path": "app/sink/k3.py", + "line": 206, + "function_name": "sink_fn_3", + "exploitability_notes": null + }, + { + "sink_type": "network", + "file_path": "app/sink/k4.py", + "line": 208, + "function_name": null, + "exploitability_notes": "note 4" + }, + { + "sink_type": "deserialization", + "file_path": "app/sink/k5.py", + "line": 210, + "function_name": "sink_fn_5", + "exploitability_notes": "note 5" + }, + { + "sink_type": "sql", + "file_path": "app/sink/k6.py", + "line": 212, + "function_name": "sink_fn_6", + "exploitability_notes": null + }, + { + "sink_type": "command", + "file_path": "app/sink/k7.py", + "line": 214, + "function_name": "sink_fn_7", + "exploitability_notes": "note 7" + }, + { + "sink_type": "template", + "file_path": "app/sink/k8.py", + "line": 216, + "function_name": null, + "exploitability_notes": "note 8" + }, + { + "sink_type": "file", + "file_path": "app/sink/k9.py", + "line": 218, + "function_name": "sink_fn_9", + "exploitability_notes": null + }, + { + "sink_type": "network", + "file_path": "app/sink/k10.py", + "line": 220, + "function_name": "sink_fn_10", + "exploitability_notes": "note 10" + }, + { + "sink_type": "deserialization", + "file_path": "app/sink/k11.py", + "line": 222, + "function_name": "sink_fn_11", + "exploitability_notes": "note 11" + }, + { + "sink_type": "sql", + "file_path": "app/sink/k12.py", + "line": 224, + "function_name": null, + "exploitability_notes": null + }, + { + "sink_type": "command", + "file_path": "app/sink/k13.py", + "line": 226, + "function_name": "sink_fn_13", + "exploitability_notes": "note 13" + }, + { + "sink_type": "template", + "file_path": "app/sink/k14.py", + "line": 228, + "function_name": "sink_fn_14", + "exploitability_notes": "note 14" + }, + { + "sink_type": "file", + "file_path": "app/sink/k15.py", + "line": 230, + "function_name": "sink_fn_15", + "exploitability_notes": null + }, + { + "sink_type": "network", + "file_path": "app/sink/k16.py", + "line": 232, + "function_name": null, + "exploitability_notes": "note 16" + }, + { + "sink_type": "deserialization", + "file_path": "app/sink/k17.py", + "line": 234, + "function_name": "sink_fn_17", + "exploitability_notes": "note 17" + } + ] + }, + "file_count": 2417, + "frameworks": [ + "Django", + "next.js", + "Spring Boot", + " React ", + "unknown-fw", + "NEXT", + "Vue" + ], + "languages": [ + "Python", + "JavaScript", + "Go", + "Ruby", + "Rust", + "python" + ], + "lines_of_code": 184213, + "security_context": { + "auth_model": "jwt", + "auth_details": "HS256 JWTs issued by app/auth/service.py, refresh tokens in Redis", + "crypto_usage": [ + { + "algorithm": "AES", + "key_size": 256, + "mode": "GCM", + "usage_context": "at-rest encryption", + "is_weak": false + }, + { + "algorithm": "MD5", + "key_size": null, + "mode": null, + "usage_context": "password hashing", + "is_weak": true + }, + { + "algorithm": "RSA", + "key_size": 1024, + "mode": null, + "usage_context": "token signing", + "is_weak": true + }, + { + "algorithm": "SHA-256", + "key_size": null, + "mode": null, + "usage_context": null, + "is_weak": false + }, + { + "algorithm": "DES", + "key_size": 56, + "mode": "CBC", + "usage_context": "legacy export", + "is_weak": true + }, + { + "algorithm": "HMAC-SHA1", + "key_size": 160, + "mode": null, + "usage_context": "webhook signature", + "is_weak": true + }, + { + "algorithm": "ChaCha20", + "key_size": 256, + "mode": "Poly1305", + "usage_context": "transport", + "is_weak": false + }, + { + "algorithm": "bcrypt", + "key_size": null, + "mode": null, + "usage_context": "password hashing", + "is_weak": false + }, + { + "algorithm": "RC4", + "key_size": 128, + "mode": null, + "usage_context": null, + "is_weak": true + }, + { + "algorithm": "ECDSA", + "key_size": 256, + "mode": null, + "usage_context": "JWT signing", + "is_weak": null + }, + { + "algorithm": "PBKDF2", + "key_size": null, + "mode": null, + "usage_context": "key derivation", + "is_weak": false + }, + { + "algorithm": "SHA-1", + "key_size": null, + "mode": null, + "usage_context": "checksum", + "is_weak": true + }, + { + "algorithm": "AES", + "key_size": 128, + "mode": "ECB", + "usage_context": "legacy blob", + "is_weak": true + }, + { + "algorithm": "Ed25519", + "key_size": 256, + "mode": null, + "usage_context": "package signing", + "is_weak": false + }, + { + "algorithm": "3DES", + "key_size": 168, + "mode": "CBC", + "usage_context": "legacy tape", + "is_weak": true + }, + { + "algorithm": "Argon2id", + "key_size": null, + "mode": null, + "usage_context": "password hashing", + "is_weak": null + } + ], + "framework_security": [ + "django.middleware.csrf.CsrfViewMiddleware", + "", + "helmet defaults", + "SecurityMiddleware", + "rack-protection", + "spring-security filter chain" + ], + "security_headers": [ + "Content-Security-Policy: default-src 'self'", + "X-Content-Type-Options: nosniff", + "Strict-Transport-Security: max-age=31536000", + "X-Frame-Options: DENY", + "Referrer-Policy: no-referrer", + "", + "Permissions-Policy: geolocation=()" + ], + "deployment_signals": [ + "kubernetes ingress with TLS termination", + "docker-compose exposes 5432", + "no WAF in front of /api", + "secrets mounted from vault", + "readiness probe on /healthz", + "single replica for the worker", + "TLS 1.2 minimum", + "internal service mesh mTLS" + ] + } +} \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/prune_keys.json b/go/internal/recontext/testdata/golden/prune_keys.json new file mode 100644 index 0000000..bd33641 --- /dev/null +++ b/go/internal/recontext/testdata/golden/prune_keys.json @@ -0,0 +1,142 @@ +{ + "": [ + "architecture", + "config", + "data_flows", + "dependencies", + "file_count", + "frameworks", + "languages", + "lines_of_code", + "recon_duration_seconds", + "security_context" + ], + "api_security": [ + "architecture", + "data_flows", + "file_count", + "frameworks", + "languages", + "lines_of_code", + "security_context" + ], + "auth": [ + "architecture", + "file_count", + "frameworks", + "languages", + "lines_of_code", + "security_context" + ], + "business_logic": [ + "architecture", + "data_flows", + "file_count", + "frameworks", + "languages", + "lines_of_code", + "security_context" + ], + "config_secrets": [ + "architecture", + "config", + "file_count", + "frameworks", + "languages", + "lines_of_code" + ], + "crypto": [ + "file_count", + "frameworks", + "languages", + "lines_of_code", + "security_context" + ], + "data_exposure": [ + "architecture", + "config", + "data_flows", + "file_count", + "frameworks", + "languages", + "lines_of_code" + ], + "dos": [ + "architecture", + "data_flows", + "file_count", + "frameworks", + "languages", + "lines_of_code" + ], + "injection": [ + "architecture", + "data_flows", + "file_count", + "frameworks", + "languages", + "lines_of_code", + "security_context" + ], + "javascript_specific": [ + "architecture", + "config", + "data_flows", + "dependencies", + "file_count", + "frameworks", + "languages", + "lines_of_code", + "recon_duration_seconds", + "security_context" + ], + "python_specific": [ + "architecture", + "config", + "data_flows", + "dependencies", + "file_count", + "frameworks", + "languages", + "lines_of_code", + "recon_duration_seconds", + "security_context" + ], + "ssrf": [ + "architecture", + "data_flows", + "file_count", + "frameworks", + "languages", + "lines_of_code", + "security_context" + ], + "supply_chain": [ + "dependencies", + "file_count", + "frameworks", + "languages", + "lines_of_code" + ], + "unknown_strategy": [ + "architecture", + "config", + "data_flows", + "dependencies", + "file_count", + "frameworks", + "languages", + "lines_of_code", + "recon_duration_seconds", + "security_context" + ], + "xss": [ + "architecture", + "data_flows", + "file_count", + "frameworks", + "languages", + "lines_of_code", + "security_context" + ] +} diff --git a/go/internal/recontext/testdata/golden/prune_supply_chain.json b/go/internal/recontext/testdata/golden/prune_supply_chain.json new file mode 100644 index 0000000..6f56f26 --- /dev/null +++ b/go/internal/recontext/testdata/golden/prune_supply_chain.json @@ -0,0 +1,381 @@ +{ + "dependencies": { + "sbom": [ + { + "name": "django", + "version": "4.2.1", + "ecosystem": "pypi", + "direct": true, + "license": "BSD-3-Clause" + }, + { + "name": "requests", + "version": "2.31.0", + "ecosystem": "pypi", + "direct": true, + "license": "Apache-2.0" + }, + { + "name": "urllib3", + "version": "1.26.5", + "ecosystem": "pypi", + "direct": false, + "license": "MIT" + }, + { + "name": "jinja2", + "version": "3.1.2", + "ecosystem": "pypi", + "direct": false, + "license": null + }, + { + "name": "lodash", + "version": "4.17.19", + "ecosystem": "npm", + "direct": true, + "license": "MIT" + }, + { + "name": "express", + "version": "4.17.1", + "ecosystem": "npm", + "direct": true, + "license": "MIT" + }, + { + "name": "minimist", + "version": "1.2.5", + "ecosystem": "npm", + "direct": false, + "license": "MIT" + }, + { + "name": "axios", + "version": "0.21.1", + "ecosystem": "npm", + "direct": false, + "license": "MIT" + }, + { + "name": "github.com/gin-gonic/gin", + "version": "1.9.0", + "ecosystem": "go", + "direct": true, + "license": "MIT" + }, + { + "name": "golang.org/x/net", + "version": "0.7.0", + "ecosystem": "go", + "direct": false, + "license": "BSD-3-Clause" + }, + { + "name": "rails", + "version": "7.0.4", + "ecosystem": "rubygems", + "direct": true, + "license": "MIT" + }, + { + "name": "nokogiri", + "version": "1.13.6", + "ecosystem": "rubygems", + "direct": false, + "license": "MIT" + }, + { + "name": "django", + "version": "4.2.1", + "ecosystem": "pypi", + "direct": true, + "license": "BSD-3-Clause" + }, + { + "name": "requests", + "version": "2.31.0", + "ecosystem": "pypi", + "direct": true, + "license": "Apache-2.0" + } + ], + "known_cves": [ + { + "cve_id": "CVE-2023-0001", + "package": "django", + "installed_version": "4.2.1", + "fixed_version": "4.2.5", + "cvss_v4_score": 9.8, + "epss_score": 0.5, + "direct": true, + "reachable": true + }, + { + "cve_id": "CVE-2023-0002", + "package": "urllib3", + "installed_version": "1.26.5", + "fixed_version": null, + "cvss_v4_score": 7.5, + "epss_score": 0.1, + "direct": false, + "reachable": false + }, + { + "cve_id": "CVE-2023-0003", + "package": "lodash", + "installed_version": "4.17.19", + "fixed_version": "4.17.21", + "cvss_v4_score": 9.8, + "epss_score": 0.9, + "direct": true, + "reachable": true + }, + { + "cve_id": "CVE-2023-0004", + "package": "minimist", + "installed_version": "1.2.5", + "fixed_version": "1.2.6", + "cvss_v4_score": null, + "epss_score": null, + "direct": false, + "reachable": null + }, + { + "cve_id": "CVE-2023-0005", + "package": "axios", + "installed_version": "0.21.1", + "fixed_version": "0.21.2", + "cvss_v4_score": 5.3, + "epss_score": 0.02, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0006", + "package": "jinja2", + "installed_version": "3.1.2", + "fixed_version": null, + "cvss_v4_score": 6.1, + "epss_score": null, + "direct": false, + "reachable": false + }, + { + "cve_id": "CVE-2023-0007", + "package": "express", + "installed_version": "4.17.1", + "fixed_version": "4.18.0", + "cvss_v4_score": 4.3, + "epss_score": 0.005, + "direct": true, + "reachable": null + }, + { + "cve_id": "CVE-2023-0008", + "package": "nokogiri", + "installed_version": "1.13.6", + "fixed_version": "1.13.10", + "cvss_v4_score": 8.8, + "epss_score": 0.3, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0009", + "package": "golang.org/x/net", + "installed_version": "0.7.0", + "fixed_version": "0.17.0", + "cvss_v4_score": 7.5, + "epss_score": 0.44, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0010", + "package": "rails", + "installed_version": "7.0.4", + "fixed_version": "7.0.7", + "cvss_v4_score": 9.1, + "epss_score": 0.6, + "direct": true, + "reachable": false + }, + { + "cve_id": "CVE-2023-0011", + "package": "gin", + "installed_version": "1.9.0", + "fixed_version": null, + "cvss_v4_score": 3.7, + "epss_score": 0.001, + "direct": true, + "reachable": null + }, + { + "cve_id": "CVE-2023-0012", + "package": "requests", + "installed_version": "2.31.0", + "fixed_version": "2.32.0", + "cvss_v4_score": 6.5, + "epss_score": 0.07, + "direct": true, + "reachable": true + }, + { + "cve_id": "CVE-2023-0013", + "package": "pyyaml", + "installed_version": "5.3", + "fixed_version": "5.4", + "cvss_v4_score": 9.8, + "epss_score": 0.5, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0014", + "package": "log4j", + "installed_version": "2.14.0", + "fixed_version": "2.17.1", + "cvss_v4_score": 10.0, + "epss_score": 0.97, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0015", + "package": "openssl", + "installed_version": "1.1.1k", + "fixed_version": "1.1.1t", + "cvss_v4_score": 7.4, + "epss_score": 0.12, + "direct": false, + "reachable": null + }, + { + "cve_id": "CVE-2023-0016", + "package": "zlib", + "installed_version": "1.2.11", + "fixed_version": "1.2.12", + "cvss_v4_score": 8.2, + "epss_score": 0.04, + "direct": false, + "reachable": false + }, + { + "cve_id": "CVE-2023-0017", + "package": "libxml2", + "installed_version": "2.9.10", + "fixed_version": null, + "cvss_v4_score": null, + "epss_score": 0.25, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0018", + "package": "curl", + "installed_version": "7.68.0", + "fixed_version": "7.88.0", + "cvss_v4_score": 8.1, + "epss_score": null, + "direct": true, + "reachable": true + } + ], + "outdated": [ + { + "package": "pkg-0", + "current_version": "1.0.0", + "latest_version": "2.0.0", + "direct": true + }, + { + "package": "pkg-1", + "current_version": "1.1.0", + "latest_version": "2.1.0", + "direct": false + }, + { + "package": "pkg-2", + "current_version": "1.2.0", + "latest_version": "2.2.0", + "direct": true + }, + { + "package": "pkg-3", + "current_version": "1.3.0", + "latest_version": "2.3.0", + "direct": false + }, + { + "package": "pkg-4", + "current_version": "1.4.0", + "latest_version": "2.4.0", + "direct": true + }, + { + "package": "pkg-5", + "current_version": "1.5.0", + "latest_version": "2.5.0", + "direct": false + }, + { + "package": "pkg-6", + "current_version": "1.6.0", + "latest_version": "2.6.0", + "direct": true + }, + { + "package": "pkg-7", + "current_version": "1.7.0", + "latest_version": "2.7.0", + "direct": false + }, + { + "package": "pkg-8", + "current_version": "1.8.0", + "latest_version": "2.8.0", + "direct": true + }, + { + "package": "pkg-9", + "current_version": "1.9.0", + "latest_version": "2.9.0", + "direct": false + }, + { + "package": "pkg-10", + "current_version": "1.10.0", + "latest_version": "2.10.0", + "direct": true + }, + { + "package": "pkg-11", + "current_version": "1.11.0", + "latest_version": "2.11.0", + "direct": false + } + ], + "direct_count": 7, + "transitive_count": 143 + }, + "file_count": 2417, + "frameworks": [ + "Django", + "next.js", + "Spring Boot", + " React ", + "unknown-fw", + "NEXT", + "Vue" + ], + "languages": [ + "Python", + "JavaScript", + "Go", + "Ruby", + "Rust", + "python" + ], + "lines_of_code": 184213 +} \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/prune_unknown_strategy.json b/go/internal/recontext/testdata/golden/prune_unknown_strategy.json new file mode 100644 index 0000000..576776d --- /dev/null +++ b/go/internal/recontext/testdata/golden/prune_unknown_strategy.json @@ -0,0 +1,2189 @@ +{ + "architecture": { + "app_type": "web_api", + "modules": [ + { + "name": "auth_service", + "path": "app/auth/service.py", + "language": "python", + "description": "Session and JWT issuance", + "dependencies": [ + "jwt", + "redis" + ] + }, + { + "name": "rbac", + "path": "app/auth/rbac.py", + "language": "python", + "description": null, + "dependencies": [] + }, + { + "name": "billing", + "path": "app/billing/core.py", + "language": "python", + "description": "Invoice state machine \u2014 handles refunds", + "dependencies": [ + "stripe" + ] + }, + { + "name": "csrf_guard", + "path": "app/web/middleware/csrf.js", + "language": "javascript", + "description": "CSRF middleware", + "dependencies": [] + }, + { + "name": "payments", + "path": "app/billing/payments.go", + "language": "go", + "description": "Charge orchestration", + "dependencies": [ + "stripe-go" + ] + }, + { + "name": "reporting", + "path": "app/reports/render.py", + "language": "python", + "description": "PDF and CSV export", + "dependencies": [ + "weasyprint" + ] + }, + { + "name": "search", + "path": "app/search/index.ts", + "language": "typescript", + "description": "Elasticsearch query builder", + "dependencies": [ + "@elastic/elasticsearch" + ] + }, + { + "name": "session_store", + "path": "app/auth/session_store.py", + "language": "python", + "description": "Redis-backed sessions", + "dependencies": [ + "redis" + ] + }, + { + "name": "notifications", + "path": "app/notify/mailer.rb", + "language": "ruby", + "description": null, + "dependencies": [ + "mail" + ] + }, + { + "name": "permission_matrix", + "path": "app/auth/permissions.py", + "language": "python", + "description": "Role \u2192 permission table", + "dependencies": [] + }, + { + "name": "uploads", + "path": "app/media/uploads.py", + "language": "python", + "description": "S3 multipart upload helper", + "dependencies": [ + "boto3" + ] + }, + { + "name": "webhooks", + "path": "app/integrations/webhooks.go", + "language": "go", + "description": "Outbound webhook dispatcher", + "dependencies": [] + }, + { + "name": "graph_api", + "path": "app/api/graphql.ts", + "language": "typescript", + "description": "GraphQL resolvers \u2014 na\u00efve depth limit", + "dependencies": [ + "graphql" + ] + }, + { + "name": "admin_panel", + "path": "app/admin/views.py", + "language": "python", + "description": "Django admin overrides", + "dependencies": [ + "django" + ] + }, + { + "name": "guard_utils", + "path": "app/common/guard.py", + "language": "python", + "description": "Assorted guard helpers", + "dependencies": [] + }, + { + "name": "cache", + "path": "app/common/cache.py", + "language": "python", + "description": "Memoization wrappers", + "dependencies": [] + }, + { + "name": "migrations", + "path": "db/migrate/2024_add_roles.rb", + "language": "ruby", + "description": "Adds role column", + "dependencies": [] + }, + { + "name": "jwt_tools", + "path": "app/common/jwt_tools.py", + "language": "python", + "description": null, + "dependencies": [ + "pyjwt" + ] + }, + { + "name": "telemetry", + "path": "app/obs/telemetry.go", + "language": "go", + "description": "OTel exporter", + "dependencies": [] + } + ], + "entry_points": [ + { + "kind": "http", + "identifier": "handler_0", + "file_path": "app/entry/e0.py", + "line": 10, + "method": "GET", + "route": null, + "auth_required": true + }, + { + "kind": "HTTP", + "identifier": "handler_1", + "file_path": "app/entry/e1.py", + "line": 17, + "method": "POST", + "route": "/v1/resource/1", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "handler_2", + "file_path": "app/entry/e2.py", + "line": 24, + "method": null, + "route": "/v1/resource/2", + "auth_required": null + }, + { + "kind": "api", + "identifier": "handler_3", + "file_path": "app/entry/e3.py", + "line": 31, + "method": "PUT", + "route": null, + "auth_required": true + }, + { + "kind": "graphql", + "identifier": "handler_4", + "file_path": "app/entry/e4.py", + "line": 38, + "method": "GET", + "route": "/v1/resource/4", + "auth_required": false + }, + { + "kind": "rpc", + "identifier": "handler_5", + "file_path": "app/entry/e5.py", + "line": 45, + "method": "POST", + "route": "/v1/resource/5", + "auth_required": null + }, + { + "kind": "route", + "identifier": "handler_6", + "file_path": "app/entry/e6.py", + "line": 52, + "method": null, + "route": null, + "auth_required": true + }, + { + "kind": "cron", + "identifier": "handler_7", + "file_path": "app/entry/e7.py", + "line": 59, + "method": "PUT", + "route": "/v1/resource/7", + "auth_required": false + }, + { + "kind": "queue", + "identifier": "handler_8", + "file_path": "app/entry/e8.py", + "line": 66, + "method": "GET", + "route": "/v1/resource/8", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_9", + "file_path": "app/entry/e9.py", + "line": 73, + "method": "POST", + "route": null, + "auth_required": true + }, + { + "kind": "api", + "identifier": "handler_10", + "file_path": "app/entry/e10.py", + "line": 80, + "method": null, + "route": "/v1/resource/10", + "auth_required": false + }, + { + "kind": "grpc", + "identifier": "handler_11", + "file_path": "app/entry/e11.py", + "line": 87, + "method": "PUT", + "route": "/v1/resource/11", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_12", + "file_path": "app/entry/e12.py", + "line": 94, + "method": "GET", + "route": null, + "auth_required": true + }, + { + "kind": "route", + "identifier": "handler_13", + "file_path": "app/entry/e13.py", + "line": 101, + "method": "POST", + "route": "/v1/resource/13", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "handler_14", + "file_path": "app/entry/e14.py", + "line": 108, + "method": null, + "route": "/v1/resource/14", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_15", + "file_path": "app/entry/e15.py", + "line": 115, + "method": "PUT", + "route": null, + "auth_required": true + }, + { + "kind": "websocket", + "identifier": "handler_16", + "file_path": "app/entry/e16.py", + "line": 122, + "method": "GET", + "route": "/v1/resource/16", + "auth_required": false + } + ], + "trust_boundaries": [ + { + "name": "boundary_0", + "source_zone": "internet", + "target_zone": "app", + "description": "Transition 0", + "enforcement": [] + }, + { + "name": "boundary_1", + "source_zone": "dmz", + "target_zone": "db", + "description": "Transition 1", + "enforcement": [ + "waf_1", + "mtls_1" + ] + }, + { + "name": "boundary_2", + "source_zone": "vpc", + "target_zone": "cache", + "description": "Transition 2", + "enforcement": [ + "waf_2", + "mtls_2" + ] + }, + { + "name": "boundary_3", + "source_zone": "worker", + "target_zone": "queue", + "description": "Transition 3", + "enforcement": [ + "waf_3", + "mtls_3" + ] + }, + { + "name": "boundary_4", + "source_zone": "internet", + "target_zone": "app", + "description": "Transition 4", + "enforcement": [] + }, + { + "name": "boundary_5", + "source_zone": "dmz", + "target_zone": "db", + "description": "Transition 5", + "enforcement": [ + "waf_5", + "mtls_5" + ] + }, + { + "name": "boundary_6", + "source_zone": "vpc", + "target_zone": "cache", + "description": "Transition 6", + "enforcement": [ + "waf_6", + "mtls_6" + ] + }, + { + "name": "boundary_7", + "source_zone": "worker", + "target_zone": "queue", + "description": "Transition 7", + "enforcement": [ + "waf_7", + "mtls_7" + ] + }, + { + "name": "boundary_8", + "source_zone": "internet", + "target_zone": "app", + "description": "Transition 8", + "enforcement": [] + }, + { + "name": "boundary_9", + "source_zone": "dmz", + "target_zone": "db", + "description": "Transition 9", + "enforcement": [ + "waf_9", + "mtls_9" + ] + }, + { + "name": "boundary_10", + "source_zone": "vpc", + "target_zone": "cache", + "description": "Transition 10", + "enforcement": [ + "waf_10", + "mtls_10" + ] + }, + { + "name": "boundary_11", + "source_zone": "worker", + "target_zone": "queue", + "description": "Transition 11", + "enforcement": [ + "waf_11", + "mtls_11" + ] + } + ], + "services": [ + { + "name": "postgres", + "service_type": "database", + "endpoint": "postgres://db:5432", + "purpose": "primary store", + "auth_mechanism": "password" + }, + { + "name": "redis", + "service_type": "cache", + "endpoint": null, + "purpose": "sessions", + "auth_mechanism": null + }, + { + "name": "stripe", + "service_type": "payments", + "endpoint": "https://api.stripe.com", + "purpose": null, + "auth_mechanism": "api_key" + }, + { + "name": "s3", + "service_type": "object_store", + "endpoint": "https://s3.amazonaws.com", + "purpose": "uploads", + "auth_mechanism": "iam" + }, + { + "name": "smtp", + "service_type": "mail", + "endpoint": null, + "purpose": "transactional email", + "auth_mechanism": null + } + ], + "api_surface": [ + { + "method": "GET", + "path": "/api/v1/thing/0", + "handler": "ThingController.action0", + "file_path": "app/api/thing_0.py", + "line": 100, + "auth_required": true, + "rate_limited": null + }, + { + "method": "POST", + "path": "/api/v1/thing/1", + "handler": "ThingController.action1", + "file_path": "app/api/thing_1.py", + "line": 103, + "auth_required": false, + "rate_limited": false + }, + { + "method": "PUT", + "path": "/api/v1/thing/2", + "handler": "ThingController.action2", + "file_path": "app/api/thing_2.py", + "line": 106, + "auth_required": null, + "rate_limited": true + }, + { + "method": "DELETE", + "path": "/api/v1/thing/3", + "handler": "ThingController.action3", + "file_path": "app/api/thing_3.py", + "line": 109, + "auth_required": false, + "rate_limited": false + }, + { + "method": "PATCH", + "path": "/api/v1/thing/4", + "handler": "ThingController.action4", + "file_path": "app/api/thing_4.py", + "line": 112, + "auth_required": true, + "rate_limited": null + }, + { + "method": "GET", + "path": "/api/v1/thing/5", + "handler": "ThingController.action5", + "file_path": "app/api/thing_5.py", + "line": 115, + "auth_required": null, + "rate_limited": true + }, + { + "method": "POST", + "path": "/api/v1/thing/6", + "handler": "ThingController.action6", + "file_path": "app/api/thing_6.py", + "line": 118, + "auth_required": true, + "rate_limited": null + }, + { + "method": "PUT", + "path": "/api/v1/thing/7", + "handler": "ThingController.action7", + "file_path": "app/api/thing_7.py", + "line": 121, + "auth_required": false, + "rate_limited": false + }, + { + "method": "DELETE", + "path": "/api/v1/thing/8", + "handler": "ThingController.action8", + "file_path": "app/api/thing_8.py", + "line": 124, + "auth_required": null, + "rate_limited": true + }, + { + "method": "PATCH", + "path": "/api/v1/thing/9", + "handler": "ThingController.action9", + "file_path": "app/api/thing_9.py", + "line": 127, + "auth_required": false, + "rate_limited": false + }, + { + "method": "GET", + "path": "/api/v1/thing/10", + "handler": "ThingController.action10", + "file_path": "app/api/thing_10.py", + "line": 130, + "auth_required": true, + "rate_limited": null + }, + { + "method": "POST", + "path": "/api/v1/thing/11", + "handler": "ThingController.action11", + "file_path": "app/api/thing_11.py", + "line": 133, + "auth_required": null, + "rate_limited": true + }, + { + "method": "PUT", + "path": "/api/v1/thing/12", + "handler": "ThingController.action12", + "file_path": "app/api/thing_12.py", + "line": 136, + "auth_required": true, + "rate_limited": null + }, + { + "method": "DELETE", + "path": "/api/v1/thing/13", + "handler": "ThingController.action13", + "file_path": "app/api/thing_13.py", + "line": 139, + "auth_required": false, + "rate_limited": false + }, + { + "method": "PATCH", + "path": "/api/v1/thing/14", + "handler": "ThingController.action14", + "file_path": "app/api/thing_14.py", + "line": 142, + "auth_required": null, + "rate_limited": true + }, + { + "method": "GET", + "path": "/api/v1/thing/15", + "handler": "ThingController.action15", + "file_path": "app/api/thing_15.py", + "line": 145, + "auth_required": false, + "rate_limited": false + }, + { + "method": "POST", + "path": "/api/v1/thing/16", + "handler": "ThingController.action16", + "file_path": "app/api/thing_16.py", + "line": 148, + "auth_required": true, + "rate_limited": null + }, + { + "method": "PUT", + "path": "/api/v1/thing/17", + "handler": "ThingController.action17", + "file_path": "app/api/thing_17.py", + "line": 151, + "auth_required": null, + "rate_limited": true + }, + { + "method": "DELETE", + "path": "/api/v1/thing/18", + "handler": "ThingController.action18", + "file_path": "app/api/thing_18.py", + "line": 154, + "auth_required": true, + "rate_limited": null + }, + { + "method": "PATCH", + "path": "/api/v1/thing/19", + "handler": "ThingController.action19", + "file_path": "app/api/thing_19.py", + "line": 157, + "auth_required": false, + "rate_limited": false + } + ] + }, + "config": { + "secrets": [ + { + "id": "secret-00", + "secret_type": "aws_access_key", + "file_path": "config/env_0.yaml", + "line": 3, + "match": "AKIA****0000", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-01", + "secret_type": "github_token", + "file_path": "config/env_1.yaml", + "line": 4, + "match": "AKIA****0001", + "confidence": "medium", + "is_test_value": true + }, + { + "id": "secret-02", + "secret_type": "private_key", + "file_path": "config/env_2.yaml", + "line": 5, + "match": "AKIA****0002", + "confidence": "low", + "is_test_value": null + }, + { + "id": "secret-03", + "secret_type": "slack_webhook", + "file_path": "config/env_3.yaml", + "line": 6, + "match": "AKIA****0003", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-04", + "secret_type": "generic_api_key", + "file_path": "config/env_4.yaml", + "line": 7, + "match": "AKIA****0004", + "confidence": "medium", + "is_test_value": true + }, + { + "id": "secret-05", + "secret_type": "aws_access_key", + "file_path": "config/env_5.yaml", + "line": 8, + "match": "AKIA****0005", + "confidence": "low", + "is_test_value": null + }, + { + "id": "secret-06", + "secret_type": "github_token", + "file_path": "config/env_6.yaml", + "line": 9, + "match": "AKIA****0006", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-07", + "secret_type": "private_key", + "file_path": "config/env_7.yaml", + "line": 10, + "match": "AKIA****0007", + "confidence": "medium", + "is_test_value": true + }, + { + "id": "secret-08", + "secret_type": "slack_webhook", + "file_path": "config/env_8.yaml", + "line": 11, + "match": "AKIA****0008", + "confidence": "low", + "is_test_value": null + }, + { + "id": "secret-09", + "secret_type": "generic_api_key", + "file_path": "config/env_9.yaml", + "line": 12, + "match": "AKIA****0009", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-10", + "secret_type": "aws_access_key", + "file_path": "config/env_10.yaml", + "line": 13, + "match": "AKIA****0010", + "confidence": "medium", + "is_test_value": true + }, + { + "id": "secret-11", + "secret_type": "github_token", + "file_path": "config/env_11.yaml", + "line": 14, + "match": "AKIA****0011", + "confidence": "low", + "is_test_value": null + }, + { + "id": "secret-12", + "secret_type": "private_key", + "file_path": "config/env_12.yaml", + "line": 15, + "match": "AKIA****0012", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-13", + "secret_type": "slack_webhook", + "file_path": "config/env_13.yaml", + "line": 16, + "match": "AKIA****0013", + "confidence": "medium", + "is_test_value": true + }, + { + "id": "secret-14", + "secret_type": "generic_api_key", + "file_path": "config/env_14.yaml", + "line": 17, + "match": "AKIA****0014", + "confidence": "low", + "is_test_value": null + }, + { + "id": "secret-15", + "secret_type": "aws_access_key", + "file_path": "config/env_15.yaml", + "line": 18, + "match": "AKIA****0015", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-16", + "secret_type": "github_token", + "file_path": "config/env_16.yaml", + "line": 19, + "match": "AKIA****0016", + "confidence": "medium", + "is_test_value": true + } + ], + "misconfigs": [ + { + "id": "misconfig-00", + "category": "logging", + "file_path": "config/app_0.ini", + "line": null, + "key": "LOG_LEVEL", + "value": "DEBUG", + "risk": "high", + "remediation": "Debug logging in production" + }, + { + "id": "misconfig-01", + "category": "tls", + "file_path": "config/app_1.ini", + "line": 13, + "key": "SSL_VERIFY", + "value": "false", + "risk": "critical", + "remediation": "TLS verification disabled" + }, + { + "id": "misconfig-02", + "category": "cors", + "file_path": "config/app_2.ini", + "line": 14, + "key": "ALLOW_ORIGIN", + "value": "*", + "risk": "high", + "remediation": null + }, + { + "id": "misconfig-03", + "category": "headers", + "file_path": "config/app_3.ini", + "line": 0, + "key": "X_FRAME_OPTIONS", + "value": null, + "risk": "medium", + "remediation": "Missing clickjacking header" + }, + { + "id": "misconfig-04", + "category": "debug", + "file_path": "config/app_4.ini", + "line": 16, + "key": "DJANGO_DEBUG", + "value": "True", + "risk": "critical", + "remediation": "Debug mode enabled" + }, + { + "id": "misconfig-05", + "category": "storage", + "file_path": "config/app_5.ini", + "line": null, + "key": "BUCKET_ACL", + "value": "public-read", + "risk": "critical", + "remediation": "Public bucket" + }, + { + "id": "misconfig-06", + "category": "auth", + "file_path": "config/app_6.ini", + "line": 18, + "key": "SESSION_TIMEOUT", + "value": "999999", + "risk": "medium", + "remediation": null + }, + { + "id": "misconfig-07", + "category": "http", + "file_path": "config/app_7.ini", + "line": 19, + "key": "REDIRECT_HTTPS", + "value": "false", + "risk": "high", + "remediation": "Plain HTTP allowed" + }, + { + "id": "misconfig-08", + "category": "secrets", + "file_path": "config/app_8.ini", + "line": 20, + "key": null, + "value": "inline", + "risk": "medium", + "remediation": "Inline secret" + }, + { + "id": "misconfig-09", + "category": "trace", + "file_path": "config/app_9.ini", + "line": 21, + "key": "OTEL_TRACE_ALL", + "value": "true", + "risk": "low", + "remediation": "Verbose tracing" + }, + { + "id": "misconfig-10", + "category": "network", + "file_path": "config/app_10.ini", + "line": null, + "key": "BIND_ADDR", + "value": "0.0.0.0", + "risk": "medium", + "remediation": null + }, + { + "id": "misconfig-11", + "category": "errors", + "file_path": "config/app_11.ini", + "line": 23, + "key": "SHOW_STACKTRACE", + "value": "true", + "risk": "high", + "remediation": "Error stacktraces exposed" + }, + { + "id": "misconfig-12", + "category": "cache", + "file_path": "config/app_12.ini", + "line": 24, + "key": "CACHE_TTL", + "value": "0", + "risk": "low", + "remediation": null + }, + { + "id": "misconfig-13", + "category": "db", + "file_path": "config/app_13.ini", + "line": 25, + "key": "SSLMODE", + "value": "disable", + "risk": "critical", + "remediation": "Database TLS off" + }, + { + "id": "misconfig-14", + "category": "queue", + "file_path": "config/app_14.ini", + "line": 26, + "key": "PREFETCH", + "value": "1000", + "risk": "low", + "remediation": null + }, + { + "id": "misconfig-15", + "category": "exposure", + "file_path": "config/app_15.ini", + "line": null, + "key": "ADMIN_PATH", + "value": "/admin", + "risk": "medium", + "remediation": "Admin surface exposed" + } + ] + }, + "data_flows": { + "flows": [ + { + "source": "request.args['q']", + "path": [ + { + "file_path": "app/search/index.ts", + "line": 20, + "component": "step_0_a", + "operation": "read" + }, + { + "file_path": "app/util/b.py", + "line": 40, + "component": "step_0_b", + "operation": "write" + } + ], + "sink": "cursor.execute", + "sanitized": false, + "files": [ + "app/search/index.ts", + "app/db/raw.py", + "app/util/a.py", + "app/util/b.py" + ] + }, + { + "source": "request.cookies['session']", + "path": [ + { + "file_path": "app/auth/session_store.py", + "line": 21, + "component": "step_1_a", + "operation": "read" + }, + { + "file_path": "app/auth/session_store.py", + "line": 41, + "component": "step_1_b", + "operation": "write" + } + ], + "sink": "redis.set", + "sanitized": true, + "files": [ + "app/auth/session_store.py" + ] + }, + { + "source": "request.json['url']", + "path": [ + { + "file_path": "app/integrations/webhooks.go", + "line": 22, + "component": "step_2_a", + "operation": "read" + }, + { + "file_path": "app/net/client.go", + "line": 42, + "component": "step_2_b", + "operation": "write" + } + ], + "sink": "requests.get", + "sanitized": false, + "files": [ + "app/integrations/webhooks.go", + "app/net/client.go" + ] + }, + { + "source": "form['password']", + "path": [ + { + "file_path": "app/auth/service.py", + "line": 23, + "component": "step_3_a", + "operation": "read" + }, + { + "file_path": "app/obs/telemetry.go", + "line": 43, + "component": "step_3_b", + "operation": "write" + } + ], + "sink": "logger.info", + "sanitized": false, + "files": [ + "app/auth/service.py", + "app/obs/telemetry.go" + ] + }, + { + "source": "header['Authorization']", + "path": [ + { + "file_path": "app/common/jwt_tools.py", + "line": 24, + "component": "step_4_a", + "operation": "read" + }, + { + "file_path": "app/common/jwt_tools.py", + "line": 44, + "component": "step_4_b", + "operation": "write" + } + ], + "sink": "jwt.decode", + "sanitized": true, + "files": [ + "app/common/jwt_tools.py" + ] + }, + { + "source": "query['email']", + "path": [ + { + "file_path": "app/notify/mailer.rb", + "line": 25, + "component": "step_5_a", + "operation": "read" + }, + { + "file_path": "app/notify/mailer.rb", + "line": 45, + "component": "step_5_b", + "operation": "write" + } + ], + "sink": "smtp.send", + "sanitized": false, + "files": [ + "app/notify/mailer.rb" + ] + }, + { + "source": "body['card_number']", + "path": [ + { + "file_path": "app/billing/payments.go", + "line": 26, + "component": "step_6_a", + "operation": "read" + }, + { + "file_path": "app/billing/core.py", + "line": 46, + "component": "step_6_b", + "operation": "write" + } + ], + "sink": "stripe.Charge.create", + "sanitized": true, + "files": [ + "app/billing/payments.go", + "app/billing/core.py" + ] + }, + { + "source": "path_param['id']", + "path": [ + { + "file_path": "app/api/thing_1.py", + "line": 27, + "component": "step_7_a", + "operation": "read" + }, + { + "file_path": "app/api/thing_1.py", + "line": 47, + "component": "step_7_b", + "operation": "write" + } + ], + "sink": "orm.filter", + "sanitized": true, + "files": [ + "app/api/thing_1.py" + ] + }, + { + "source": "upload.filename", + "path": [ + { + "file_path": "app/media/uploads.py", + "line": 28, + "component": "step_8_a", + "operation": "read" + }, + { + "file_path": "app/x/w.py", + "line": 48, + "component": "step_8_b", + "operation": "write" + } + ], + "sink": "open", + "sanitized": false, + "files": [ + "app/media/uploads.py", + "app/common/cache.py", + "app/common/guard.py", + "app/x/y.py", + "app/x/z.py", + "app/x/w.py" + ] + }, + { + "source": "env['DEBUG']", + "path": [ + { + "file_path": "app/web/render.py", + "line": 29, + "component": "step_9_a", + "operation": "read" + }, + { + "file_path": "app/web/render.py", + "line": 49, + "component": "step_9_b", + "operation": "write" + } + ], + "sink": "template.render", + "sanitized": false, + "files": [ + "app/web/render.py" + ] + }, + { + "source": "request.body", + "path": [ + { + "file_path": "app/admin/views.py", + "line": 30, + "component": "step_10_a", + "operation": "read" + }, + { + "file_path": "app/admin/views.py", + "line": 50, + "component": "step_10_b", + "operation": "write" + } + ], + "sink": "eval", + "sanitized": false, + "files": [ + "app/admin/views.py" + ] + }, + { + "source": "websocket.msg", + "path": [ + { + "file_path": "app/ws/handler.py", + "line": 31, + "component": "step_11_a", + "operation": "read" + }, + { + "file_path": "app/ws/exec.py", + "line": 51, + "component": "step_11_b", + "operation": "write" + } + ], + "sink": "subprocess.run", + "sanitized": false, + "files": [ + "app/ws/handler.py", + "app/ws/exec.py" + ] + }, + { + "source": "cli.argv", + "path": [ + { + "file_path": "tools/run.py", + "line": 32, + "component": "step_12_a", + "operation": "read" + }, + { + "file_path": "tools/run.py", + "line": 52, + "component": "step_12_b", + "operation": "write" + } + ], + "sink": "os.system", + "sanitized": false, + "files": [ + "tools/run.py" + ] + }, + { + "source": "queue.payload", + "path": [ + { + "file_path": "app/worker/task.py", + "line": 33, + "component": "step_13_a", + "operation": "read" + }, + { + "file_path": "app/worker/task.py", + "line": 53, + "component": "step_13_b", + "operation": "write" + } + ], + "sink": "pickle.loads", + "sanitized": false, + "files": [ + "app/worker/task.py" + ] + }, + { + "source": "request.args['redirect']", + "path": [ + { + "file_path": "app/web/redirect.py", + "line": 34, + "component": "step_14_a", + "operation": "read" + }, + { + "file_path": "app/web/redirect.py", + "line": 54, + "component": "step_14_b", + "operation": "write" + } + ], + "sink": "HttpResponseRedirect", + "sanitized": false, + "files": [ + "app/web/redirect.py" + ] + }, + { + "source": "session['role']", + "path": [ + { + "file_path": "app/auth/permissions.py", + "line": 35, + "component": "step_15_a", + "operation": "read" + }, + { + "file_path": "app/auth/rbac.py", + "line": 55, + "component": "step_15_b", + "operation": "write" + } + ], + "sink": "permission_check", + "sanitized": true, + "files": [ + "app/auth/permissions.py", + "app/auth/rbac.py" + ] + }, + { + "source": "request.files['avatar']", + "path": [ + { + "file_path": "app/media/uploads.py", + "line": 36, + "component": "step_16_a", + "operation": "read" + }, + { + "file_path": "app/media/uploads.py", + "line": 56, + "component": "step_16_b", + "operation": "write" + } + ], + "sink": "s3.put_object", + "sanitized": true, + "files": [ + "app/media/uploads.py" + ] + }, + { + "source": "token", + "path": [ + { + "file_path": "app/common/cache.py", + "line": 37, + "component": "step_17_a", + "operation": "read" + }, + { + "file_path": "app/common/cache.py", + "line": 57, + "component": "step_17_b", + "operation": "write" + } + ], + "sink": "cache.set", + "sanitized": true, + "files": [ + "app/common/cache.py" + ] + }, + { + "source": "request.headers['X-User-Phone']", + "path": [ + { + "file_path": "app/obs/audit.py", + "line": 38, + "component": "step_18_a", + "operation": "read" + }, + { + "file_path": "app/obs/audit.py", + "line": 58, + "component": "step_18_b", + "operation": "write" + } + ], + "sink": "audit_log", + "sanitized": false, + "files": [ + "app/obs/audit.py" + ] + }, + { + "source": "graphql.variables", + "path": [ + { + "file_path": "app/api/graphql.ts", + "line": 39, + "component": "step_19_a", + "operation": "read" + }, + { + "file_path": "app/api/graphql.ts", + "line": 59, + "component": "step_19_b", + "operation": "write" + } + ], + "sink": "db.query", + "sanitized": false, + "files": [ + "app/api/graphql.ts" + ] + } + ], + "sanitization_points": [ + { + "file_path": "app/sanitize/s0.py", + "line": 5, + "function_name": null, + "sanitization_type": "escape", + "protects_against": [] + }, + { + "file_path": "app/sanitize/s1.py", + "line": 9, + "function_name": "clean_1", + "sanitization_type": "parameterize", + "protects_against": [ + "CWE-80", + "CWE-90" + ] + }, + { + "file_path": "app/sanitize/s2.py", + "line": 13, + "function_name": "clean_2", + "sanitization_type": "allowlist", + "protects_against": [ + "CWE-81", + "CWE-91" + ] + }, + { + "file_path": "app/sanitize/s3.py", + "line": 17, + "function_name": "clean_3", + "sanitization_type": "encode", + "protects_against": [] + }, + { + "file_path": "app/sanitize/s4.py", + "line": 21, + "function_name": "clean_4", + "sanitization_type": "escape", + "protects_against": [ + "CWE-83", + "CWE-93" + ] + }, + { + "file_path": "app/sanitize/s5.py", + "line": 25, + "function_name": null, + "sanitization_type": "parameterize", + "protects_against": [ + "CWE-84", + "CWE-94" + ] + }, + { + "file_path": "app/sanitize/s6.py", + "line": 29, + "function_name": "clean_6", + "sanitization_type": "allowlist", + "protects_against": [] + }, + { + "file_path": "app/sanitize/s7.py", + "line": 33, + "function_name": "clean_7", + "sanitization_type": "encode", + "protects_against": [ + "CWE-86", + "CWE-96" + ] + }, + { + "file_path": "app/sanitize/s8.py", + "line": 37, + "function_name": "clean_8", + "sanitization_type": "escape", + "protects_against": [ + "CWE-87", + "CWE-97" + ] + }, + { + "file_path": "app/sanitize/s9.py", + "line": 41, + "function_name": "clean_9", + "sanitization_type": "parameterize", + "protects_against": [] + }, + { + "file_path": "app/sanitize/s10.py", + "line": 45, + "function_name": null, + "sanitization_type": "allowlist", + "protects_against": [ + "CWE-89", + "CWE-99" + ] + }, + { + "file_path": "app/sanitize/s11.py", + "line": 49, + "function_name": "clean_11", + "sanitization_type": "encode", + "protects_against": [ + "CWE-90", + "CWE-100" + ] + } + ], + "sinks": [ + { + "sink_type": "sql", + "file_path": "app/sink/k0.py", + "line": 200, + "function_name": null, + "exploitability_notes": null + }, + { + "sink_type": "command", + "file_path": "app/sink/k1.py", + "line": 202, + "function_name": "sink_fn_1", + "exploitability_notes": "note 1" + }, + { + "sink_type": "template", + "file_path": "app/sink/k2.py", + "line": 204, + "function_name": "sink_fn_2", + "exploitability_notes": "note 2" + }, + { + "sink_type": "file", + "file_path": "app/sink/k3.py", + "line": 206, + "function_name": "sink_fn_3", + "exploitability_notes": null + }, + { + "sink_type": "network", + "file_path": "app/sink/k4.py", + "line": 208, + "function_name": null, + "exploitability_notes": "note 4" + }, + { + "sink_type": "deserialization", + "file_path": "app/sink/k5.py", + "line": 210, + "function_name": "sink_fn_5", + "exploitability_notes": "note 5" + }, + { + "sink_type": "sql", + "file_path": "app/sink/k6.py", + "line": 212, + "function_name": "sink_fn_6", + "exploitability_notes": null + }, + { + "sink_type": "command", + "file_path": "app/sink/k7.py", + "line": 214, + "function_name": "sink_fn_7", + "exploitability_notes": "note 7" + }, + { + "sink_type": "template", + "file_path": "app/sink/k8.py", + "line": 216, + "function_name": null, + "exploitability_notes": "note 8" + }, + { + "sink_type": "file", + "file_path": "app/sink/k9.py", + "line": 218, + "function_name": "sink_fn_9", + "exploitability_notes": null + }, + { + "sink_type": "network", + "file_path": "app/sink/k10.py", + "line": 220, + "function_name": "sink_fn_10", + "exploitability_notes": "note 10" + }, + { + "sink_type": "deserialization", + "file_path": "app/sink/k11.py", + "line": 222, + "function_name": "sink_fn_11", + "exploitability_notes": "note 11" + }, + { + "sink_type": "sql", + "file_path": "app/sink/k12.py", + "line": 224, + "function_name": null, + "exploitability_notes": null + }, + { + "sink_type": "command", + "file_path": "app/sink/k13.py", + "line": 226, + "function_name": "sink_fn_13", + "exploitability_notes": "note 13" + }, + { + "sink_type": "template", + "file_path": "app/sink/k14.py", + "line": 228, + "function_name": "sink_fn_14", + "exploitability_notes": "note 14" + }, + { + "sink_type": "file", + "file_path": "app/sink/k15.py", + "line": 230, + "function_name": "sink_fn_15", + "exploitability_notes": null + }, + { + "sink_type": "network", + "file_path": "app/sink/k16.py", + "line": 232, + "function_name": null, + "exploitability_notes": "note 16" + }, + { + "sink_type": "deserialization", + "file_path": "app/sink/k17.py", + "line": 234, + "function_name": "sink_fn_17", + "exploitability_notes": "note 17" + } + ] + }, + "dependencies": { + "sbom": [ + { + "name": "django", + "version": "4.2.1", + "ecosystem": "pypi", + "direct": true, + "license": "BSD-3-Clause" + }, + { + "name": "requests", + "version": "2.31.0", + "ecosystem": "pypi", + "direct": true, + "license": "Apache-2.0" + }, + { + "name": "urllib3", + "version": "1.26.5", + "ecosystem": "pypi", + "direct": false, + "license": "MIT" + }, + { + "name": "jinja2", + "version": "3.1.2", + "ecosystem": "pypi", + "direct": false, + "license": null + }, + { + "name": "lodash", + "version": "4.17.19", + "ecosystem": "npm", + "direct": true, + "license": "MIT" + }, + { + "name": "express", + "version": "4.17.1", + "ecosystem": "npm", + "direct": true, + "license": "MIT" + }, + { + "name": "minimist", + "version": "1.2.5", + "ecosystem": "npm", + "direct": false, + "license": "MIT" + }, + { + "name": "axios", + "version": "0.21.1", + "ecosystem": "npm", + "direct": false, + "license": "MIT" + }, + { + "name": "github.com/gin-gonic/gin", + "version": "1.9.0", + "ecosystem": "go", + "direct": true, + "license": "MIT" + }, + { + "name": "golang.org/x/net", + "version": "0.7.0", + "ecosystem": "go", + "direct": false, + "license": "BSD-3-Clause" + }, + { + "name": "rails", + "version": "7.0.4", + "ecosystem": "rubygems", + "direct": true, + "license": "MIT" + }, + { + "name": "nokogiri", + "version": "1.13.6", + "ecosystem": "rubygems", + "direct": false, + "license": "MIT" + }, + { + "name": "django", + "version": "4.2.1", + "ecosystem": "pypi", + "direct": true, + "license": "BSD-3-Clause" + }, + { + "name": "requests", + "version": "2.31.0", + "ecosystem": "pypi", + "direct": true, + "license": "Apache-2.0" + } + ], + "known_cves": [ + { + "cve_id": "CVE-2023-0001", + "package": "django", + "installed_version": "4.2.1", + "fixed_version": "4.2.5", + "cvss_v4_score": 9.8, + "epss_score": 0.5, + "direct": true, + "reachable": true + }, + { + "cve_id": "CVE-2023-0002", + "package": "urllib3", + "installed_version": "1.26.5", + "fixed_version": null, + "cvss_v4_score": 7.5, + "epss_score": 0.1, + "direct": false, + "reachable": false + }, + { + "cve_id": "CVE-2023-0003", + "package": "lodash", + "installed_version": "4.17.19", + "fixed_version": "4.17.21", + "cvss_v4_score": 9.8, + "epss_score": 0.9, + "direct": true, + "reachable": true + }, + { + "cve_id": "CVE-2023-0004", + "package": "minimist", + "installed_version": "1.2.5", + "fixed_version": "1.2.6", + "cvss_v4_score": null, + "epss_score": null, + "direct": false, + "reachable": null + }, + { + "cve_id": "CVE-2023-0005", + "package": "axios", + "installed_version": "0.21.1", + "fixed_version": "0.21.2", + "cvss_v4_score": 5.3, + "epss_score": 0.02, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0006", + "package": "jinja2", + "installed_version": "3.1.2", + "fixed_version": null, + "cvss_v4_score": 6.1, + "epss_score": null, + "direct": false, + "reachable": false + }, + { + "cve_id": "CVE-2023-0007", + "package": "express", + "installed_version": "4.17.1", + "fixed_version": "4.18.0", + "cvss_v4_score": 4.3, + "epss_score": 0.005, + "direct": true, + "reachable": null + }, + { + "cve_id": "CVE-2023-0008", + "package": "nokogiri", + "installed_version": "1.13.6", + "fixed_version": "1.13.10", + "cvss_v4_score": 8.8, + "epss_score": 0.3, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0009", + "package": "golang.org/x/net", + "installed_version": "0.7.0", + "fixed_version": "0.17.0", + "cvss_v4_score": 7.5, + "epss_score": 0.44, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0010", + "package": "rails", + "installed_version": "7.0.4", + "fixed_version": "7.0.7", + "cvss_v4_score": 9.1, + "epss_score": 0.6, + "direct": true, + "reachable": false + }, + { + "cve_id": "CVE-2023-0011", + "package": "gin", + "installed_version": "1.9.0", + "fixed_version": null, + "cvss_v4_score": 3.7, + "epss_score": 0.001, + "direct": true, + "reachable": null + }, + { + "cve_id": "CVE-2023-0012", + "package": "requests", + "installed_version": "2.31.0", + "fixed_version": "2.32.0", + "cvss_v4_score": 6.5, + "epss_score": 0.07, + "direct": true, + "reachable": true + }, + { + "cve_id": "CVE-2023-0013", + "package": "pyyaml", + "installed_version": "5.3", + "fixed_version": "5.4", + "cvss_v4_score": 9.8, + "epss_score": 0.5, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0014", + "package": "log4j", + "installed_version": "2.14.0", + "fixed_version": "2.17.1", + "cvss_v4_score": 10.0, + "epss_score": 0.97, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0015", + "package": "openssl", + "installed_version": "1.1.1k", + "fixed_version": "1.1.1t", + "cvss_v4_score": 7.4, + "epss_score": 0.12, + "direct": false, + "reachable": null + }, + { + "cve_id": "CVE-2023-0016", + "package": "zlib", + "installed_version": "1.2.11", + "fixed_version": "1.2.12", + "cvss_v4_score": 8.2, + "epss_score": 0.04, + "direct": false, + "reachable": false + }, + { + "cve_id": "CVE-2023-0017", + "package": "libxml2", + "installed_version": "2.9.10", + "fixed_version": null, + "cvss_v4_score": null, + "epss_score": 0.25, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0018", + "package": "curl", + "installed_version": "7.68.0", + "fixed_version": "7.88.0", + "cvss_v4_score": 8.1, + "epss_score": null, + "direct": true, + "reachable": true + } + ], + "outdated": [ + { + "package": "pkg-0", + "current_version": "1.0.0", + "latest_version": "2.0.0", + "direct": true + }, + { + "package": "pkg-1", + "current_version": "1.1.0", + "latest_version": "2.1.0", + "direct": false + }, + { + "package": "pkg-2", + "current_version": "1.2.0", + "latest_version": "2.2.0", + "direct": true + }, + { + "package": "pkg-3", + "current_version": "1.3.0", + "latest_version": "2.3.0", + "direct": false + }, + { + "package": "pkg-4", + "current_version": "1.4.0", + "latest_version": "2.4.0", + "direct": true + }, + { + "package": "pkg-5", + "current_version": "1.5.0", + "latest_version": "2.5.0", + "direct": false + }, + { + "package": "pkg-6", + "current_version": "1.6.0", + "latest_version": "2.6.0", + "direct": true + }, + { + "package": "pkg-7", + "current_version": "1.7.0", + "latest_version": "2.7.0", + "direct": false + }, + { + "package": "pkg-8", + "current_version": "1.8.0", + "latest_version": "2.8.0", + "direct": true + }, + { + "package": "pkg-9", + "current_version": "1.9.0", + "latest_version": "2.9.0", + "direct": false + }, + { + "package": "pkg-10", + "current_version": "1.10.0", + "latest_version": "2.10.0", + "direct": true + }, + { + "package": "pkg-11", + "current_version": "1.11.0", + "latest_version": "2.11.0", + "direct": false + } + ], + "direct_count": 7, + "transitive_count": 143 + }, + "file_count": 2417, + "frameworks": [ + "Django", + "next.js", + "Spring Boot", + " React ", + "unknown-fw", + "NEXT", + "Vue" + ], + "languages": [ + "Python", + "JavaScript", + "Go", + "Ruby", + "Rust", + "python" + ], + "lines_of_code": 184213, + "recon_duration_seconds": 42.5, + "security_context": { + "auth_model": "jwt", + "auth_details": "HS256 JWTs issued by app/auth/service.py, refresh tokens in Redis", + "crypto_usage": [ + { + "algorithm": "AES", + "key_size": 256, + "mode": "GCM", + "usage_context": "at-rest encryption", + "is_weak": false + }, + { + "algorithm": "MD5", + "key_size": null, + "mode": null, + "usage_context": "password hashing", + "is_weak": true + }, + { + "algorithm": "RSA", + "key_size": 1024, + "mode": null, + "usage_context": "token signing", + "is_weak": true + }, + { + "algorithm": "SHA-256", + "key_size": null, + "mode": null, + "usage_context": null, + "is_weak": false + }, + { + "algorithm": "DES", + "key_size": 56, + "mode": "CBC", + "usage_context": "legacy export", + "is_weak": true + }, + { + "algorithm": "HMAC-SHA1", + "key_size": 160, + "mode": null, + "usage_context": "webhook signature", + "is_weak": true + }, + { + "algorithm": "ChaCha20", + "key_size": 256, + "mode": "Poly1305", + "usage_context": "transport", + "is_weak": false + }, + { + "algorithm": "bcrypt", + "key_size": null, + "mode": null, + "usage_context": "password hashing", + "is_weak": false + }, + { + "algorithm": "RC4", + "key_size": 128, + "mode": null, + "usage_context": null, + "is_weak": true + }, + { + "algorithm": "ECDSA", + "key_size": 256, + "mode": null, + "usage_context": "JWT signing", + "is_weak": null + }, + { + "algorithm": "PBKDF2", + "key_size": null, + "mode": null, + "usage_context": "key derivation", + "is_weak": false + }, + { + "algorithm": "SHA-1", + "key_size": null, + "mode": null, + "usage_context": "checksum", + "is_weak": true + }, + { + "algorithm": "AES", + "key_size": 128, + "mode": "ECB", + "usage_context": "legacy blob", + "is_weak": true + }, + { + "algorithm": "Ed25519", + "key_size": 256, + "mode": null, + "usage_context": "package signing", + "is_weak": false + }, + { + "algorithm": "3DES", + "key_size": 168, + "mode": "CBC", + "usage_context": "legacy tape", + "is_weak": true + }, + { + "algorithm": "Argon2id", + "key_size": null, + "mode": null, + "usage_context": "password hashing", + "is_weak": null + } + ], + "framework_security": [ + "django.middleware.csrf.CsrfViewMiddleware", + "", + "helmet defaults", + "SecurityMiddleware", + "rack-protection", + "spring-security filter chain" + ], + "security_headers": [ + "Content-Security-Policy: default-src 'self'", + "X-Content-Type-Options: nosniff", + "Strict-Transport-Security: max-age=31536000", + "X-Frame-Options: DENY", + "Referrer-Policy: no-referrer", + "", + "Permissions-Policy: geolocation=()" + ], + "deployment_signals": [ + "kubernetes ingress with TLS termination", + "docker-compose exposes 5432", + "no WAF in front of /api", + "secrets mounted from vault", + "readiness probe on /healthz", + "single replica for the worker", + "TLS 1.2 minimum", + "internal service mesh mTLS" + ] + } +} \ No newline at end of file diff --git a/go/internal/recontext/testdata/golden/strategy_dispatch.json b/go/internal/recontext/testdata/golden/strategy_dispatch.json new file mode 100644 index 0000000..14b329d --- /dev/null +++ b/go/internal/recontext/testdata/golden/strategy_dispatch.json @@ -0,0 +1,15 @@ +{ + "api_security": "82771f025cbc1e0b1641ddf58f8dfef0ed953bb003adc8f374feb89b39d487a3", + "auth": "b60513ba49bcbe930024c756c324c146c4db1c8286610906a84d94a37549fc53", + "business_logic": "5233f410243248527401314d2421f5ea1c882243342797767d9fe66656d058bf", + "config_secrets": "c8f201b7dad4c7f3f5b5839ed74ea7346dd21a36e49f9b03d8a1c40a37892670", + "crypto": "c7f937f4cebe00d387c8d603052795e13c79b0a5e7d2724ba7c3bb135b1e72d9", + "data_exposure": "6401321c6a9b97bcb9d7039f448944a4888d4ed2e255cb674fb093d2327bd7b3", + "dos": "aab1ed8b0c727bc8cbafdd01a4356359893fa0ec252a33f8e3a6f82aa99f1593", + "injection": "dbc7d5b08698797bdaa3c460840b389ef5e91d6e937ce466396e22de25d894d8", + "javascript_specific": "aab1ed8b0c727bc8cbafdd01a4356359893fa0ec252a33f8e3a6f82aa99f1593", + "python_specific": "aab1ed8b0c727bc8cbafdd01a4356359893fa0ec252a33f8e3a6f82aa99f1593", + "ssrf": "aab1ed8b0c727bc8cbafdd01a4356359893fa0ec252a33f8e3a6f82aa99f1593", + "supply_chain": "13dde4da65c9c8ef0a41a25f7d8e3d2ce70f7a0ee207392304f6bdf8cbde0011", + "xss": "aab1ed8b0c727bc8cbafdd01a4356359893fa0ec252a33f8e3a6f82aa99f1593" +} diff --git a/go/internal/recontext/testdata/golden/supply_chain.txt b/go/internal/recontext/testdata/golden/supply_chain.txt new file mode 100644 index 0000000..f297901 --- /dev/null +++ b/go/internal/recontext/testdata/golden/supply_chain.txt @@ -0,0 +1,44 @@ +Supply-chain-focused recon summary. + +Dependency inventory: direct=7, transitive=143, SBOM entries=14. + +Known CVE exposure (reachable/high severity first): 18 total, showing top 15: +- CVE-2023-0014 in log4j 2.14.0 (fixed=2.17.1, cvss=10.0, epss=0.97, direct=False, reachable=True) +- CVE-2023-0003 in lodash 4.17.19 (fixed=4.17.21, cvss=9.8, epss=0.9, direct=True, reachable=True) +- CVE-2023-0001 in django 4.2.1 (fixed=4.2.5, cvss=9.8, epss=0.5, direct=True, reachable=True) +- CVE-2023-0013 in pyyaml 5.3 (fixed=5.4, cvss=9.8, epss=0.5, direct=False, reachable=True) +- CVE-2023-0008 in nokogiri 1.13.6 (fixed=1.13.10, cvss=8.8, epss=0.3, direct=False, reachable=True) +- CVE-2023-0018 in curl 7.68.0 (fixed=7.88.0, cvss=8.1, epss=None, direct=True, reachable=True) +- CVE-2023-0009 in golang.org/x/net 0.7.0 (fixed=0.17.0, cvss=7.5, epss=0.44, direct=False, reachable=True) +- CVE-2023-0012 in requests 2.31.0 (fixed=2.32.0, cvss=6.5, epss=0.07, direct=True, reachable=True) +- CVE-2023-0005 in axios 0.21.1 (fixed=0.21.2, cvss=5.3, epss=0.02, direct=False, reachable=True) +- CVE-2023-0017 in libxml2 2.9.10 (fixed=unknown, cvss=None, epss=0.25, direct=False, reachable=True) +- CVE-2023-0010 in rails 7.0.4 (fixed=7.0.7, cvss=9.1, epss=0.6, direct=True, reachable=False) +- CVE-2023-0016 in zlib 1.2.11 (fixed=1.2.12, cvss=8.2, epss=0.04, direct=False, reachable=False) +- CVE-2023-0002 in urllib3 1.26.5 (fixed=unknown, cvss=7.5, epss=0.1, direct=False, reachable=False) +- CVE-2023-0015 in openssl 1.1.1k (fixed=1.1.1t, cvss=7.4, epss=0.12, direct=False, reachable=None) +- CVE-2023-0006 in jinja2 3.1.2 (fixed=unknown, cvss=6.1, epss=None, direct=False, reachable=False) + +Outdated dependencies: 12 total, showing top 10: +- pkg-0: 1.0.0 -> 2.0.0 (direct=True) +- pkg-1: 1.1.0 -> 2.1.0 (direct=False) +- pkg-2: 1.2.0 -> 2.2.0 (direct=True) +- pkg-3: 1.3.0 -> 2.3.0 (direct=False) +- pkg-4: 1.4.0 -> 2.4.0 (direct=True) +- pkg-5: 1.5.0 -> 2.5.0 (direct=False) +- pkg-6: 1.6.0 -> 2.6.0 (direct=True) +- pkg-7: 1.7.0 -> 2.7.0 (direct=False) +- pkg-8: 1.8.0 -> 2.8.0 (direct=True) +- pkg-9: 1.9.0 -> 2.9.0 (direct=False) + +Primary dependency ecosystems in this repo: 12 total, showing top 10: +- go: github.com/gin-gonic/gin@1.9.0 +- go: golang.org/x/net@0.7.0 +- npm: axios@0.21.1 +- npm: express@4.17.1 +- npm: lodash@4.17.19 +- npm: minimist@1.2.5 +- pypi: django@4.2.1 +- pypi: jinja2@3.1.2 +- pypi: requests@2.31.0 +- pypi: urllib3@1.26.5 \ No newline at end of file diff --git a/go/internal/recontext/testdata/recon_fixture.json b/go/internal/recontext/testdata/recon_fixture.json new file mode 100644 index 0000000..bf916bb --- /dev/null +++ b/go/internal/recontext/testdata/recon_fixture.json @@ -0,0 +1,2189 @@ +{ + "architecture": { + "app_type": "web_api", + "modules": [ + { + "name": "auth_service", + "path": "app/auth/service.py", + "language": "python", + "description": "Session and JWT issuance", + "dependencies": [ + "jwt", + "redis" + ] + }, + { + "name": "rbac", + "path": "app/auth/rbac.py", + "language": "python", + "description": null, + "dependencies": [] + }, + { + "name": "billing", + "path": "app/billing/core.py", + "language": "python", + "description": "Invoice state machine — handles refunds", + "dependencies": [ + "stripe" + ] + }, + { + "name": "csrf_guard", + "path": "app/web/middleware/csrf.js", + "language": "javascript", + "description": "CSRF middleware", + "dependencies": [] + }, + { + "name": "payments", + "path": "app/billing/payments.go", + "language": "go", + "description": "Charge orchestration", + "dependencies": [ + "stripe-go" + ] + }, + { + "name": "reporting", + "path": "app/reports/render.py", + "language": "python", + "description": "PDF and CSV export", + "dependencies": [ + "weasyprint" + ] + }, + { + "name": "search", + "path": "app/search/index.ts", + "language": "typescript", + "description": "Elasticsearch query builder", + "dependencies": [ + "@elastic/elasticsearch" + ] + }, + { + "name": "session_store", + "path": "app/auth/session_store.py", + "language": "python", + "description": "Redis-backed sessions", + "dependencies": [ + "redis" + ] + }, + { + "name": "notifications", + "path": "app/notify/mailer.rb", + "language": "ruby", + "description": null, + "dependencies": [ + "mail" + ] + }, + { + "name": "permission_matrix", + "path": "app/auth/permissions.py", + "language": "python", + "description": "Role → permission table", + "dependencies": [] + }, + { + "name": "uploads", + "path": "app/media/uploads.py", + "language": "python", + "description": "S3 multipart upload helper", + "dependencies": [ + "boto3" + ] + }, + { + "name": "webhooks", + "path": "app/integrations/webhooks.go", + "language": "go", + "description": "Outbound webhook dispatcher", + "dependencies": [] + }, + { + "name": "graph_api", + "path": "app/api/graphql.ts", + "language": "typescript", + "description": "GraphQL resolvers — naïve depth limit", + "dependencies": [ + "graphql" + ] + }, + { + "name": "admin_panel", + "path": "app/admin/views.py", + "language": "python", + "description": "Django admin overrides", + "dependencies": [ + "django" + ] + }, + { + "name": "guard_utils", + "path": "app/common/guard.py", + "language": "python", + "description": "Assorted guard helpers", + "dependencies": [] + }, + { + "name": "cache", + "path": "app/common/cache.py", + "language": "python", + "description": "Memoization wrappers", + "dependencies": [] + }, + { + "name": "migrations", + "path": "db/migrate/2024_add_roles.rb", + "language": "ruby", + "description": "Adds role column", + "dependencies": [] + }, + { + "name": "jwt_tools", + "path": "app/common/jwt_tools.py", + "language": "python", + "description": null, + "dependencies": [ + "pyjwt" + ] + }, + { + "name": "telemetry", + "path": "app/obs/telemetry.go", + "language": "go", + "description": "OTel exporter", + "dependencies": [] + } + ], + "entry_points": [ + { + "kind": "http", + "identifier": "handler_0", + "file_path": "app/entry/e0.py", + "line": 10, + "method": "GET", + "route": null, + "auth_required": true + }, + { + "kind": "HTTP", + "identifier": "handler_1", + "file_path": "app/entry/e1.py", + "line": 17, + "method": "POST", + "route": "/v1/resource/1", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "handler_2", + "file_path": "app/entry/e2.py", + "line": 24, + "method": null, + "route": "/v1/resource/2", + "auth_required": null + }, + { + "kind": "api", + "identifier": "handler_3", + "file_path": "app/entry/e3.py", + "line": 31, + "method": "PUT", + "route": null, + "auth_required": true + }, + { + "kind": "graphql", + "identifier": "handler_4", + "file_path": "app/entry/e4.py", + "line": 38, + "method": "GET", + "route": "/v1/resource/4", + "auth_required": false + }, + { + "kind": "rpc", + "identifier": "handler_5", + "file_path": "app/entry/e5.py", + "line": 45, + "method": "POST", + "route": "/v1/resource/5", + "auth_required": null + }, + { + "kind": "route", + "identifier": "handler_6", + "file_path": "app/entry/e6.py", + "line": 52, + "method": null, + "route": null, + "auth_required": true + }, + { + "kind": "cron", + "identifier": "handler_7", + "file_path": "app/entry/e7.py", + "line": 59, + "method": "PUT", + "route": "/v1/resource/7", + "auth_required": false + }, + { + "kind": "queue", + "identifier": "handler_8", + "file_path": "app/entry/e8.py", + "line": 66, + "method": "GET", + "route": "/v1/resource/8", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_9", + "file_path": "app/entry/e9.py", + "line": 73, + "method": "POST", + "route": null, + "auth_required": true + }, + { + "kind": "api", + "identifier": "handler_10", + "file_path": "app/entry/e10.py", + "line": 80, + "method": null, + "route": "/v1/resource/10", + "auth_required": false + }, + { + "kind": "grpc", + "identifier": "handler_11", + "file_path": "app/entry/e11.py", + "line": 87, + "method": "PUT", + "route": "/v1/resource/11", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_12", + "file_path": "app/entry/e12.py", + "line": 94, + "method": "GET", + "route": null, + "auth_required": true + }, + { + "kind": "route", + "identifier": "handler_13", + "file_path": "app/entry/e13.py", + "line": 101, + "method": "POST", + "route": "/v1/resource/13", + "auth_required": false + }, + { + "kind": "cli", + "identifier": "handler_14", + "file_path": "app/entry/e14.py", + "line": 108, + "method": null, + "route": "/v1/resource/14", + "auth_required": null + }, + { + "kind": "http", + "identifier": "handler_15", + "file_path": "app/entry/e15.py", + "line": 115, + "method": "PUT", + "route": null, + "auth_required": true + }, + { + "kind": "websocket", + "identifier": "handler_16", + "file_path": "app/entry/e16.py", + "line": 122, + "method": "GET", + "route": "/v1/resource/16", + "auth_required": false + } + ], + "trust_boundaries": [ + { + "name": "boundary_0", + "source_zone": "internet", + "target_zone": "app", + "description": "Transition 0", + "enforcement": [] + }, + { + "name": "boundary_1", + "source_zone": "dmz", + "target_zone": "db", + "description": "Transition 1", + "enforcement": [ + "waf_1", + "mtls_1" + ] + }, + { + "name": "boundary_2", + "source_zone": "vpc", + "target_zone": "cache", + "description": "Transition 2", + "enforcement": [ + "waf_2", + "mtls_2" + ] + }, + { + "name": "boundary_3", + "source_zone": "worker", + "target_zone": "queue", + "description": "Transition 3", + "enforcement": [ + "waf_3", + "mtls_3" + ] + }, + { + "name": "boundary_4", + "source_zone": "internet", + "target_zone": "app", + "description": "Transition 4", + "enforcement": [] + }, + { + "name": "boundary_5", + "source_zone": "dmz", + "target_zone": "db", + "description": "Transition 5", + "enforcement": [ + "waf_5", + "mtls_5" + ] + }, + { + "name": "boundary_6", + "source_zone": "vpc", + "target_zone": "cache", + "description": "Transition 6", + "enforcement": [ + "waf_6", + "mtls_6" + ] + }, + { + "name": "boundary_7", + "source_zone": "worker", + "target_zone": "queue", + "description": "Transition 7", + "enforcement": [ + "waf_7", + "mtls_7" + ] + }, + { + "name": "boundary_8", + "source_zone": "internet", + "target_zone": "app", + "description": "Transition 8", + "enforcement": [] + }, + { + "name": "boundary_9", + "source_zone": "dmz", + "target_zone": "db", + "description": "Transition 9", + "enforcement": [ + "waf_9", + "mtls_9" + ] + }, + { + "name": "boundary_10", + "source_zone": "vpc", + "target_zone": "cache", + "description": "Transition 10", + "enforcement": [ + "waf_10", + "mtls_10" + ] + }, + { + "name": "boundary_11", + "source_zone": "worker", + "target_zone": "queue", + "description": "Transition 11", + "enforcement": [ + "waf_11", + "mtls_11" + ] + } + ], + "services": [ + { + "name": "postgres", + "service_type": "database", + "endpoint": "postgres://db:5432", + "purpose": "primary store", + "auth_mechanism": "password" + }, + { + "name": "redis", + "service_type": "cache", + "endpoint": null, + "purpose": "sessions", + "auth_mechanism": null + }, + { + "name": "stripe", + "service_type": "payments", + "endpoint": "https://api.stripe.com", + "purpose": null, + "auth_mechanism": "api_key" + }, + { + "name": "s3", + "service_type": "object_store", + "endpoint": "https://s3.amazonaws.com", + "purpose": "uploads", + "auth_mechanism": "iam" + }, + { + "name": "smtp", + "service_type": "mail", + "endpoint": null, + "purpose": "transactional email", + "auth_mechanism": null + } + ], + "api_surface": [ + { + "method": "GET", + "path": "/api/v1/thing/0", + "handler": "ThingController.action0", + "file_path": "app/api/thing_0.py", + "line": 100, + "auth_required": true, + "rate_limited": null + }, + { + "method": "POST", + "path": "/api/v1/thing/1", + "handler": "ThingController.action1", + "file_path": "app/api/thing_1.py", + "line": 103, + "auth_required": false, + "rate_limited": false + }, + { + "method": "PUT", + "path": "/api/v1/thing/2", + "handler": "ThingController.action2", + "file_path": "app/api/thing_2.py", + "line": 106, + "auth_required": null, + "rate_limited": true + }, + { + "method": "DELETE", + "path": "/api/v1/thing/3", + "handler": "ThingController.action3", + "file_path": "app/api/thing_3.py", + "line": 109, + "auth_required": false, + "rate_limited": false + }, + { + "method": "PATCH", + "path": "/api/v1/thing/4", + "handler": "ThingController.action4", + "file_path": "app/api/thing_4.py", + "line": 112, + "auth_required": true, + "rate_limited": null + }, + { + "method": "GET", + "path": "/api/v1/thing/5", + "handler": "ThingController.action5", + "file_path": "app/api/thing_5.py", + "line": 115, + "auth_required": null, + "rate_limited": true + }, + { + "method": "POST", + "path": "/api/v1/thing/6", + "handler": "ThingController.action6", + "file_path": "app/api/thing_6.py", + "line": 118, + "auth_required": true, + "rate_limited": null + }, + { + "method": "PUT", + "path": "/api/v1/thing/7", + "handler": "ThingController.action7", + "file_path": "app/api/thing_7.py", + "line": 121, + "auth_required": false, + "rate_limited": false + }, + { + "method": "DELETE", + "path": "/api/v1/thing/8", + "handler": "ThingController.action8", + "file_path": "app/api/thing_8.py", + "line": 124, + "auth_required": null, + "rate_limited": true + }, + { + "method": "PATCH", + "path": "/api/v1/thing/9", + "handler": "ThingController.action9", + "file_path": "app/api/thing_9.py", + "line": 127, + "auth_required": false, + "rate_limited": false + }, + { + "method": "GET", + "path": "/api/v1/thing/10", + "handler": "ThingController.action10", + "file_path": "app/api/thing_10.py", + "line": 130, + "auth_required": true, + "rate_limited": null + }, + { + "method": "POST", + "path": "/api/v1/thing/11", + "handler": "ThingController.action11", + "file_path": "app/api/thing_11.py", + "line": 133, + "auth_required": null, + "rate_limited": true + }, + { + "method": "PUT", + "path": "/api/v1/thing/12", + "handler": "ThingController.action12", + "file_path": "app/api/thing_12.py", + "line": 136, + "auth_required": true, + "rate_limited": null + }, + { + "method": "DELETE", + "path": "/api/v1/thing/13", + "handler": "ThingController.action13", + "file_path": "app/api/thing_13.py", + "line": 139, + "auth_required": false, + "rate_limited": false + }, + { + "method": "PATCH", + "path": "/api/v1/thing/14", + "handler": "ThingController.action14", + "file_path": "app/api/thing_14.py", + "line": 142, + "auth_required": null, + "rate_limited": true + }, + { + "method": "GET", + "path": "/api/v1/thing/15", + "handler": "ThingController.action15", + "file_path": "app/api/thing_15.py", + "line": 145, + "auth_required": false, + "rate_limited": false + }, + { + "method": "POST", + "path": "/api/v1/thing/16", + "handler": "ThingController.action16", + "file_path": "app/api/thing_16.py", + "line": 148, + "auth_required": true, + "rate_limited": null + }, + { + "method": "PUT", + "path": "/api/v1/thing/17", + "handler": "ThingController.action17", + "file_path": "app/api/thing_17.py", + "line": 151, + "auth_required": null, + "rate_limited": true + }, + { + "method": "DELETE", + "path": "/api/v1/thing/18", + "handler": "ThingController.action18", + "file_path": "app/api/thing_18.py", + "line": 154, + "auth_required": true, + "rate_limited": null + }, + { + "method": "PATCH", + "path": "/api/v1/thing/19", + "handler": "ThingController.action19", + "file_path": "app/api/thing_19.py", + "line": 157, + "auth_required": false, + "rate_limited": false + } + ] + }, + "data_flows": { + "flows": [ + { + "source": "request.args['q']", + "path": [ + { + "file_path": "app/search/index.ts", + "line": 20, + "component": "step_0_a", + "operation": "read" + }, + { + "file_path": "app/util/b.py", + "line": 40, + "component": "step_0_b", + "operation": "write" + } + ], + "sink": "cursor.execute", + "sanitized": false, + "files": [ + "app/search/index.ts", + "app/db/raw.py", + "app/util/a.py", + "app/util/b.py" + ] + }, + { + "source": "request.cookies['session']", + "path": [ + { + "file_path": "app/auth/session_store.py", + "line": 21, + "component": "step_1_a", + "operation": "read" + }, + { + "file_path": "app/auth/session_store.py", + "line": 41, + "component": "step_1_b", + "operation": "write" + } + ], + "sink": "redis.set", + "sanitized": true, + "files": [ + "app/auth/session_store.py" + ] + }, + { + "source": "request.json['url']", + "path": [ + { + "file_path": "app/integrations/webhooks.go", + "line": 22, + "component": "step_2_a", + "operation": "read" + }, + { + "file_path": "app/net/client.go", + "line": 42, + "component": "step_2_b", + "operation": "write" + } + ], + "sink": "requests.get", + "sanitized": false, + "files": [ + "app/integrations/webhooks.go", + "app/net/client.go" + ] + }, + { + "source": "form['password']", + "path": [ + { + "file_path": "app/auth/service.py", + "line": 23, + "component": "step_3_a", + "operation": "read" + }, + { + "file_path": "app/obs/telemetry.go", + "line": 43, + "component": "step_3_b", + "operation": "write" + } + ], + "sink": "logger.info", + "sanitized": false, + "files": [ + "app/auth/service.py", + "app/obs/telemetry.go" + ] + }, + { + "source": "header['Authorization']", + "path": [ + { + "file_path": "app/common/jwt_tools.py", + "line": 24, + "component": "step_4_a", + "operation": "read" + }, + { + "file_path": "app/common/jwt_tools.py", + "line": 44, + "component": "step_4_b", + "operation": "write" + } + ], + "sink": "jwt.decode", + "sanitized": true, + "files": [ + "app/common/jwt_tools.py" + ] + }, + { + "source": "query['email']", + "path": [ + { + "file_path": "app/notify/mailer.rb", + "line": 25, + "component": "step_5_a", + "operation": "read" + }, + { + "file_path": "app/notify/mailer.rb", + "line": 45, + "component": "step_5_b", + "operation": "write" + } + ], + "sink": "smtp.send", + "sanitized": false, + "files": [ + "app/notify/mailer.rb" + ] + }, + { + "source": "body['card_number']", + "path": [ + { + "file_path": "app/billing/payments.go", + "line": 26, + "component": "step_6_a", + "operation": "read" + }, + { + "file_path": "app/billing/core.py", + "line": 46, + "component": "step_6_b", + "operation": "write" + } + ], + "sink": "stripe.Charge.create", + "sanitized": true, + "files": [ + "app/billing/payments.go", + "app/billing/core.py" + ] + }, + { + "source": "path_param['id']", + "path": [ + { + "file_path": "app/api/thing_1.py", + "line": 27, + "component": "step_7_a", + "operation": "read" + }, + { + "file_path": "app/api/thing_1.py", + "line": 47, + "component": "step_7_b", + "operation": "write" + } + ], + "sink": "orm.filter", + "sanitized": true, + "files": [ + "app/api/thing_1.py" + ] + }, + { + "source": "upload.filename", + "path": [ + { + "file_path": "app/media/uploads.py", + "line": 28, + "component": "step_8_a", + "operation": "read" + }, + { + "file_path": "app/x/w.py", + "line": 48, + "component": "step_8_b", + "operation": "write" + } + ], + "sink": "open", + "sanitized": false, + "files": [ + "app/media/uploads.py", + "app/common/cache.py", + "app/common/guard.py", + "app/x/y.py", + "app/x/z.py", + "app/x/w.py" + ] + }, + { + "source": "env['DEBUG']", + "path": [ + { + "file_path": "app/web/render.py", + "line": 29, + "component": "step_9_a", + "operation": "read" + }, + { + "file_path": "app/web/render.py", + "line": 49, + "component": "step_9_b", + "operation": "write" + } + ], + "sink": "template.render", + "sanitized": false, + "files": [ + "app/web/render.py" + ] + }, + { + "source": "request.body", + "path": [ + { + "file_path": "app/admin/views.py", + "line": 30, + "component": "step_10_a", + "operation": "read" + }, + { + "file_path": "app/admin/views.py", + "line": 50, + "component": "step_10_b", + "operation": "write" + } + ], + "sink": "eval", + "sanitized": false, + "files": [ + "app/admin/views.py" + ] + }, + { + "source": "websocket.msg", + "path": [ + { + "file_path": "app/ws/handler.py", + "line": 31, + "component": "step_11_a", + "operation": "read" + }, + { + "file_path": "app/ws/exec.py", + "line": 51, + "component": "step_11_b", + "operation": "write" + } + ], + "sink": "subprocess.run", + "sanitized": false, + "files": [ + "app/ws/handler.py", + "app/ws/exec.py" + ] + }, + { + "source": "cli.argv", + "path": [ + { + "file_path": "tools/run.py", + "line": 32, + "component": "step_12_a", + "operation": "read" + }, + { + "file_path": "tools/run.py", + "line": 52, + "component": "step_12_b", + "operation": "write" + } + ], + "sink": "os.system", + "sanitized": false, + "files": [ + "tools/run.py" + ] + }, + { + "source": "queue.payload", + "path": [ + { + "file_path": "app/worker/task.py", + "line": 33, + "component": "step_13_a", + "operation": "read" + }, + { + "file_path": "app/worker/task.py", + "line": 53, + "component": "step_13_b", + "operation": "write" + } + ], + "sink": "pickle.loads", + "sanitized": false, + "files": [ + "app/worker/task.py" + ] + }, + { + "source": "request.args['redirect']", + "path": [ + { + "file_path": "app/web/redirect.py", + "line": 34, + "component": "step_14_a", + "operation": "read" + }, + { + "file_path": "app/web/redirect.py", + "line": 54, + "component": "step_14_b", + "operation": "write" + } + ], + "sink": "HttpResponseRedirect", + "sanitized": false, + "files": [ + "app/web/redirect.py" + ] + }, + { + "source": "session['role']", + "path": [ + { + "file_path": "app/auth/permissions.py", + "line": 35, + "component": "step_15_a", + "operation": "read" + }, + { + "file_path": "app/auth/rbac.py", + "line": 55, + "component": "step_15_b", + "operation": "write" + } + ], + "sink": "permission_check", + "sanitized": true, + "files": [ + "app/auth/permissions.py", + "app/auth/rbac.py" + ] + }, + { + "source": "request.files['avatar']", + "path": [ + { + "file_path": "app/media/uploads.py", + "line": 36, + "component": "step_16_a", + "operation": "read" + }, + { + "file_path": "app/media/uploads.py", + "line": 56, + "component": "step_16_b", + "operation": "write" + } + ], + "sink": "s3.put_object", + "sanitized": true, + "files": [ + "app/media/uploads.py" + ] + }, + { + "source": "token", + "path": [ + { + "file_path": "app/common/cache.py", + "line": 37, + "component": "step_17_a", + "operation": "read" + }, + { + "file_path": "app/common/cache.py", + "line": 57, + "component": "step_17_b", + "operation": "write" + } + ], + "sink": "cache.set", + "sanitized": true, + "files": [ + "app/common/cache.py" + ] + }, + { + "source": "request.headers['X-User-Phone']", + "path": [ + { + "file_path": "app/obs/audit.py", + "line": 38, + "component": "step_18_a", + "operation": "read" + }, + { + "file_path": "app/obs/audit.py", + "line": 58, + "component": "step_18_b", + "operation": "write" + } + ], + "sink": "audit_log", + "sanitized": false, + "files": [ + "app/obs/audit.py" + ] + }, + { + "source": "graphql.variables", + "path": [ + { + "file_path": "app/api/graphql.ts", + "line": 39, + "component": "step_19_a", + "operation": "read" + }, + { + "file_path": "app/api/graphql.ts", + "line": 59, + "component": "step_19_b", + "operation": "write" + } + ], + "sink": "db.query", + "sanitized": false, + "files": [ + "app/api/graphql.ts" + ] + } + ], + "sanitization_points": [ + { + "file_path": "app/sanitize/s0.py", + "line": 5, + "function_name": null, + "sanitization_type": "escape", + "protects_against": [] + }, + { + "file_path": "app/sanitize/s1.py", + "line": 9, + "function_name": "clean_1", + "sanitization_type": "parameterize", + "protects_against": [ + "CWE-80", + "CWE-90" + ] + }, + { + "file_path": "app/sanitize/s2.py", + "line": 13, + "function_name": "clean_2", + "sanitization_type": "allowlist", + "protects_against": [ + "CWE-81", + "CWE-91" + ] + }, + { + "file_path": "app/sanitize/s3.py", + "line": 17, + "function_name": "clean_3", + "sanitization_type": "encode", + "protects_against": [] + }, + { + "file_path": "app/sanitize/s4.py", + "line": 21, + "function_name": "clean_4", + "sanitization_type": "escape", + "protects_against": [ + "CWE-83", + "CWE-93" + ] + }, + { + "file_path": "app/sanitize/s5.py", + "line": 25, + "function_name": null, + "sanitization_type": "parameterize", + "protects_against": [ + "CWE-84", + "CWE-94" + ] + }, + { + "file_path": "app/sanitize/s6.py", + "line": 29, + "function_name": "clean_6", + "sanitization_type": "allowlist", + "protects_against": [] + }, + { + "file_path": "app/sanitize/s7.py", + "line": 33, + "function_name": "clean_7", + "sanitization_type": "encode", + "protects_against": [ + "CWE-86", + "CWE-96" + ] + }, + { + "file_path": "app/sanitize/s8.py", + "line": 37, + "function_name": "clean_8", + "sanitization_type": "escape", + "protects_against": [ + "CWE-87", + "CWE-97" + ] + }, + { + "file_path": "app/sanitize/s9.py", + "line": 41, + "function_name": "clean_9", + "sanitization_type": "parameterize", + "protects_against": [] + }, + { + "file_path": "app/sanitize/s10.py", + "line": 45, + "function_name": null, + "sanitization_type": "allowlist", + "protects_against": [ + "CWE-89", + "CWE-99" + ] + }, + { + "file_path": "app/sanitize/s11.py", + "line": 49, + "function_name": "clean_11", + "sanitization_type": "encode", + "protects_against": [ + "CWE-90", + "CWE-100" + ] + } + ], + "sinks": [ + { + "sink_type": "sql", + "file_path": "app/sink/k0.py", + "line": 200, + "function_name": null, + "exploitability_notes": null + }, + { + "sink_type": "command", + "file_path": "app/sink/k1.py", + "line": 202, + "function_name": "sink_fn_1", + "exploitability_notes": "note 1" + }, + { + "sink_type": "template", + "file_path": "app/sink/k2.py", + "line": 204, + "function_name": "sink_fn_2", + "exploitability_notes": "note 2" + }, + { + "sink_type": "file", + "file_path": "app/sink/k3.py", + "line": 206, + "function_name": "sink_fn_3", + "exploitability_notes": null + }, + { + "sink_type": "network", + "file_path": "app/sink/k4.py", + "line": 208, + "function_name": null, + "exploitability_notes": "note 4" + }, + { + "sink_type": "deserialization", + "file_path": "app/sink/k5.py", + "line": 210, + "function_name": "sink_fn_5", + "exploitability_notes": "note 5" + }, + { + "sink_type": "sql", + "file_path": "app/sink/k6.py", + "line": 212, + "function_name": "sink_fn_6", + "exploitability_notes": null + }, + { + "sink_type": "command", + "file_path": "app/sink/k7.py", + "line": 214, + "function_name": "sink_fn_7", + "exploitability_notes": "note 7" + }, + { + "sink_type": "template", + "file_path": "app/sink/k8.py", + "line": 216, + "function_name": null, + "exploitability_notes": "note 8" + }, + { + "sink_type": "file", + "file_path": "app/sink/k9.py", + "line": 218, + "function_name": "sink_fn_9", + "exploitability_notes": null + }, + { + "sink_type": "network", + "file_path": "app/sink/k10.py", + "line": 220, + "function_name": "sink_fn_10", + "exploitability_notes": "note 10" + }, + { + "sink_type": "deserialization", + "file_path": "app/sink/k11.py", + "line": 222, + "function_name": "sink_fn_11", + "exploitability_notes": "note 11" + }, + { + "sink_type": "sql", + "file_path": "app/sink/k12.py", + "line": 224, + "function_name": null, + "exploitability_notes": null + }, + { + "sink_type": "command", + "file_path": "app/sink/k13.py", + "line": 226, + "function_name": "sink_fn_13", + "exploitability_notes": "note 13" + }, + { + "sink_type": "template", + "file_path": "app/sink/k14.py", + "line": 228, + "function_name": "sink_fn_14", + "exploitability_notes": "note 14" + }, + { + "sink_type": "file", + "file_path": "app/sink/k15.py", + "line": 230, + "function_name": "sink_fn_15", + "exploitability_notes": null + }, + { + "sink_type": "network", + "file_path": "app/sink/k16.py", + "line": 232, + "function_name": null, + "exploitability_notes": "note 16" + }, + { + "sink_type": "deserialization", + "file_path": "app/sink/k17.py", + "line": 234, + "function_name": "sink_fn_17", + "exploitability_notes": "note 17" + } + ] + }, + "dependencies": { + "sbom": [ + { + "name": "django", + "version": "4.2.1", + "ecosystem": "pypi", + "direct": true, + "license": "BSD-3-Clause" + }, + { + "name": "requests", + "version": "2.31.0", + "ecosystem": "pypi", + "direct": true, + "license": "Apache-2.0" + }, + { + "name": "urllib3", + "version": "1.26.5", + "ecosystem": "pypi", + "direct": false, + "license": "MIT" + }, + { + "name": "jinja2", + "version": "3.1.2", + "ecosystem": "pypi", + "direct": false, + "license": null + }, + { + "name": "lodash", + "version": "4.17.19", + "ecosystem": "npm", + "direct": true, + "license": "MIT" + }, + { + "name": "express", + "version": "4.17.1", + "ecosystem": "npm", + "direct": true, + "license": "MIT" + }, + { + "name": "minimist", + "version": "1.2.5", + "ecosystem": "npm", + "direct": false, + "license": "MIT" + }, + { + "name": "axios", + "version": "0.21.1", + "ecosystem": "npm", + "direct": false, + "license": "MIT" + }, + { + "name": "github.com/gin-gonic/gin", + "version": "1.9.0", + "ecosystem": "go", + "direct": true, + "license": "MIT" + }, + { + "name": "golang.org/x/net", + "version": "0.7.0", + "ecosystem": "go", + "direct": false, + "license": "BSD-3-Clause" + }, + { + "name": "rails", + "version": "7.0.4", + "ecosystem": "rubygems", + "direct": true, + "license": "MIT" + }, + { + "name": "nokogiri", + "version": "1.13.6", + "ecosystem": "rubygems", + "direct": false, + "license": "MIT" + }, + { + "name": "django", + "version": "4.2.1", + "ecosystem": "pypi", + "direct": true, + "license": "BSD-3-Clause" + }, + { + "name": "requests", + "version": "2.31.0", + "ecosystem": "pypi", + "direct": true, + "license": "Apache-2.0" + } + ], + "known_cves": [ + { + "cve_id": "CVE-2023-0001", + "package": "django", + "installed_version": "4.2.1", + "fixed_version": "4.2.5", + "cvss_v4_score": 9.8, + "epss_score": 0.5, + "direct": true, + "reachable": true + }, + { + "cve_id": "CVE-2023-0002", + "package": "urllib3", + "installed_version": "1.26.5", + "fixed_version": null, + "cvss_v4_score": 7.5, + "epss_score": 0.1, + "direct": false, + "reachable": false + }, + { + "cve_id": "CVE-2023-0003", + "package": "lodash", + "installed_version": "4.17.19", + "fixed_version": "4.17.21", + "cvss_v4_score": 9.8, + "epss_score": 0.9, + "direct": true, + "reachable": true + }, + { + "cve_id": "CVE-2023-0004", + "package": "minimist", + "installed_version": "1.2.5", + "fixed_version": "1.2.6", + "cvss_v4_score": null, + "epss_score": null, + "direct": false, + "reachable": null + }, + { + "cve_id": "CVE-2023-0005", + "package": "axios", + "installed_version": "0.21.1", + "fixed_version": "0.21.2", + "cvss_v4_score": 5.3, + "epss_score": 0.02, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0006", + "package": "jinja2", + "installed_version": "3.1.2", + "fixed_version": null, + "cvss_v4_score": 6.1, + "epss_score": null, + "direct": false, + "reachable": false + }, + { + "cve_id": "CVE-2023-0007", + "package": "express", + "installed_version": "4.17.1", + "fixed_version": "4.18.0", + "cvss_v4_score": 4.3, + "epss_score": 0.005, + "direct": true, + "reachable": null + }, + { + "cve_id": "CVE-2023-0008", + "package": "nokogiri", + "installed_version": "1.13.6", + "fixed_version": "1.13.10", + "cvss_v4_score": 8.8, + "epss_score": 0.3, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0009", + "package": "golang.org/x/net", + "installed_version": "0.7.0", + "fixed_version": "0.17.0", + "cvss_v4_score": 7.5, + "epss_score": 0.44, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0010", + "package": "rails", + "installed_version": "7.0.4", + "fixed_version": "7.0.7", + "cvss_v4_score": 9.1, + "epss_score": 0.6, + "direct": true, + "reachable": false + }, + { + "cve_id": "CVE-2023-0011", + "package": "gin", + "installed_version": "1.9.0", + "fixed_version": null, + "cvss_v4_score": 3.7, + "epss_score": 0.001, + "direct": true, + "reachable": null + }, + { + "cve_id": "CVE-2023-0012", + "package": "requests", + "installed_version": "2.31.0", + "fixed_version": "2.32.0", + "cvss_v4_score": 6.5, + "epss_score": 0.07, + "direct": true, + "reachable": true + }, + { + "cve_id": "CVE-2023-0013", + "package": "pyyaml", + "installed_version": "5.3", + "fixed_version": "5.4", + "cvss_v4_score": 9.8, + "epss_score": 0.5, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0014", + "package": "log4j", + "installed_version": "2.14.0", + "fixed_version": "2.17.1", + "cvss_v4_score": 10.0, + "epss_score": 0.97, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0015", + "package": "openssl", + "installed_version": "1.1.1k", + "fixed_version": "1.1.1t", + "cvss_v4_score": 7.4, + "epss_score": 0.12, + "direct": false, + "reachable": null + }, + { + "cve_id": "CVE-2023-0016", + "package": "zlib", + "installed_version": "1.2.11", + "fixed_version": "1.2.12", + "cvss_v4_score": 8.2, + "epss_score": 0.04, + "direct": false, + "reachable": false + }, + { + "cve_id": "CVE-2023-0017", + "package": "libxml2", + "installed_version": "2.9.10", + "fixed_version": null, + "cvss_v4_score": null, + "epss_score": 0.25, + "direct": false, + "reachable": true + }, + { + "cve_id": "CVE-2023-0018", + "package": "curl", + "installed_version": "7.68.0", + "fixed_version": "7.88.0", + "cvss_v4_score": 8.1, + "epss_score": null, + "direct": true, + "reachable": true + } + ], + "outdated": [ + { + "package": "pkg-0", + "current_version": "1.0.0", + "latest_version": "2.0.0", + "direct": true + }, + { + "package": "pkg-1", + "current_version": "1.1.0", + "latest_version": "2.1.0", + "direct": false + }, + { + "package": "pkg-2", + "current_version": "1.2.0", + "latest_version": "2.2.0", + "direct": true + }, + { + "package": "pkg-3", + "current_version": "1.3.0", + "latest_version": "2.3.0", + "direct": false + }, + { + "package": "pkg-4", + "current_version": "1.4.0", + "latest_version": "2.4.0", + "direct": true + }, + { + "package": "pkg-5", + "current_version": "1.5.0", + "latest_version": "2.5.0", + "direct": false + }, + { + "package": "pkg-6", + "current_version": "1.6.0", + "latest_version": "2.6.0", + "direct": true + }, + { + "package": "pkg-7", + "current_version": "1.7.0", + "latest_version": "2.7.0", + "direct": false + }, + { + "package": "pkg-8", + "current_version": "1.8.0", + "latest_version": "2.8.0", + "direct": true + }, + { + "package": "pkg-9", + "current_version": "1.9.0", + "latest_version": "2.9.0", + "direct": false + }, + { + "package": "pkg-10", + "current_version": "1.10.0", + "latest_version": "2.10.0", + "direct": true + }, + { + "package": "pkg-11", + "current_version": "1.11.0", + "latest_version": "2.11.0", + "direct": false + } + ], + "direct_count": 7, + "transitive_count": 143 + }, + "config": { + "secrets": [ + { + "id": "secret-00", + "secret_type": "aws_access_key", + "file_path": "config/env_0.yaml", + "line": 3, + "match": "AKIA****0000", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-01", + "secret_type": "github_token", + "file_path": "config/env_1.yaml", + "line": 4, + "match": "AKIA****0001", + "confidence": "medium", + "is_test_value": true + }, + { + "id": "secret-02", + "secret_type": "private_key", + "file_path": "config/env_2.yaml", + "line": 5, + "match": "AKIA****0002", + "confidence": "low", + "is_test_value": null + }, + { + "id": "secret-03", + "secret_type": "slack_webhook", + "file_path": "config/env_3.yaml", + "line": 6, + "match": "AKIA****0003", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-04", + "secret_type": "generic_api_key", + "file_path": "config/env_4.yaml", + "line": 7, + "match": "AKIA****0004", + "confidence": "medium", + "is_test_value": true + }, + { + "id": "secret-05", + "secret_type": "aws_access_key", + "file_path": "config/env_5.yaml", + "line": 8, + "match": "AKIA****0005", + "confidence": "low", + "is_test_value": null + }, + { + "id": "secret-06", + "secret_type": "github_token", + "file_path": "config/env_6.yaml", + "line": 9, + "match": "AKIA****0006", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-07", + "secret_type": "private_key", + "file_path": "config/env_7.yaml", + "line": 10, + "match": "AKIA****0007", + "confidence": "medium", + "is_test_value": true + }, + { + "id": "secret-08", + "secret_type": "slack_webhook", + "file_path": "config/env_8.yaml", + "line": 11, + "match": "AKIA****0008", + "confidence": "low", + "is_test_value": null + }, + { + "id": "secret-09", + "secret_type": "generic_api_key", + "file_path": "config/env_9.yaml", + "line": 12, + "match": "AKIA****0009", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-10", + "secret_type": "aws_access_key", + "file_path": "config/env_10.yaml", + "line": 13, + "match": "AKIA****0010", + "confidence": "medium", + "is_test_value": true + }, + { + "id": "secret-11", + "secret_type": "github_token", + "file_path": "config/env_11.yaml", + "line": 14, + "match": "AKIA****0011", + "confidence": "low", + "is_test_value": null + }, + { + "id": "secret-12", + "secret_type": "private_key", + "file_path": "config/env_12.yaml", + "line": 15, + "match": "AKIA****0012", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-13", + "secret_type": "slack_webhook", + "file_path": "config/env_13.yaml", + "line": 16, + "match": "AKIA****0013", + "confidence": "medium", + "is_test_value": true + }, + { + "id": "secret-14", + "secret_type": "generic_api_key", + "file_path": "config/env_14.yaml", + "line": 17, + "match": "AKIA****0014", + "confidence": "low", + "is_test_value": null + }, + { + "id": "secret-15", + "secret_type": "aws_access_key", + "file_path": "config/env_15.yaml", + "line": 18, + "match": "AKIA****0015", + "confidence": "high", + "is_test_value": false + }, + { + "id": "secret-16", + "secret_type": "github_token", + "file_path": "config/env_16.yaml", + "line": 19, + "match": "AKIA****0016", + "confidence": "medium", + "is_test_value": true + } + ], + "misconfigs": [ + { + "id": "misconfig-00", + "category": "logging", + "file_path": "config/app_0.ini", + "line": null, + "key": "LOG_LEVEL", + "value": "DEBUG", + "risk": "high", + "remediation": "Debug logging in production" + }, + { + "id": "misconfig-01", + "category": "tls", + "file_path": "config/app_1.ini", + "line": 13, + "key": "SSL_VERIFY", + "value": "false", + "risk": "critical", + "remediation": "TLS verification disabled" + }, + { + "id": "misconfig-02", + "category": "cors", + "file_path": "config/app_2.ini", + "line": 14, + "key": "ALLOW_ORIGIN", + "value": "*", + "risk": "high", + "remediation": null + }, + { + "id": "misconfig-03", + "category": "headers", + "file_path": "config/app_3.ini", + "line": 0, + "key": "X_FRAME_OPTIONS", + "value": null, + "risk": "medium", + "remediation": "Missing clickjacking header" + }, + { + "id": "misconfig-04", + "category": "debug", + "file_path": "config/app_4.ini", + "line": 16, + "key": "DJANGO_DEBUG", + "value": "True", + "risk": "critical", + "remediation": "Debug mode enabled" + }, + { + "id": "misconfig-05", + "category": "storage", + "file_path": "config/app_5.ini", + "line": null, + "key": "BUCKET_ACL", + "value": "public-read", + "risk": "critical", + "remediation": "Public bucket" + }, + { + "id": "misconfig-06", + "category": "auth", + "file_path": "config/app_6.ini", + "line": 18, + "key": "SESSION_TIMEOUT", + "value": "999999", + "risk": "medium", + "remediation": null + }, + { + "id": "misconfig-07", + "category": "http", + "file_path": "config/app_7.ini", + "line": 19, + "key": "REDIRECT_HTTPS", + "value": "false", + "risk": "high", + "remediation": "Plain HTTP allowed" + }, + { + "id": "misconfig-08", + "category": "secrets", + "file_path": "config/app_8.ini", + "line": 20, + "key": null, + "value": "inline", + "risk": "medium", + "remediation": "Inline secret" + }, + { + "id": "misconfig-09", + "category": "trace", + "file_path": "config/app_9.ini", + "line": 21, + "key": "OTEL_TRACE_ALL", + "value": "true", + "risk": "low", + "remediation": "Verbose tracing" + }, + { + "id": "misconfig-10", + "category": "network", + "file_path": "config/app_10.ini", + "line": null, + "key": "BIND_ADDR", + "value": "0.0.0.0", + "risk": "medium", + "remediation": null + }, + { + "id": "misconfig-11", + "category": "errors", + "file_path": "config/app_11.ini", + "line": 23, + "key": "SHOW_STACKTRACE", + "value": "true", + "risk": "high", + "remediation": "Error stacktraces exposed" + }, + { + "id": "misconfig-12", + "category": "cache", + "file_path": "config/app_12.ini", + "line": 24, + "key": "CACHE_TTL", + "value": "0", + "risk": "low", + "remediation": null + }, + { + "id": "misconfig-13", + "category": "db", + "file_path": "config/app_13.ini", + "line": 25, + "key": "SSLMODE", + "value": "disable", + "risk": "critical", + "remediation": "Database TLS off" + }, + { + "id": "misconfig-14", + "category": "queue", + "file_path": "config/app_14.ini", + "line": 26, + "key": "PREFETCH", + "value": "1000", + "risk": "low", + "remediation": null + }, + { + "id": "misconfig-15", + "category": "exposure", + "file_path": "config/app_15.ini", + "line": null, + "key": "ADMIN_PATH", + "value": "/admin", + "risk": "medium", + "remediation": "Admin surface exposed" + } + ] + }, + "security_context": { + "auth_model": "jwt", + "auth_details": "HS256 JWTs issued by app/auth/service.py, refresh tokens in Redis", + "crypto_usage": [ + { + "algorithm": "AES", + "key_size": 256, + "mode": "GCM", + "usage_context": "at-rest encryption", + "is_weak": false + }, + { + "algorithm": "MD5", + "key_size": null, + "mode": null, + "usage_context": "password hashing", + "is_weak": true + }, + { + "algorithm": "RSA", + "key_size": 1024, + "mode": null, + "usage_context": "token signing", + "is_weak": true + }, + { + "algorithm": "SHA-256", + "key_size": null, + "mode": null, + "usage_context": null, + "is_weak": false + }, + { + "algorithm": "DES", + "key_size": 56, + "mode": "CBC", + "usage_context": "legacy export", + "is_weak": true + }, + { + "algorithm": "HMAC-SHA1", + "key_size": 160, + "mode": null, + "usage_context": "webhook signature", + "is_weak": true + }, + { + "algorithm": "ChaCha20", + "key_size": 256, + "mode": "Poly1305", + "usage_context": "transport", + "is_weak": false + }, + { + "algorithm": "bcrypt", + "key_size": null, + "mode": null, + "usage_context": "password hashing", + "is_weak": false + }, + { + "algorithm": "RC4", + "key_size": 128, + "mode": null, + "usage_context": null, + "is_weak": true + }, + { + "algorithm": "ECDSA", + "key_size": 256, + "mode": null, + "usage_context": "JWT signing", + "is_weak": null + }, + { + "algorithm": "PBKDF2", + "key_size": null, + "mode": null, + "usage_context": "key derivation", + "is_weak": false + }, + { + "algorithm": "SHA-1", + "key_size": null, + "mode": null, + "usage_context": "checksum", + "is_weak": true + }, + { + "algorithm": "AES", + "key_size": 128, + "mode": "ECB", + "usage_context": "legacy blob", + "is_weak": true + }, + { + "algorithm": "Ed25519", + "key_size": 256, + "mode": null, + "usage_context": "package signing", + "is_weak": false + }, + { + "algorithm": "3DES", + "key_size": 168, + "mode": "CBC", + "usage_context": "legacy tape", + "is_weak": true + }, + { + "algorithm": "Argon2id", + "key_size": null, + "mode": null, + "usage_context": "password hashing", + "is_weak": null + } + ], + "framework_security": [ + "django.middleware.csrf.CsrfViewMiddleware", + "", + "helmet defaults", + "SecurityMiddleware", + "rack-protection", + "spring-security filter chain" + ], + "security_headers": [ + "Content-Security-Policy: default-src 'self'", + "X-Content-Type-Options: nosniff", + "Strict-Transport-Security: max-age=31536000", + "X-Frame-Options: DENY", + "Referrer-Policy: no-referrer", + "", + "Permissions-Policy: geolocation=()" + ], + "deployment_signals": [ + "kubernetes ingress with TLS termination", + "docker-compose exposes 5432", + "no WAF in front of /api", + "secrets mounted from vault", + "readiness probe on /healthz", + "single replica for the worker", + "TLS 1.2 minimum", + "internal service mesh mTLS" + ] + }, + "languages": [ + "Python", + "JavaScript", + "Go", + "Ruby", + "Rust", + "python" + ], + "frameworks": [ + "Django", + "next.js", + "Spring Boot", + " React ", + "unknown-fw", + "NEXT", + "Vue" + ], + "lines_of_code": 184213, + "file_count": 2417, + "recon_duration_seconds": 42.5 +} diff --git a/go/internal/schemas/compliance.go b/go/internal/schemas/compliance.go new file mode 100644 index 0000000..99c87dc --- /dev/null +++ b/go/internal/schemas/compliance.go @@ -0,0 +1,31 @@ +package schemas + +// This file ports src/sec_af/schemas/compliance.py — the compliance framework +// data models (DESIGN.md section 10). Both models are fully required; neither +// needs default seeding. + +// ComplianceMapping maps a finding onto one control of one framework. +// +// Ports schemas/compliance.py ComplianceMapping. +type ComplianceMapping struct { + Framework string `json:"framework"` + ControlID string `json:"control_id"` + ControlName string `json:"control_name"` +} + +// ComplianceGap is an aggregate: one framework control with unresolved +// findings against it. +// +// Ports schemas/compliance.py ComplianceGap. Python parity: `cwe_ids` is a +// REQUIRED `list[str]` (no default_factory), so a zero-value Go struct +// marshals it as null — which is what Python does too when the caller passes +// nothing, because pydantic refuses to construct the model at all. Callers +// always supply it. +type ComplianceGap struct { + Framework string `json:"framework"` + ControlID string `json:"control_id"` + ControlName string `json:"control_name"` + FindingCount int `json:"finding_count"` + MaxSeverity string `json:"max_severity"` + CweIDs []string `json:"cwe_ids"` +} diff --git a/go/internal/schemas/defaults.go b/go/internal/schemas/defaults.go new file mode 100644 index 0000000..ee90b3b --- /dev/null +++ b/go/internal/schemas/defaults.go @@ -0,0 +1,552 @@ +package schemas + +import ( + "bytes" + "encoding/json" +) + +// This file implements non-zero-default seeding for every struct that has at +// least one field whose pydantic default is not the Go zero value — see the +// package doc for the contract. Each such struct gets: +// +// - `NewX() X`, the pydantic-default value. Go code that BUILDS an X must use +// it, otherwise `default_factory=list` fields marshal as null instead of [] +// and the non-zero scalar defaults are missing. +// - `UnmarshalJSON`, which seeds NewX() before decoding, so an absent key +// keeps the default while a present key (even false/0/""/null) overrides it +// — matching pydantic's model_validate. +// +// The `type alias X` trick strips X's methods so the inner json.Unmarshal does +// not recurse; nested field types keep their own UnmarshalJSON and seeding. +// +// Python parity: the uuid4 default_factory fields (RawFinding.ID/Fingerprint, +// PotentialChain.ChainID, SecretFinding.ID, MisconfigFinding.ID, +// VerifiedFinding.ID) are minted by the CONSTRUCTORS only. UnmarshalJSON +// deliberately leaves them at "" when the key is absent so that decoding a +// payload never mints a fresh identity mid-pipeline; the live path always +// carries the field. + +// --- hunt.go --- + +// NewScanLocationsResult returns ScanLocationsResult's pydantic defaults +// (locations=[]). +func NewScanLocationsResult() ScanLocationsResult { + return ScanLocationsResult{Locations: []VulnLocation{}} +} + +// UnmarshalJSON seeds ScanLocationsResult's list default. +func (s *ScanLocationsResult) UnmarshalJSON(b []byte) error { + *s = NewScanLocationsResult() + type alias ScanLocationsResult + return json.Unmarshal(b, (*alias)(s)) +} + +// NewRawFinding returns RawFinding's pydantic defaults: fresh uuid4 ID and +// Fingerprint, related_files=[]. data_flow stays nil (Python default None). +func NewRawFinding() RawFinding { + return RawFinding{ + ID: NewUUID4(), + RelatedFiles: []string{}, + Fingerprint: NewUUID4(), + } +} + +// UnmarshalJSON seeds RawFinding.RelatedFiles=[]. ID and Fingerprint are NOT +// seeded (see the file header). +func (r *RawFinding) UnmarshalJSON(b []byte) error { + *r = RawFinding{RelatedFiles: []string{}} + type alias RawFinding + return json.Unmarshal(b, (*alias)(r)) +} + +// NewPotentialChain returns PotentialChain's pydantic defaults: fresh uuid4 +// ChainID, finding_ids=[]. +func NewPotentialChain() PotentialChain { + return PotentialChain{ChainID: NewUUID4(), FindingIDs: []string{}} +} + +// UnmarshalJSON seeds PotentialChain.FindingIDs=[]. ChainID is NOT seeded. +func (p *PotentialChain) UnmarshalJSON(b []byte) error { + *p = PotentialChain{FindingIDs: []string{}} + type alias PotentialChain + return json.Unmarshal(b, (*alias)(p)) +} + +// NewHuntResult returns HuntResult's pydantic defaults (three empty lists, +// zeroed counters). +func NewHuntResult() HuntResult { + return HuntResult{ + Findings: []RawFinding{}, + Chains: []PotentialChain{}, + StrategiesRun: []string{}, + } +} + +// UnmarshalJSON seeds HuntResult's three list defaults. +func (h *HuntResult) UnmarshalJSON(b []byte) error { + *h = NewHuntResult() + type alias HuntResult + return json.Unmarshal(b, (*alias)(h)) +} + +// NewDeduplicatedResult returns DeduplicatedResult's pydantic defaults. +func NewDeduplicatedResult() DeduplicatedResult { + return DeduplicatedResult{Findings: []RawFinding{}, Chains: []PotentialChain{}} +} + +// UnmarshalJSON seeds DeduplicatedResult's two list defaults. +func (d *DeduplicatedResult) UnmarshalJSON(b []byte) error { + *d = NewDeduplicatedResult() + type alias DeduplicatedResult + return json.Unmarshal(b, (*alias)(d)) +} + +// NewChainCorrelationResult returns ChainCorrelationResult's pydantic defaults. +func NewChainCorrelationResult() ChainCorrelationResult { + return ChainCorrelationResult{Chains: []string{}, DuplicateIDs: []string{}} +} + +// UnmarshalJSON seeds ChainCorrelationResult's two list defaults. +func (c *ChainCorrelationResult) UnmarshalJSON(b []byte) error { + *c = NewChainCorrelationResult() + type alias ChainCorrelationResult + return json.Unmarshal(b, (*alias)(c)) +} + +// --- input.go --- + +// NewAuditInput returns AuditInput's pydantic defaults: branch="main", +// depth="standard", severity_threshold="low", +// scan_types=["sast","sca","secrets","config"], output_formats=["json"], +// exclude_paths=["tests/","vendor/","node_modules/",".git/"], and empty +// compliance_frameworks / repo_urls / custom_policies. +func NewAuditInput() AuditInput { + return AuditInput{ + Branch: "main", + Depth: "standard", + SeverityThreshold: "low", + ScanTypes: []string{"sast", "sca", "secrets", "config"}, + OutputFormats: []string{"json"}, + ComplianceFrameworks: []string{}, + ExcludePaths: []string{"tests/", "vendor/", "node_modules/", ".git/"}, + RepoUrls: []string{}, + CustomPolicies: []string{}, + } +} + +// UnmarshalJSON seeds AuditInput's pydantic defaults before decoding, so +// afx.Bind[AuditInput](payload) behaves like AuditInput.model_validate(payload). +func (a *AuditInput) UnmarshalJSON(b []byte) error { + *a = NewAuditInput() + type alias AuditInput + return json.Unmarshal(b, (*alias)(a)) +} + +// --- output.go --- + +// NewAttackChain returns AttackChain's pydantic defaults (findings=[]). +func NewAttackChain() AttackChain { + return AttackChain{Findings: []string{}} +} + +// UnmarshalJSON seeds AttackChain.Findings=[]. +func (a *AttackChain) UnmarshalJSON(b []byte) error { + *a = NewAttackChain() + type alias AttackChain + return json.Unmarshal(b, (*alias)(a)) +} + +// NewServiceDefinition returns ServiceDefinition's pydantic defaults. +func NewServiceDefinition() ServiceDefinition { + return ServiceDefinition{APIEndpoints: []string{}, Dependencies: []string{}} +} + +// UnmarshalJSON seeds ServiceDefinition's two list defaults. +func (s *ServiceDefinition) UnmarshalJSON(b []byte) error { + *s = NewServiceDefinition() + type alias ServiceDefinition + return json.Unmarshal(b, (*alias)(s)) +} + +// NewMonitoringResult returns MonitoringResult's pydantic defaults. +func NewMonitoringResult() MonitoringResult { + return MonitoringResult{ + NewFindings: []RegressionFinding{}, + FixedFindings: []RegressionFinding{}, + } +} + +// UnmarshalJSON seeds MonitoringResult's two list defaults. +func (m *MonitoringResult) UnmarshalJSON(b []byte) error { + *m = NewMonitoringResult() + type alias MonitoringResult + return json.Unmarshal(b, (*alias)(m)) +} + +// NewPolicyViolation returns PolicyViolation's pydantic defaults +// (severity="medium"). +func NewPolicyViolation() PolicyViolation { + return PolicyViolation{Severity: "medium"} +} + +// UnmarshalJSON seeds PolicyViolation.Severity="medium". +func (p *PolicyViolation) UnmarshalJSON(b []byte) error { + *p = NewPolicyViolation() + type alias PolicyViolation + return json.Unmarshal(b, (*alias)(p)) +} + +// NewSecurityAuditResult returns SecurityAuditResult's pydantic defaults: the +// five list fields empty, the three dict fields empty. +func NewSecurityAuditResult() SecurityAuditResult { + return SecurityAuditResult{ + StrategiesUsed: []string{}, + Findings: []VerifiedFinding{}, + AttackChains: []AttackChain{}, + BySeverity: map[string]int{}, + ComplianceGaps: []ComplianceGap{}, + PolicyViolations: []PolicyViolation{}, + CostBreakdown: map[string]float64{}, + Metadata: map[string]any{}, + } +} + +// UnmarshalJSON seeds SecurityAuditResult's container defaults and decodes with +// UseNumber. +// +// UseNumber is here for exactly one field: `Metadata map[string]any`, the port +// of pydantic's `metadata: dict[str, object]`. An `object`-typed value keeps +// whatever the DECODER produced, and CPython's json.loads produces an `int` for +// an integer literal and a `float` otherwise — so `{"demoted_total": 2}` +// re-serialises as "2" in Python. Go's default decode turns every number into +// float64 and would spell it "2.0". UseNumber affects ONLY values decoded into +// `interface{}`; the model's typed int/float fields are untouched, exactly as +// pydantic's are. +// +// The same compensation is applied on the live path by afx.WireNumbers, where +// the drop summary arrives already decoded from the SDK's own reader (see +// internal/node/audit.go). +func (s *SecurityAuditResult) UnmarshalJSON(b []byte) error { + *s = NewSecurityAuditResult() + type alias SecurityAuditResult + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + return dec.Decode((*alias)(s)) +} + +// NewAuditMetrics returns AuditMetrics's pydantic defaults +// (cost_breakdown={}). +func NewAuditMetrics() AuditMetrics { + return AuditMetrics{CostBreakdown: map[string]float64{}} +} + +// UnmarshalJSON seeds AuditMetrics.CostBreakdown={}. +func (a *AuditMetrics) UnmarshalJSON(b []byte) error { + *a = NewAuditMetrics() + type alias AuditMetrics + return json.Unmarshal(b, (*alias)(a)) +} + +// --- prove.go --- + +// NewDataFlowEvidence returns DataFlowEvidence's pydantic defaults (steps=[]). +func NewDataFlowEvidence() DataFlowEvidence { + return DataFlowEvidence{Steps: []DataFlowStep{}} +} + +// UnmarshalJSON seeds DataFlowEvidence.Steps=[]. +func (d *DataFlowEvidence) UnmarshalJSON(b []byte) error { + *d = NewDataFlowEvidence() + type alias DataFlowEvidence + return json.Unmarshal(b, (*alias)(d)) +} + +// NewReachabilityEvidence returns ReachabilityEvidence's pydantic defaults +// (call_chain=[]). +func NewReachabilityEvidence() ReachabilityEvidence { + return ReachabilityEvidence{CallChain: []string{}} +} + +// UnmarshalJSON seeds ReachabilityEvidence.CallChain=[]. +func (r *ReachabilityEvidence) UnmarshalJSON(b []byte) error { + *r = NewReachabilityEvidence() + type alias ReachabilityEvidence + return json.Unmarshal(b, (*alias)(r)) +} + +// NewVerifiedFinding returns VerifiedFinding's pydantic defaults: fresh uuid4 +// ID, and tags / related_locations / compliance / reproduction_steps empty. +func NewVerifiedFinding() VerifiedFinding { + v := verifiedFindingListDefaults() + v.ID = NewUUID4() + return v +} + +// verifiedFindingListDefaults is NewVerifiedFinding without the uuid4 ID — +// what UnmarshalJSON seeds. +func verifiedFindingListDefaults() VerifiedFinding { + return VerifiedFinding{ + Tags: []string{}, + RelatedLocations: []Location{}, + Compliance: []ComplianceMapping{}, + ReproductionSteps: []ReproductionStep{}, + } +} + +// UnmarshalJSON seeds VerifiedFinding's four list defaults. ID is NOT seeded. +func (v *VerifiedFinding) UnmarshalJSON(b []byte) error { + *v = verifiedFindingListDefaults() + type alias VerifiedFinding + return json.Unmarshal(b, (*alias)(v)) +} + +// --- recon.go --- + +// NewArchitectureMap returns ArchitectureMap's pydantic defaults (five empty +// lists; app_type stays nil). +func NewArchitectureMap() ArchitectureMap { + return ArchitectureMap{ + Modules: []Module{}, + EntryPoints: []EntryPoint{}, + TrustBoundaries: []TrustBoundary{}, + Services: []Service{}, + APISurface: []APIEndpoint{}, + } +} + +// UnmarshalJSON seeds ArchitectureMap's five list defaults. +func (a *ArchitectureMap) UnmarshalJSON(b []byte) error { + *a = NewArchitectureMap() + type alias ArchitectureMap + return json.Unmarshal(b, (*alias)(a)) +} + +// NewModule returns Module's pydantic defaults (dependencies=[]). +func NewModule() Module { return Module{Dependencies: []string{}} } + +// UnmarshalJSON seeds Module.Dependencies=[]. +func (m *Module) UnmarshalJSON(b []byte) error { + *m = NewModule() + type alias Module + return json.Unmarshal(b, (*alias)(m)) +} + +// NewTrustBoundary returns TrustBoundary's pydantic defaults (enforcement=[]). +func NewTrustBoundary() TrustBoundary { return TrustBoundary{Enforcement: []string{}} } + +// UnmarshalJSON seeds TrustBoundary.Enforcement=[]. +func (t *TrustBoundary) UnmarshalJSON(b []byte) error { + *t = NewTrustBoundary() + type alias TrustBoundary + return json.Unmarshal(b, (*alias)(t)) +} + +// NewSanitizationPoint returns SanitizationPoint's pydantic defaults +// (protects_against=[]). +func NewSanitizationPoint() SanitizationPoint { + return SanitizationPoint{ProtectsAgainst: []string{}} +} + +// UnmarshalJSON seeds SanitizationPoint.ProtectsAgainst=[]. +func (s *SanitizationPoint) UnmarshalJSON(b []byte) error { + *s = NewSanitizationPoint() + type alias SanitizationPoint + return json.Unmarshal(b, (*alias)(s)) +} + +// NewDataFlow returns DataFlow's pydantic defaults (path=[], files=[]). +func NewDataFlow() DataFlow { + return DataFlow{Path: []ReconDataFlowStep{}, Files: []string{}} +} + +// UnmarshalJSON seeds DataFlow's two list defaults. +func (d *DataFlow) UnmarshalJSON(b []byte) error { + *d = NewDataFlow() + type alias DataFlow + return json.Unmarshal(b, (*alias)(d)) +} + +// NewDataFlowMap returns DataFlowMap's pydantic defaults (three empty lists). +func NewDataFlowMap() DataFlowMap { + return DataFlowMap{ + Flows: []DataFlow{}, + SanitizationPoints: []SanitizationPoint{}, + Sinks: []Sink{}, + } +} + +// UnmarshalJSON seeds DataFlowMap's three list defaults. +func (d *DataFlowMap) UnmarshalJSON(b []byte) error { + *d = NewDataFlowMap() + type alias DataFlowMap + return json.Unmarshal(b, (*alias)(d)) +} + +// NewDependencyReport returns DependencyReport's pydantic defaults (three +// empty lists, zeroed counters). +func NewDependencyReport() DependencyReport { + return DependencyReport{ + Sbom: []Dependency{}, + KnownCves: []KnownCVE{}, + Outdated: []OutdatedDep{}, + } +} + +// UnmarshalJSON seeds DependencyReport's three list defaults. +func (d *DependencyReport) UnmarshalJSON(b []byte) error { + *d = NewDependencyReport() + type alias DependencyReport + return json.Unmarshal(b, (*alias)(d)) +} + +// NewSecretFinding returns SecretFinding's pydantic defaults (fresh uuid4 ID). +func NewSecretFinding() SecretFinding { return SecretFinding{ID: NewUUID4()} } + +// NewMisconfigFinding returns MisconfigFinding's pydantic defaults (fresh +// uuid4 ID). +func NewMisconfigFinding() MisconfigFinding { return MisconfigFinding{ID: NewUUID4()} } + +// NewConfigReport returns ConfigReport's pydantic defaults (secrets=[], +// misconfigs=[]). +func NewConfigReport() ConfigReport { + return ConfigReport{Secrets: []SecretFinding{}, Misconfigs: []MisconfigFinding{}} +} + +// UnmarshalJSON seeds ConfigReport's two list defaults. +func (c *ConfigReport) UnmarshalJSON(b []byte) error { + *c = NewConfigReport() + type alias ConfigReport + return json.Unmarshal(b, (*alias)(c)) +} + +// NewSecurityContext returns SecurityContext's pydantic defaults (four empty +// lists). +func NewSecurityContext() SecurityContext { + return SecurityContext{ + CryptoUsage: []CryptoUsage{}, + FrameworkSecurity: []string{}, + SecurityHeaders: []string{}, + DeploymentSignals: []string{}, + } +} + +// UnmarshalJSON seeds SecurityContext's four list defaults. +func (s *SecurityContext) UnmarshalJSON(b []byte) error { + *s = NewSecurityContext() + type alias SecurityContext + return json.Unmarshal(b, (*alias)(s)) +} + +// NewReconResult returns ReconResult's pydantic defaults: languages=[], +// frameworks=[], and — because the five nested models are REQUIRED in Python +// and therefore always constructed by the caller — each nested model at ITS +// own defaults rather than a half-built zero value. +func NewReconResult() ReconResult { + return ReconResult{ + Architecture: NewArchitectureMap(), + DataFlows: NewDataFlowMap(), + Dependencies: NewDependencyReport(), + Config: NewConfigReport(), + SecurityContext: NewSecurityContext(), + Languages: []string{}, + Frameworks: []string{}, + } +} + +// UnmarshalJSON seeds ReconResult's defaults. The nested models re-seed +// themselves through their own UnmarshalJSON when their key is present; when a +// key is ABSENT the seeded default survives, which is what Python's +// `_recon_model` normalization (reasoners/phases.py) relies on. +func (r *ReconResult) UnmarshalJSON(b []byte) error { + *r = NewReconResult() + type alias ReconResult + return json.Unmarshal(b, (*alias)(r)) +} + +// NewArchitectureMapRaw returns ArchitectureMapRaw's pydantic defaults +// (app_type="unknown", five empty lists). +func NewArchitectureMapRaw() ArchitectureMapRaw { + return ArchitectureMapRaw{ + AppType: "unknown", + Modules: []string{}, + EntryPoints: []string{}, + TrustBoundaries: []string{}, + Services: []string{}, + APIEndpoints: []string{}, + } +} + +// UnmarshalJSON seeds ArchitectureMapRaw's defaults. +func (a *ArchitectureMapRaw) UnmarshalJSON(b []byte) error { + *a = NewArchitectureMapRaw() + type alias ArchitectureMapRaw + return json.Unmarshal(b, (*alias)(a)) +} + +// NewDataFlowMapRaw returns DataFlowMapRaw's pydantic defaults (three empty +// lists). +func NewDataFlowMapRaw() DataFlowMapRaw { + return DataFlowMapRaw{ + Flows: []string{}, + SanitizationPoints: []string{}, + Sinks: []string{}, + } +} + +// UnmarshalJSON seeds DataFlowMapRaw's three list defaults. +func (d *DataFlowMapRaw) UnmarshalJSON(b []byte) error { + *d = NewDataFlowMapRaw() + type alias DataFlowMapRaw + return json.Unmarshal(b, (*alias)(d)) +} + +// NewDependencyReportRaw returns DependencyReportRaw's pydantic defaults. +func NewDependencyReportRaw() DependencyReportRaw { + return DependencyReportRaw{Sbom: []string{}, KnownCves: []string{}, Outdated: []string{}} +} + +// UnmarshalJSON seeds DependencyReportRaw's three list defaults. +func (d *DependencyReportRaw) UnmarshalJSON(b []byte) error { + *d = NewDependencyReportRaw() + type alias DependencyReportRaw + return json.Unmarshal(b, (*alias)(d)) +} + +// NewConfigReportRaw returns ConfigReportRaw's pydantic defaults. +func NewConfigReportRaw() ConfigReportRaw { + return ConfigReportRaw{Secrets: []string{}, Misconfigs: []string{}} +} + +// UnmarshalJSON seeds ConfigReportRaw's two list defaults. +func (c *ConfigReportRaw) UnmarshalJSON(b []byte) error { + *c = NewConfigReportRaw() + type alias ConfigReportRaw + return json.Unmarshal(b, (*alias)(c)) +} + +// NewSecurityContextRaw returns SecurityContextRaw's pydantic defaults +// (auth_details="" — the Go zero value — plus two empty lists). +func NewSecurityContextRaw() SecurityContextRaw { + return SecurityContextRaw{CryptoUsage: []string{}, SecuritySignals: []string{}} +} + +// UnmarshalJSON seeds SecurityContextRaw's two list defaults. +func (s *SecurityContextRaw) UnmarshalJSON(b []byte) error { + *s = NewSecurityContextRaw() + type alias SecurityContextRaw + return json.Unmarshal(b, (*alias)(s)) +} + +// --- prove.go (before-validator models that also need list seeding) --- + +// NewDataFlowTrace returns DataFlowTrace with a non-nil Steps slice. Python +// declares `steps` as REQUIRED (no default_factory), so this is not a pydantic +// default — it exists so Go code that builds a trace does not emit null where +// the model always carries a list. +func NewDataFlowTrace() DataFlowTrace { return DataFlowTrace{Steps: []string{}} } + +// NewReachabilityProof returns ReachabilityProof with a non-nil CallChain. +// As with DataFlowTrace, `call_chain` is REQUIRED in Python. +func NewReachabilityProof() ReachabilityProof { + return ReachabilityProof{CallChain: []string{}} +} diff --git a/go/internal/schemas/doc.go b/go/internal/schemas/doc.go new file mode 100644 index 0000000..9ed1673 --- /dev/null +++ b/go/internal/schemas/doc.go @@ -0,0 +1,99 @@ +// Package schemas ports every pydantic model and enum SEC-AF exchanges over a +// JSON boundary: `src/sec_af/schemas/*.py` (compliance, gates, hunt, input, +// output, prove, recon, views) plus `PolicyEvalResult` from +// `src/sec_af/policies.py`, which `evaluate_policy` hands to +// `app.harness(schema=...)`. +// +// # Parity rules (docs/DESIGN.md §0.2, §2, §3) +// +// Struct name == pydantic class name, json tag == pydantic field name, and +// Go field declaration order == Python field declaration order (encoding/json +// emits struct fields in declaration order, so a marshaled Go value reproduces +// `model_dump()`'s key order exactly — the parity test in +// model_keys_test.go asserts the ordered key list, not just the set). +// +// NO `omitempty` anywhere. Python reasoners return `model_dump()`, which emits +// every field including the ones that are None. Where Python uses +// `model_dump(exclude_none=True)` the call site drops nulls through +// afx.DropNulls; it is never a property of the struct. +// +// `Optional[X]` / `X | None` maps to a Go pointer (`*string`, `*int`, +// `*float64`, `*bool`, `*SomeModel`) so an unset value marshals to JSON null +// exactly as pydantic does. The two exceptions are `list[X] | None` and +// `dict[K, V] | None`: a nil Go slice/map already marshals to null and a +// non-nil empty one to `[]` / `{}`, which is precisely the pydantic behavior, +// so those stay plain slices/maps (a pointer would only add a second nil to +// reason about). +// +// # Default seeding (the pr-af `schemas/defaults.go` pattern) +// +// Go's json.Unmarshal leaves an absent key at the Go zero value; pydantic +// fills the declared default. Every struct with at least one non-zero pydantic +// default therefore gets, in defaults.go: +// +// - an exported constructor `NewX() X` returning the pydantic-default value. +// Use it whenever Go code BUILDS an X — a zero-value `X{}` marshals +// `default_factory=list` fields as `null` where pydantic emits `[]`, and +// misses the non-zero scalar defaults (`AuditInput.Depth == "standard"`, +// `ArchitectureMapRaw.AppType == "unknown"`, …). +// - an `UnmarshalJSON` that seeds `NewX()` before decoding, so an absent key +// keeps the default while a present key — `false`, `0`, `""` — overrides +// it. +// +// An explicit `null` is the ONE case seeding cannot get right on its own, and +// it goes the other way: encoding/json treats a null as a no-op for a scalar +// and as ZERO-THE-VALUE for a slice/map/pointer, so `{"findings": null}` binds +// cleanly AND wipes the seeded `[]`, emitting `"findings": null` — a shape +// pydantic can never produce for a non-Optional field (`HuntResult(findings= +// None)` raises). Only `X | None` fields accept a null, plus the handful of +// required fields whose `mode="before"` validator maps None onto a value +// (`DataFlowTrace.source` -> "unknown"). That distinction is generated ground +// truth (`accepts_null` in testdata/model_keys.json) and is enforced one layer +// up, by internal/phases' checked binders — so `afx.Bind[X](map)` alone is +// NOT `X.model_validate(dict)`; `phases.BindX` is. +// +// The `type alias X` trick inside each UnmarshalJSON strips X's methods so the +// inner json.Unmarshal does not recurse; nested field types keep their own +// UnmarshalJSON and therefore their own seeding. +// +// Models whose every field is required or zero-defaulted have no constructor +// and no UnmarshalJSON — their zero value already matches pydantic. The parity +// test enumerates all 80 distinct models and fails if one that needs seeding +// lacks it. +// +// `default_factory=lambda: str(uuid4())` fields (`RawFinding.ID`, +// `RawFinding.Fingerprint`, `PotentialChain.ChainID`, `SecretFinding.ID`, +// `MisconfigFinding.ID`, `VerifiedFinding.ID`) are seeded by the constructors +// with a fresh RFC 4122 v4 string from NewUUID4 (uuid.go, crypto/rand — the +// port takes no new third-party dependency). They are deliberately NOT seeded +// in UnmarshalJSON: decoding is how a value crosses a reasoner boundary, and a +// payload that omits `id` should not silently mint a NEW identity on every +// hop. Python has the same hazard; the live path always carries the field. +// +// # Enums +// +// Python's `class X(str, Enum)` maps to `type X string` with one constant per +// member; `class EvidenceLevel(IntEnum)` maps to `type EvidenceLevel int`. +// Each has `Valid()` and a `ParseX` helper. Note that `HuntStrategy` has a +// value alias — Python's `LOGIC_BUGS = "business_logic"` is the SAME member as +// `BUSINESS_LOGIC`, not a distinct one (`HuntStrategy.LOGIC_BUGS is +// HuntStrategy.BUSINESS_LOGIC` is True) — so Go declares both constants with +// the same value and `AllHuntStrategies` lists the value once. +// +// Python parity: `str(Severity.HIGH)` is "Severity.HIGH", not "high" (3.11 +// Enum.__str__ on a str mixin), while `str(EvidenceLevel.FULL_EXPLOIT)` is "6" +// (3.11 IntEnum.__str__ == int.__str__). Go's string/int enums print their +// value, so any prompt builder that interpolates an enum must use `.value` / +// `int(...)` on the Python side to match — none of the ported prompt builders +// interpolate a bare enum member. +// +// # Name collisions +// +// `prove.py` re-declares `Location`, `CvssV4Score`, `EpssScore` and +// `ReproductionStep` byte-identically to `output.py`; the Go package keeps one +// struct each (declared in output.go, which is what `schemas/__init__.py` +// re-exports) and the parity test asserts both Python declarations really do +// have the same shape. `recon.DataFlowStep` and `prove.DataFlowStep` are +// DIFFERENT models sharing a name; Go follows `schemas/__init__.py`, which +// re-exports prove's as `DataFlowStep` and recon's as `ReconDataFlowStep`. +package schemas diff --git a/go/internal/schemas/gates.go b/go/internal/schemas/gates.go new file mode 100644 index 0000000..3c6b7d5 --- /dev/null +++ b/go/internal/schemas/gates.go @@ -0,0 +1,98 @@ +package schemas + +// This file ports src/sec_af/schemas/gates.py — the FLAT schemas handed to +// `.ai()` gate calls (DESIGN.md §2.4/§2.5: .ai() schemas must stay flat). +// Every field is required in Python except DuplicateCheck.duplicate_of, so +// only that one model has a nullable field and none need default seeding. + +// SeverityClassification is the quick severity classification gate used in +// scoring (DESIGN.md §2.4). +// +// Ports schemas/gates.py SeverityClassification. +type SeverityClassification struct { + // Severity is one of: "critical", "high", "medium", "low". + Severity string `json:"severity"` + Confidence float64 `json:"confidence"` + Rationale string `json:"rationale"` +} + +// DuplicateCheck is the quick duplicate-check gate for dedup decisions +// (DESIGN.md §5.5). +// +// Ports schemas/gates.py DuplicateCheck. +type DuplicateCheck struct { + IsDuplicate bool `json:"is_duplicate"` + DuplicateOf *string `json:"duplicate_of"` + Reason string `json:"reason"` +} + +// StrategySelection is the strategy-selection gate for HUNT routing +// (DESIGN.md §5.3). +// +// Ports schemas/gates.py StrategySelection. +type StrategySelection struct { + Strategies []string `json:"strategies"` + Rationale string `json:"rationale"` +} + +// CWEExpansion carries AI-suggested CWE additions derived from recon context. +// +// Ports schemas/gates.py CWEExpansion. +type CWEExpansion struct { + // AdditionalCwes are CWE IDs to add beyond the hunter's baseline, e.g. + // ['CWE-918', 'CWE-611']. + AdditionalCwes []string `json:"additional_cwes"` + Rationale string `json:"rationale"` +} + +// RelevanceGate is the relevance/noise filter gate for candidate findings +// (DESIGN.md §2.4). +// +// Ports schemas/gates.py RelevanceGate. +type RelevanceGate struct { + IsRelevant bool `json:"is_relevant"` + Confidence float64 `json:"confidence"` + Reason string `json:"reason"` +} + +// VerdictGate is the binary verdict gate for simple cases (DESIGN.md §2.4, +// §6.3). +// +// Ports schemas/gates.py VerdictGate. +type VerdictGate struct { + Confirmed bool `json:"confirmed"` + Confidence float64 `json:"confidence"` + Reason string `json:"reason"` +} + +// ComplianceSuggestion is one AI-suggested framework control mapping. +// +// Ports schemas/gates.py ComplianceSuggestion. It has the same shape as +// ComplianceMapping but is a distinct pydantic class (and therefore a distinct +// harness/ai schema fixture), so Go keeps it distinct too. +type ComplianceSuggestion struct { + Framework string `json:"framework"` + ControlID string `json:"control_id"` + ControlName string `json:"control_name"` +} + +// ComplianceGate is the AI half of get_compliance_mappings_hybrid. +// +// Ports schemas/gates.py ComplianceGate. +type ComplianceGate struct { + Mappings []ComplianceSuggestion `json:"mappings"` + Confidence string `json:"confidence"` +} + +// ReachabilityGate is the reachability assessment for findings that carry no +// explicit reachability tag. +// +// Ports schemas/gates.py ReachabilityGate. +type ReachabilityGate struct { + // Reachability is one of: "externally_reachable", "requires_auth", + // "internal_only", "unreachable". + Reachability string `json:"reachability"` + Rationale string `json:"rationale"` + // Confidence is one of: "high", "medium", "low". + Confidence string `json:"confidence"` +} diff --git a/go/internal/schemas/hunt.go b/go/internal/schemas/hunt.go new file mode 100644 index 0000000..fb763cb --- /dev/null +++ b/go/internal/schemas/hunt.go @@ -0,0 +1,358 @@ +package schemas + +import ( + "strconv" + "strings" +) + +// This file ports src/sec_af/schemas/hunt.py — the HUNT phase enums, the flat +// two-step scan/enrich harness schemas, and the RawFinding / chain / result +// models (DESIGN.md §5.2-§5.5). + +// FindingType is the finding taxonomy (DESIGN.md §5.4). +// +// Ports schemas/hunt.py FindingType (`class FindingType(str, Enum)`). +type FindingType string + +// FindingType members, in Python declaration order. +const ( + FindingTypeSast FindingType = "sast" + FindingTypeSca FindingType = "sca" + FindingTypeSecrets FindingType = "secrets" + FindingTypeConfig FindingType = "config" + FindingTypeLogic FindingType = "logic" + FindingTypeAPI FindingType = "api" +) + +// AllFindingTypes lists every FindingType in Python declaration order. +var AllFindingTypes = []FindingType{ + FindingTypeSast, FindingTypeSca, FindingTypeSecrets, + FindingTypeConfig, FindingTypeLogic, FindingTypeAPI, +} + +// Valid reports whether f is one of the declared members. +func (f FindingType) Valid() bool { + for _, v := range AllFindingTypes { + if f == v { + return true + } + } + return false +} + +// ParseFindingType is the Go equivalent of `FindingType(s)`: it returns the +// member with value s, or an error (pydantic raises ValidationError). +func ParseFindingType(s string) (FindingType, error) { + v := FindingType(s) + if !v.Valid() { + return "", &EnumValueError{Enum: "FindingType", Value: s} + } + return v, nil +} + +// Severity is the severity scale (DESIGN.md §5.4, §7). +// +// Ports schemas/hunt.py Severity (`class Severity(str, Enum)`). +type Severity string + +// Severity members, in Python declaration order (decreasing urgency). +const ( + SeverityCritical Severity = "critical" + SeverityHigh Severity = "high" + SeverityMedium Severity = "medium" + SeverityLow Severity = "low" + SeverityInfo Severity = "info" +) + +// AllSeverities lists every Severity in Python declaration order. +var AllSeverities = []Severity{ + SeverityCritical, SeverityHigh, SeverityMedium, SeverityLow, SeverityInfo, +} + +// Valid reports whether s is one of the declared members. +func (s Severity) Valid() bool { + for _, v := range AllSeverities { + if s == v { + return true + } + } + return false +} + +// ParseSeverity is the Go equivalent of `Severity(s)`. +func ParseSeverity(s string) (Severity, error) { + v := Severity(s) + if !v.Valid() { + return "", &EnumValueError{Enum: "Severity", Value: s} + } + return v, nil +} + +// Confidence is the confidence scale for provisional findings +// (DESIGN.md §5.4). +// +// Ports schemas/hunt.py Confidence (`class Confidence(str, Enum)`). +type Confidence string + +// Confidence members, in Python declaration order. +const ( + ConfidenceHigh Confidence = "high" + ConfidenceMedium Confidence = "medium" + ConfidenceLow Confidence = "low" +) + +// AllConfidences lists every Confidence in Python declaration order. +var AllConfidences = []Confidence{ConfidenceHigh, ConfidenceMedium, ConfidenceLow} + +// Valid reports whether c is one of the declared members. +func (c Confidence) Valid() bool { + for _, v := range AllConfidences { + if c == v { + return true + } + } + return false +} + +// ParseConfidence is the Go equivalent of `Confidence(s)`. +func ParseConfidence(s string) (Confidence, error) { + v := Confidence(s) + if !v.Valid() { + return "", &EnumValueError{Enum: "Confidence", Value: s} + } + return v, nil +} + +// HuntStrategy is the strategy catalog for hunters (DESIGN.md §5.2, §5.3). +// +// Ports schemas/hunt.py HuntStrategy (`class HuntStrategy(str, Enum)`). +// +// Python parity: `LOGIC_BUGS = "business_logic"` repeats BUSINESS_LOGIC's +// value, so Python's Enum machinery makes LOGIC_BUGS an ALIAS for the same +// member — `HuntStrategy.LOGIC_BUGS is HuntStrategy.BUSINESS_LOGIC` is True and +// `list(HuntStrategy)` yields the value once. Go declares both constants (so +// call sites can use either spelling) but AllHuntStrategies lists +// "business_logic" once, matching `list(HuntStrategy)`. +type HuntStrategy string + +// HuntStrategy members, in Python declaration order. +const ( + HuntStrategyInjection HuntStrategy = "injection" + HuntStrategyXSS HuntStrategy = "xss" + HuntStrategyDos HuntStrategy = "dos" + HuntStrategySSRF HuntStrategy = "ssrf" + HuntStrategyAuth HuntStrategy = "auth" + HuntStrategyCrypto HuntStrategy = "crypto" + HuntStrategyBusinessLogic HuntStrategy = "business_logic" + HuntStrategyLogicBugs HuntStrategy = "business_logic" // Python alias of BUSINESS_LOGIC + HuntStrategyDataExposure HuntStrategy = "data_exposure" + HuntStrategySupplyChain HuntStrategy = "supply_chain" + HuntStrategyConfigSecrets HuntStrategy = "config_secrets" + HuntStrategyAPISecurity HuntStrategy = "api_security" + HuntStrategyPythonSpecific HuntStrategy = "python_specific" + HuntStrategyJavascriptSpecific HuntStrategy = "javascript_specific" +) + +// AllHuntStrategies lists every distinct HuntStrategy value in Python +// declaration order — i.e. `[s.value for s in HuntStrategy]`, which excludes +// the LOGIC_BUGS alias. +var AllHuntStrategies = []HuntStrategy{ + HuntStrategyInjection, HuntStrategyXSS, HuntStrategyDos, HuntStrategySSRF, + HuntStrategyAuth, HuntStrategyCrypto, HuntStrategyBusinessLogic, + HuntStrategyDataExposure, HuntStrategySupplyChain, HuntStrategyConfigSecrets, + HuntStrategyAPISecurity, HuntStrategyPythonSpecific, HuntStrategyJavascriptSpecific, +} + +// Valid reports whether h is one of the declared members. +func (h HuntStrategy) Valid() bool { + for _, v := range AllHuntStrategies { + if h == v { + return true + } + } + return false +} + +// ParseHuntStrategy is the Go equivalent of `HuntStrategy(s)`. +func ParseHuntStrategy(s string) (HuntStrategy, error) { + v := HuntStrategy(s) + if !v.Valid() { + return "", &EnumValueError{Enum: "HuntStrategy", Value: s} + } + return v, nil +} + +// EnumValueError is what the ParseX helpers return for an unknown value. It +// stands in for the `ValueError: 'x' is not a valid Severity` pydantic/Enum +// raises. +type EnumValueError struct { + Enum string + Value string +} + +func (e *EnumValueError) Error() string { + return "'" + e.Value + "' is not a valid " + e.Enum +} + +// VulnLocation is the flat schema for hunt Step 1 (location scanning). +// +// Ports schemas/hunt.py VulnLocation. +type VulnLocation struct { + FilePath string `json:"file_path"` + StartLine int `json:"start_line"` + CodeSnippet string `json:"code_snippet"` + // PatternType e.g. 'sql_injection', 'command_injection'. + PatternType string `json:"pattern_type"` +} + +// EnrichedFinding is the flat schema for hunt Step 2 (finding enrichment). +// +// Ports schemas/hunt.py EnrichedFinding. Severity/Confidence are plain strings +// here (not the enums) because this is a raw harness output schema. +type EnrichedFinding struct { + Title string `json:"title"` + Description string `json:"description"` + CweID string `json:"cwe_id"` + // Severity is one of: "critical", "high", "medium", "low", "info". + Severity string `json:"severity"` + // Confidence is one of: "high", "medium", "low". + Confidence string `json:"confidence"` + // DataFlowSummary is a natural-language summary (string, not nested). + DataFlowSummary string `json:"data_flow_summary"` +} + +// ScanLocationsResult is the container for hunt Step 1 results. +// +// Ports schemas/hunt.py ScanLocationsResult. Seeded (defaults.go): +// locations `[]`. +type ScanLocationsResult struct { + Locations []VulnLocation `json:"locations"` +} + +// RawFinding is a potential vulnerability produced by a hunter +// (DESIGN.md §5.4). +// +// Ports schemas/hunt.py RawFinding. Seeded (defaults.go): related_files `[]`, +// and NewRawFinding mints ID and Fingerprint as fresh uuid4 strings (Python's +// `default_factory=lambda: str(uuid4())` on BOTH fields — the fingerprint is a +// random uuid by default, NOT a content hash; agents/hunt overwrites it with a +// real fingerprint before dedup). +type RawFinding struct { + ID string `json:"id"` + HunterStrategy string `json:"hunter_strategy"` + Title string `json:"title"` + Description string `json:"description"` + FindingType FindingType `json:"finding_type"` + CweID string `json:"cwe_id"` + CweName string `json:"cwe_name"` + OwaspCategory *string `json:"owasp_category"` + FilePath string `json:"file_path"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` + FunctionName *string `json:"function_name"` + CodeSnippet string `json:"code_snippet"` + EstimatedSeverity Severity `json:"estimated_severity"` + Confidence Confidence `json:"confidence"` + DataFlow []ReconDataFlowStep `json:"data_flow"` + RelatedFiles []string `json:"related_files"` + Fingerprint string `json:"fingerprint"` +} + +// ForVerifier projects the finding onto what the verifier pipeline needs. +// +// Ports schemas/hunt.py RawFinding.for_verifier(). Python parity: the +// data_flow summary joins `f"{step.file_path}:{step.line} {step.operation}"` +// with "\n", and is "" when data_flow is empty or None — note Python tests +// `if self.data_flow:`, so an EMPTY list yields "" just like None does. +func (r RawFinding) ForVerifier() FindingForVerifier { + summary := "" + if len(r.DataFlow) > 0 { + parts := make([]string, 0, len(r.DataFlow)) + for _, step := range r.DataFlow { + parts = append(parts, step.FilePath+":"+strconv.Itoa(step.Line)+" "+step.Operation) + } + summary = strings.Join(parts, "\n") + } + return FindingForVerifier{ + ID: r.ID, + Title: r.Title, + Description: r.Description, + FilePath: r.FilePath, + StartLine: r.StartLine, + EndLine: r.EndLine, + CodeSnippet: r.CodeSnippet, + CweID: r.CweID, + FunctionName: r.FunctionName, + DataFlowSummary: summary, + } +} + +// ForDedup projects the finding onto what the deduplicator needs. +// +// Ports schemas/hunt.py RawFinding.for_dedup(). Python passes +// `self.finding_type.value` / `self.estimated_severity.value`; the Go enums +// already ARE their values. +func (r RawFinding) ForDedup() FindingForDedup { + return FindingForDedup{ + ID: r.ID, + Fingerprint: r.Fingerprint, + Title: r.Title, + FilePath: r.FilePath, + StartLine: r.StartLine, + CweID: r.CweID, + FindingType: string(r.FindingType), + EstimatedSeverity: string(r.EstimatedSeverity), + } +} + +// PotentialChain is a potential multi-step attack chain before proof +// (DESIGN.md §5.5). +// +// Ports schemas/hunt.py PotentialChain. Seeded (defaults.go): finding_ids `[]` +// and NewPotentialChain mints ChainID as a fresh uuid4. +type PotentialChain struct { + ChainID string `json:"chain_id"` + Title string `json:"title"` + FindingIDs []string `json:"finding_ids"` + CombinedImpact string `json:"combined_impact"` + EstimatedSeverity Severity `json:"estimated_severity"` +} + +// HuntResult is the deduplicated and correlated hunt output (DESIGN.md §5.5). +// +// Ports schemas/hunt.py HuntResult. Seeded (defaults.go): findings, chains and +// strategies_run `[]`; the counters and duration default to the Go zero value. +type HuntResult struct { + Findings []RawFinding `json:"findings"` + Chains []PotentialChain `json:"chains"` + TotalRaw int `json:"total_raw"` + DeduplicatedCount int `json:"deduplicated_count"` + ChainCount int `json:"chain_count"` + StrategiesRun []string `json:"strategies_run"` + HuntDurationSeconds float64 `json:"hunt_duration_seconds"` +} + +// DeduplicatedResult is the dedup lane output before PROVE prioritization +// (DESIGN.md §5.5). +// +// Ports schemas/hunt.py DeduplicatedResult. Seeded (defaults.go): findings and +// chains `[]`. +type DeduplicatedResult struct { + Findings []RawFinding `json:"findings"` + Chains []PotentialChain `json:"chains"` + DroppedDuplicates int `json:"dropped_duplicates"` + KeptFindings int `json:"kept_findings"` +} + +// ChainCorrelationResult is the flat harness schema for chain correlation: +// the LLM identifies chains only. +// +// Ports schemas/hunt.py ChainCorrelationResult. Seeded (defaults.go): both +// list fields `[]`. +type ChainCorrelationResult struct { + // Chains: one entry per multi-step attack chain, formatted + // "title | finding_id1,finding_id2,... | combined_impact | severity". + Chains []string `json:"chains"` + // DuplicateIDs are finding IDs the programmatic dedup missed (to drop). + DuplicateIDs []string `json:"duplicate_ids"` +} diff --git a/go/internal/schemas/input.go b/go/internal/schemas/input.go new file mode 100644 index 0000000..b5f8ab2 --- /dev/null +++ b/go/internal/schemas/input.go @@ -0,0 +1,89 @@ +package schemas + +// This file ports src/sec_af/schemas/input.py — the REST API input contract +// for `sec-af.audit` (DESIGN.md §8.2). + +// AuditInput is the input for a `sec-af.audit` execution (DESIGN.md §8.2). +// +// Ports schemas/input.py AuditInput. Seeded (defaults.go): branch="main", +// depth="standard", severity_threshold="low", +// scan_types=["sast","sca","secrets","config"], output_formats=["json"], +// exclude_paths=["tests/","vendor/","node_modules/",".git/"], and +// compliance_frameworks / repo_urls / custom_policies `[]`. +// +// `repo_url` is the only REQUIRED field — binding a payload without it must +// fail (ported from tests/test_schemas.py::test_schema_validation_and_required_fields); +// see Validate. +type AuditInput struct { + // RepoURL is the git repository URL to audit. REQUIRED. + RepoURL string `json:"repo_url"` + // Branch to audit. + Branch string `json:"branch"` + // CommitSha to audit. + CommitSha *string `json:"commit_sha"` + // BaseCommitSha is the base commit for diff-aware PR scanning. + BaseCommitSha *string `json:"base_commit_sha"` + // Depth is the scan depth profile: quick|standard|thorough. + Depth string `json:"depth"` + // SeverityThreshold is the minimum severity to report: + // critical|high|medium|low|info. + SeverityThreshold string `json:"severity_threshold"` + ScanTypes []string `json:"scan_types"` + OutputFormats []string `json:"output_formats"` + ComplianceFrameworks []string `json:"compliance_frameworks"` + // MaxCostUsd is the budget cap in USD. + MaxCostUsd *float64 `json:"max_cost_usd"` + // MaxProvers caps parallel provers. + MaxProvers *int `json:"max_provers"` + // MaxDurationSeconds caps execution time. + MaxDurationSeconds *int `json:"max_duration_seconds"` + // IncludePaths restricts the scan to these repository paths. + IncludePaths []string `json:"include_paths"` + ExcludePaths []string `json:"exclude_paths"` + // IsPr reports whether the scan is for a pull request. + IsPr bool `json:"is_pr"` + // PrID is the pull request identifier. + PrID *string `json:"pr_id"` + // PostPrComments posts findings as PR comments. + PostPrComments bool `json:"post_pr_comments"` + // FailOnFindings returns a non-zero status for CI gating. + FailOnFindings bool `json:"fail_on_findings"` + // DastEnabled turns on DAST-like runtime exploit verification (sandbox). + DastEnabled bool `json:"dast_enabled"` + // RepoUrls are additional repository URLs for cross-service analysis. + RepoUrls []string `json:"repo_urls"` + // MonitoringMode enables continuous monitoring (compare against baseline). + MonitoringMode bool `json:"monitoring_mode"` + // BaselinePath points at baseline scan results for regression detection. + BaselinePath *string `json:"baseline_path"` + // CustomPolicies are org-specific security policy rules to evaluate, + // e.g. 'All endpoints must require authentication'. + CustomPolicies []string `json:"custom_policies"` +} + +// Validate reproduces the one required-field constraint pydantic enforces on +// AuditInput: `repo_url` has no default, so `AuditInput(branch="main")` raises +// ValidationError. Go's json.Unmarshal cannot express "required", so the +// binding call site calls Validate. +// +// Python parity: this is the ONLY constraint on AuditInput — the schema +// declares no ge/le bounds and no Literal, so depth / severity_threshold / +// scan_types are free-form strings validated (or not) downstream, exactly as +// in Python. +func (a AuditInput) Validate() error { + if a.RepoURL == "" { + return &MissingFieldError{Model: "AuditInput", Field: "repo_url"} + } + return nil +} + +// MissingFieldError stands in for pydantic's ValidationError on a missing +// required field. +type MissingFieldError struct { + Model string + Field string +} + +func (e *MissingFieldError) Error() string { + return e.Model + ": field required: " + e.Field +} diff --git a/go/internal/schemas/model_keys_test.go b/go/internal/schemas/model_keys_test.go new file mode 100644 index 0000000..b2a0fb3 --- /dev/null +++ b/go/internal/schemas/model_keys_test.go @@ -0,0 +1,502 @@ +package schemas + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "reflect" + "regexp" + "sort" + "testing" +) + +// This file is the exhaustive schema parity gate. testdata/model_keys.json is +// generated from the LIVE pydantic models by go/scripts/gen_model_keys.py +// (regenerate with: +// +// PYTHONPATH=$PWD/src ~/.agentfield/packages/sec-af/venv/bin/python \ +// go/scripts/gen_model_keys.py +// +// from the repo root). For every model it records model_dump()'s ordered key +// list, which fields are required, and what a minimally-constructed instance +// serialises each non-required field to. The tests below assert the Go structs +// reproduce all of it, so a pydantic field that is added, renamed, reordered or +// re-defaulted in Python fails the Go build's test step instead of silently +// diverging on the wire. + +// --------------------------------------------------------------------------- +// The generated ground truth +// --------------------------------------------------------------------------- + +type modelSpec struct { + PythonModule string `json:"python_module"` + PythonClass string `json:"python_class"` + GoName string `json:"go_name"` + DuplicateOf *string `json:"duplicate_of"` + Keys []string `json:"keys"` + Required []string `json:"required"` + NullFields []string `json:"null_fields"` + EmptyListFields []string `json:"empty_list_fields"` + EmptyDictFields []string `json:"empty_dict_fields"` + ScalarDefaults map[string]any `json:"scalar_defaults"` + UUIDDefaults []string `json:"uuid_defaults"` +} + +type enumSpec struct { + PythonModule string `json:"python_module"` + Members map[string]any `json:"members"` + Kind string `json:"kind"` +} + +type groundTruth struct { + Models []modelSpec `json:"models"` + Enums map[string]enumSpec `json:"enums"` + SchemasAll []string `json:"schemas_all"` +} + +func loadGroundTruth(t *testing.T) groundTruth { + t.Helper() + raw, err := os.ReadFile("testdata/model_keys.json") + if err != nil { + t.Fatalf("read testdata/model_keys.json: %v", err) + } + var gt groundTruth + if err := json.Unmarshal(raw, >); err != nil { + t.Fatalf("decode testdata/model_keys.json: %v", err) + } + if len(gt.Models) == 0 { + t.Fatal("testdata/model_keys.json has no models") + } + return gt +} + +// --------------------------------------------------------------------------- +// The Go side: one default-constructed value per ported model. +// +// The value is NewX() where the model has pydantic defaults (defaults.go) and +// the zero value otherwise — i.e. exactly what Go code gets when it builds the +// struct the intended way. That is what the parity assertions run against, so +// a missing constructor shows up as a `null` where Python emits `[]`. +// --------------------------------------------------------------------------- + +func goModelRegistry() map[string]any { + return map[string]any{ + // --- compliance.go --- + "ComplianceMapping": ComplianceMapping{}, + "ComplianceGap": ComplianceGap{}, + // --- gates.go --- + "SeverityClassification": SeverityClassification{}, + "DuplicateCheck": DuplicateCheck{}, + "StrategySelection": StrategySelection{}, + "CWEExpansion": CWEExpansion{}, + "RelevanceGate": RelevanceGate{}, + "VerdictGate": VerdictGate{}, + "ComplianceSuggestion": ComplianceSuggestion{}, + "ComplianceGate": ComplianceGate{}, + "ReachabilityGate": ReachabilityGate{}, + // --- hunt.go --- + "VulnLocation": VulnLocation{}, + "EnrichedFinding": EnrichedFinding{}, + "ScanLocationsResult": NewScanLocationsResult(), + "RawFinding": NewRawFinding(), + "PotentialChain": NewPotentialChain(), + "HuntResult": NewHuntResult(), + "DeduplicatedResult": NewDeduplicatedResult(), + "ChainCorrelationResult": NewChainCorrelationResult(), + // --- input.go --- + "AuditInput": NewAuditInput(), + // --- output.go --- + "Location": Location{}, + "CvssV4Score": CvssV4Score{}, + "EpssScore": EpssScore{}, + "MitreMapping": MitreMapping{}, + "AttackChain": NewAttackChain(), + "ReproductionStep": ReproductionStep{}, + "ServiceDefinition": NewServiceDefinition(), + "CrossServiceFinding": CrossServiceFinding{}, + "RegressionFinding": RegressionFinding{}, + "MonitoringResult": NewMonitoringResult(), + "PolicyViolation": NewPolicyViolation(), + "SecurityAuditResult": NewSecurityAuditResult(), + "AuditProgress": AuditProgress{}, + "AuditMetrics": NewAuditMetrics(), + // --- prove.go --- + "DataFlowTrace": NewDataFlowTrace(), + "ReachabilityProof": NewReachabilityProof(), + "SanitizationResult": SanitizationResult{}, + "ExploitHypothesis": ExploitHypothesis{}, + "DastVerificationResult": DastVerificationResult{}, + "VerdictDecision": VerdictDecision{}, + "RemediationSuggestion": RemediationSuggestion{}, + "DataFlowStep": DataFlowStep{}, + "DataFlowEvidence": NewDataFlowEvidence(), + "SanitizationAnalysis": SanitizationAnalysis{}, + "HttpEvidence": HttpEvidence{}, + "ReachabilityEvidence": NewReachabilityEvidence(), + "ChainStep": ChainStep{}, + "Proof": Proof{}, + "ProverSignal": ProverSignal{}, + "VerifiedFinding": NewVerifiedFinding(), + // --- recon.go --- + "Module": NewModule(), + "EntryPoint": EntryPoint{}, + "TrustBoundary": NewTrustBoundary(), + "Service": Service{}, + "APIEndpoint": APIEndpoint{}, + "ArchitectureMap": NewArchitectureMap(), + "ReconDataFlowStep": ReconDataFlowStep{}, + "SanitizationPoint": NewSanitizationPoint(), + "Sink": Sink{}, + "DataFlow": NewDataFlow(), + "DataFlowMap": NewDataFlowMap(), + "Dependency": Dependency{}, + "KnownCVE": KnownCVE{}, + "OutdatedDep": OutdatedDep{}, + "DependencyReport": NewDependencyReport(), + "SecretFinding": NewSecretFinding(), + "MisconfigFinding": NewMisconfigFinding(), + "ConfigReport": NewConfigReport(), + "CryptoUsage": CryptoUsage{}, + "SecurityContext": NewSecurityContext(), + "ReconResult": NewReconResult(), + "ArchitectureMapRaw": NewArchitectureMapRaw(), + "DataFlowMapRaw": NewDataFlowMapRaw(), + "DependencyReportRaw": NewDependencyReportRaw(), + "ConfigReportRaw": NewConfigReportRaw(), + "SecurityContextRaw": NewSecurityContextRaw(), + // --- views.go --- + "FindingForVerifier": FindingForVerifier{}, + "FindingForDedup": FindingForDedup{}, + "FindingForReachability": FindingForReachability{}, + // --- policies.go --- + "PolicyEvalResult": PolicyEvalResult{}, + } +} + +// orderedKeys returns the top-level object keys of b in the order they appear. +// encoding/json emits struct fields in declaration order, so this exposes the +// Go field order for comparison with pydantic's model_dump() key order. +func orderedKeys(t *testing.T, b []byte) []string { + t.Helper() + dec := json.NewDecoder(bytes.NewReader(b)) + tok, err := dec.Token() + if err != nil { + t.Fatalf("read opening token: %v", err) + } + if d, ok := tok.(json.Delim); !ok || d != '{' { + t.Fatalf("expected a JSON object, got %v", tok) + } + var keys []string + depth := 0 + for dec.More() || depth > 0 { + tok, err := dec.Token() + if err != nil { + t.Fatalf("read token: %v", err) + } + if d, ok := tok.(json.Delim); ok { + switch d { + case '{', '[': + depth++ + case '}', ']': + depth-- + } + continue + } + if depth == 0 { + key, ok := tok.(string) + if !ok { + t.Fatalf("expected an object key, got %T", tok) + } + keys = append(keys, key) + // Skip this key's value wholesale. + var v json.RawMessage + if err := dec.Decode(&v); err != nil { + t.Fatalf("skip value of %q: %v", key, err) + } + } + } + return keys +} + +// asAny re-decodes b into map[string]any so values can be compared with the +// generated JSON on equal footing (both sides get float64 for every number). +func asAny(t *testing.T, b []byte) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("decode: %v", err) + } + return m +} + +// --------------------------------------------------------------------------- +// V1 — every Python model has a Go struct, and vice versa. +// --------------------------------------------------------------------------- + +func TestModelRegistryCoversEveryPydanticModel(t *testing.T) { + gt := loadGroundTruth(t) + registry := goModelRegistry() + + seen := map[string]bool{} + for _, spec := range gt.Models { + seen[spec.GoName] = true + if _, ok := registry[spec.GoName]; !ok { + t.Errorf("pydantic model %s.%s has no Go struct %q in the registry", + spec.PythonModule, spec.PythonClass, spec.GoName) + } + } + for name := range registry { + if !seen[name] { + t.Errorf("Go struct %q is in the registry but no pydantic model maps to it", name) + } + } + if len(registry) != 80 { + t.Errorf("registry has %d models, want 80 (update this count deliberately)", len(registry)) + } +} + +// --------------------------------------------------------------------------- +// V2 — marshaling a Go default value reproduces model_dump()'s ORDERED key +// list. This is the field-name, field-count and field-order gate in one. +// --------------------------------------------------------------------------- + +func TestModelKeysMatchPydanticDump(t *testing.T) { + gt := loadGroundTruth(t) + registry := goModelRegistry() + + for _, spec := range gt.Models { + spec := spec + t.Run(spec.PythonModule+"."+spec.PythonClass, func(t *testing.T) { + value, ok := registry[spec.GoName] + if !ok { + t.Skipf("no Go struct for %s (reported by TestModelRegistryCoversEveryPydanticModel)", spec.GoName) + } + b, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal %s: %v", spec.GoName, err) + } + got := orderedKeys(t, b) + if !reflect.DeepEqual(got, spec.Keys) { + t.Errorf("%s json keys\n got: %v\nwant: %v", spec.GoName, got, spec.Keys) + } + }) + } +} + +// --------------------------------------------------------------------------- +// V3 — every non-required field serialises to what pydantic's default +// serialises to: null for Optional, [] for default_factory=list, {} for +// default_factory=dict, and the literal value for a non-zero scalar default. +// This is what proves "NO omitempty", "Optional -> pointer" and the +// defaults.go seeding are all correct at once. +// --------------------------------------------------------------------------- + +func TestModelDefaultsMatchPydanticDefaults(t *testing.T) { + gt := loadGroundTruth(t) + registry := goModelRegistry() + uuidRe := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + + for _, spec := range gt.Models { + spec := spec + t.Run(spec.PythonModule+"."+spec.PythonClass, func(t *testing.T) { + value, ok := registry[spec.GoName] + if !ok { + t.Skipf("no Go struct for %s", spec.GoName) + } + b, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal %s: %v", spec.GoName, err) + } + got := asAny(t, b) + + for _, field := range spec.NullFields { + if v, present := got[field]; !present || v != nil { + t.Errorf("%s.%s = %#v, want null (pydantic Optional default None)", spec.GoName, field, v) + } + } + for _, field := range spec.EmptyListFields { + v, present := got[field] + if !present { + t.Errorf("%s.%s missing", spec.GoName, field) + continue + } + list, isList := v.([]any) + if !isList || len(list) != 0 { + t.Errorf("%s.%s = %#v, want [] (pydantic default_factory=list; a nil Go slice marshals to null)", + spec.GoName, field, v) + } + } + for _, field := range spec.EmptyDictFields { + v, present := got[field] + if !present { + t.Errorf("%s.%s missing", spec.GoName, field) + continue + } + obj, isObj := v.(map[string]any) + if !isObj || len(obj) != 0 { + t.Errorf("%s.%s = %#v, want {} (pydantic default_factory=dict)", spec.GoName, field, v) + } + } + for field, want := range spec.ScalarDefaults { + if !reflect.DeepEqual(got[field], want) { + t.Errorf("%s.%s = %#v, want %#v (pydantic default)", spec.GoName, field, got[field], want) + } + } + for _, field := range spec.UUIDDefaults { + s, isStr := got[field].(string) + if !isStr || !uuidRe.MatchString(s) { + t.Errorf("%s.%s = %#v, want an RFC 4122 v4 uuid string (pydantic default_factory=lambda: str(uuid4()))", + spec.GoName, field, got[field]) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// V4 — the four models prove.py re-declares byte-identically to output.py +// really are identical, which is what licenses the Go package to keep one +// struct per name. +// --------------------------------------------------------------------------- + +func TestDuplicateDeclarationsAreIdentical(t *testing.T) { + gt := loadGroundTruth(t) + byName := map[string]modelSpec{} + for _, spec := range gt.Models { + if spec.DuplicateOf == nil { + byName[spec.GoName] = spec + } + } + + dupes := 0 + for _, spec := range gt.Models { + if spec.DuplicateOf == nil { + continue + } + dupes++ + canonical, ok := byName[*spec.DuplicateOf] + if !ok { + t.Fatalf("%s.%s says it duplicates %q, which is not declared anywhere", + spec.PythonModule, spec.PythonClass, *spec.DuplicateOf) + } + if !reflect.DeepEqual(spec.Keys, canonical.Keys) { + t.Errorf("%s.%s keys %v != %s.%s keys %v — the Go package cannot keep one struct for both", + spec.PythonModule, spec.PythonClass, spec.Keys, + canonical.PythonModule, canonical.PythonClass, canonical.Keys) + } + if !reflect.DeepEqual(spec.NullFields, canonical.NullFields) { + t.Errorf("%s.%s null fields %v != %s null fields %v", + spec.PythonModule, spec.PythonClass, spec.NullFields, canonical.GoName, canonical.NullFields) + } + if !reflect.DeepEqual(spec.Required, canonical.Required) { + t.Errorf("%s.%s required %v != %s required %v", + spec.PythonModule, spec.PythonClass, spec.Required, canonical.GoName, canonical.Required) + } + } + if dupes != 4 { + t.Errorf("found %d duplicate declarations, want 4 (Location, CvssV4Score, EpssScore, ReproductionStep)", dupes) + } +} + +// --------------------------------------------------------------------------- +// V5 — enum members and values match Python exactly, aliases included. +// --------------------------------------------------------------------------- + +func TestEnumMembersMatchPython(t *testing.T) { + gt := loadGroundTruth(t) + + goEnums := map[string]map[string]any{ + "FindingType": { + "SAST": string(FindingTypeSast), "SCA": string(FindingTypeSca), + "SECRETS": string(FindingTypeSecrets), "CONFIG": string(FindingTypeConfig), + "LOGIC": string(FindingTypeLogic), "API": string(FindingTypeAPI), + }, + "Severity": { + "CRITICAL": string(SeverityCritical), "HIGH": string(SeverityHigh), + "MEDIUM": string(SeverityMedium), "LOW": string(SeverityLow), + "INFO": string(SeverityInfo), + }, + "Confidence": { + "HIGH": string(ConfidenceHigh), "MEDIUM": string(ConfidenceMedium), + "LOW": string(ConfidenceLow), + }, + "HuntStrategy": { + "INJECTION": string(HuntStrategyInjection), "XSS": string(HuntStrategyXSS), + "DOS": string(HuntStrategyDos), "SSRF": string(HuntStrategySSRF), + "AUTH": string(HuntStrategyAuth), "CRYPTO": string(HuntStrategyCrypto), + "BUSINESS_LOGIC": string(HuntStrategyBusinessLogic), + "LOGIC_BUGS": string(HuntStrategyLogicBugs), + "DATA_EXPOSURE": string(HuntStrategyDataExposure), + "SUPPLY_CHAIN": string(HuntStrategySupplyChain), + "CONFIG_SECRETS": string(HuntStrategyConfigSecrets), + "API_SECURITY": string(HuntStrategyAPISecurity), + "PYTHON_SPECIFIC": string(HuntStrategyPythonSpecific), + "JAVASCRIPT_SPECIFIC": string(HuntStrategyJavascriptSpecific), + }, + "Verdict": { + "CONFIRMED": string(VerdictConfirmed), "LIKELY": string(VerdictLikely), + "INCONCLUSIVE": string(VerdictInconclusive), "NOT_EXPLOITABLE": string(VerdictNotExploitable), + }, + "EvidenceLevel": { + "STATIC_MATCH": float64(EvidenceLevelStaticMatch), + "FLOW_IDENTIFIED": float64(EvidenceLevelFlowIdentified), + "REACHABILITY_CONFIRMED": float64(EvidenceLevelReachabilityConfirmed), + "SANITIZATION_BYPASSABLE": float64(EvidenceLevelSanitizationBypassable), + "EXPLOIT_SCENARIO_VALIDATED": float64(EvidenceLevelExploitScenarioValidated), + "FULL_EXPLOIT": float64(EvidenceLevelFullExploit), + }, + } + + if len(goEnums) != len(gt.Enums) { + t.Errorf("Go declares %d enums, Python has %d", len(goEnums), len(gt.Enums)) + } + for name, spec := range gt.Enums { + got, ok := goEnums[name] + if !ok { + t.Errorf("python enum %s has no Go counterpart", name) + continue + } + if !reflect.DeepEqual(got, spec.Members) { + t.Errorf("enum %s members\n got: %v\nwant: %v", name, sortedPairs(got), sortedPairs(spec.Members)) + } + } +} + +func sortedPairs(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k, v := range m { + out = append(out, fmt.Sprintf("%s=%v", k, v)) + } + sort.Strings(out) + return out +} + +// --------------------------------------------------------------------------- +// V6 — every name sec_af.schemas.__init__.__all__ re-exports exists in the Go +// package under the SAME name. This is the cross-agent contract harnessx +// relies on to resolve testdata/schemas/.json. +// --------------------------------------------------------------------------- + +func TestSchemasInitExportsHaveGoTypes(t *testing.T) { + gt := loadGroundTruth(t) + if len(gt.SchemasAll) != 62 { + t.Errorf("sec_af.schemas.__all__ has %d names, want 62 (update deliberately)", len(gt.SchemasAll)) + } + + known := map[string]bool{} + for name := range goModelRegistry() { + known[name] = true + } + // The enums are types too, and __all__ re-exports them. + for _, name := range []string{"FindingType", "Severity", "Confidence", "HuntStrategy", "Verdict", "EvidenceLevel"} { + known[name] = true + } + // Python parity: __init__.py aliases recon.DataFlowStep as + // ReconDataFlowStep, which the registry already carries under that name. + for _, name := range gt.SchemasAll { + if !known[name] { + t.Errorf("sec_af.schemas.__all__ exports %q with no Go type of that name", name) + } + } +} diff --git a/go/internal/schemas/output.go b/go/internal/schemas/output.go new file mode 100644 index 0000000..ce0d94c --- /dev/null +++ b/go/internal/schemas/output.go @@ -0,0 +1,206 @@ +package schemas + +// This file ports src/sec_af/schemas/output.py — the output and orchestration +// payloads (DESIGN.md §7, §12.3). +// +// Location, CvssV4Score, EpssScore and ReproductionStep are declared here even +// though prove.py declares byte-identical copies: schemas/__init__.py +// re-exports output.py's, and the Go package needs exactly one struct per name +// (harnessx resolves the embedded pydantic schema fixture by Go type name). + +// Location is source-location metadata for a finding reference +// (DESIGN.md §7.1). +// +// Ports schemas/output.py Location (== schemas/prove.py Location). +type Location struct { + FilePath string `json:"file_path"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` + StartColumn *int `json:"start_column"` + EndColumn *int `json:"end_column"` + FunctionName *string `json:"function_name"` + CodeSnippet *string `json:"code_snippet"` +} + +// CvssV4Score holds CVSS v4 scoring details (DESIGN.md §7.1). +// +// Ports schemas/output.py CvssV4Score (== schemas/prove.py CvssV4Score). +type CvssV4Score struct { + Vector string `json:"vector"` + BaseScore float64 `json:"base_score"` + Severity string `json:"severity"` + Automatable bool `json:"automatable"` + SubsequentImpact bool `json:"subsequent_impact"` +} + +// EpssScore holds EPSS probability details (DESIGN.md §7.1). +// +// Ports schemas/output.py EpssScore (== schemas/prove.py EpssScore). +type EpssScore struct { + Score float64 `json:"score"` + Percentile float64 `json:"percentile"` + Date string `json:"date"` +} + +// MitreMapping is a MITRE ATT&CK mapping for an attack chain +// (DESIGN.md §7.2). +// +// Ports schemas/output.py MitreMapping. +type MitreMapping struct { + Tactic string `json:"tactic"` + TechniqueID string `json:"technique_id"` + TechniqueName string `json:"technique_name"` +} + +// AttackChain is a verified multi-step exploit chain (DESIGN.md §7.2). +// +// Ports schemas/output.py AttackChain. Seeded (defaults.go): findings `[]`. +// `mitre_attack_mapping` is `list[MitreMapping] | None` with NO +// default_factory, so a nil Go slice marshaling to null is exactly right. +type AttackChain struct { + ChainID string `json:"chain_id"` + Title string `json:"title"` + Description string `json:"description"` + Findings []string `json:"findings"` + CombinedSeverity Severity `json:"combined_severity"` + CombinedImpact string `json:"combined_impact"` + MitreAttackMapping []MitreMapping `json:"mitre_attack_mapping"` +} + +// ReproductionStep is one reproduction instruction for analysts +// (DESIGN.md §7.1). +// +// Ports schemas/output.py ReproductionStep (== schemas/prove.py +// ReproductionStep). +type ReproductionStep struct { + Step int `json:"step"` + Description string `json:"description"` + Command *string `json:"command"` + ExpectedOutput *string `json:"expected_output"` +} + +// ServiceDefinition is a service node in a multi-repo architecture. +// +// Ports schemas/output.py ServiceDefinition. Seeded (defaults.go): +// api_endpoints and dependencies `[]`. +type ServiceDefinition struct { + Name string `json:"name"` + RepoURL string `json:"repo_url"` + APIEndpoints []string `json:"api_endpoints"` + // Dependencies are the names of services this one depends on. + Dependencies []string `json:"dependencies"` +} + +// CrossServiceFinding is the flat schema for cross-service attack chain +// analysis. +// +// Ports schemas/output.py CrossServiceFinding. +type CrossServiceFinding struct { + ChainDescription string `json:"chain_description"` + ServicesInvolved []string `json:"services_involved"` + EntryPoint string `json:"entry_point"` + Impact string `json:"impact"` +} + +// RegressionFinding is a finding that appeared (or disappeared) since the +// baseline scan. +// +// Ports schemas/output.py RegressionFinding. +type RegressionFinding struct { + FindingTitle string `json:"finding_title"` + FindingID string `json:"finding_id"` + Severity string `json:"severity"` + CweID string `json:"cwe_id"` + // Status is one of: "new", "fixed", "unchanged". + Status string `json:"status"` +} + +// MonitoringResult is the result of comparing the current scan against a +// baseline. +// +// Ports schemas/output.py MonitoringResult. Seeded (defaults.go): +// new_findings and fixed_findings `[]`. +type MonitoringResult struct { + BaselineCommit string `json:"baseline_commit"` + CurrentCommit string `json:"current_commit"` + NewFindings []RegressionFinding `json:"new_findings"` + FixedFindings []RegressionFinding `json:"fixed_findings"` + UnchangedCount int `json:"unchanged_count"` + RegressionDetected bool `json:"regression_detected"` +} + +// PolicyViolation is a violation of an org-specific security policy. +// +// Ports schemas/output.py PolicyViolation. Seeded (defaults.go): +// severity="medium". +type PolicyViolation struct { + // Policy is the policy rule that was violated. + Policy string `json:"policy"` + // ViolationDescription explains how the code violates the policy. + ViolationDescription string `json:"violation_description"` + FilePath string `json:"file_path"` + Severity string `json:"severity"` +} + +// SecurityAuditResult is the top-level SEC-AF audit output (DESIGN.md §7.3). +// +// Ports schemas/output.py SecurityAuditResult. Seeded (defaults.go): the five +// list fields `[]`, the three dict fields `{}`. +// +// Timestamp is a pydantic `datetime`; see timestamp.go for the exact wire +// format (`datetime.isoformat()` via FastAPI's jsonable_encoder). +type SecurityAuditResult struct { + Repository string `json:"repository"` + CommitSha string `json:"commit_sha"` + Branch *string `json:"branch"` + Timestamp Timestamp `json:"timestamp"` + DepthProfile string `json:"depth_profile"` + StrategiesUsed []string `json:"strategies_used"` + Provider string `json:"provider"` + Findings []VerifiedFinding `json:"findings"` + AttackChains []AttackChain `json:"attack_chains"` + TotalRawFindings int `json:"total_raw_findings"` + Confirmed int `json:"confirmed"` + Likely int `json:"likely"` + Inconclusive int `json:"inconclusive"` + NotExploitable int `json:"not_exploitable"` + NoiseReductionPct float64 `json:"noise_reduction_pct"` + BySeverity map[string]int `json:"by_severity"` + ComplianceGaps []ComplianceGap `json:"compliance_gaps"` + PolicyViolations []PolicyViolation `json:"policy_violations"` + DurationSeconds float64 `json:"duration_seconds"` + AgentInvocations int `json:"agent_invocations"` + CostUsd float64 `json:"cost_usd"` + CostBreakdown map[string]float64 `json:"cost_breakdown"` + Metadata map[string]any `json:"metadata"` + Sarif string `json:"sarif"` +} + +// AuditProgress is an orchestrator phase progress event (DESIGN.md §12.3). +// +// Ports schemas/output.py AuditProgress. Every field is required. +type AuditProgress struct { + Phase string `json:"phase"` + PhaseProgress float64 `json:"phase_progress"` + AgentsTotal int `json:"agents_total"` + AgentsCompleted int `json:"agents_completed"` + AgentsRunning int `json:"agents_running"` + FindingsSoFar int `json:"findings_so_far"` + ElapsedSeconds float64 `json:"elapsed_seconds"` + EstimatedRemainingSeconds float64 `json:"estimated_remaining_seconds"` + CostSoFarUsd float64 `json:"cost_so_far_usd"` +} + +// AuditMetrics holds run-level performance and budget metrics +// (DESIGN.md §7.3, §9.1). +// +// Ports schemas/output.py AuditMetrics. Seeded (defaults.go): +// cost_breakdown `{}`. +type AuditMetrics struct { + DurationSeconds float64 `json:"duration_seconds"` + AgentInvocations int `json:"agent_invocations"` + CostUsd float64 `json:"cost_usd"` + CostBreakdown map[string]float64 `json:"cost_breakdown"` + BudgetExhausted bool `json:"budget_exhausted"` + FindingsNotVerified int `json:"findings_not_verified"` +} diff --git a/go/internal/schemas/policies.go b/go/internal/schemas/policies.go new file mode 100644 index 0000000..5afa4f3 --- /dev/null +++ b/go/internal/schemas/policies.go @@ -0,0 +1,26 @@ +package schemas + +// This file ports the one pydantic model declared OUTSIDE src/sec_af/schemas/ +// that crosses a JSON boundary: PolicyEvalResult from src/sec_af/policies.py, +// which `evaluate_policy` hands to `app.harness(schema=PolicyEvalResult)`. +// It lives in this package so harnessx can resolve its embedded pydantic +// schema fixture by Go type name like every other harness schema; the rest of +// policies.py (prompt building, evaluate_policy) belongs to internal/policies. +// +// config.py's BaseModels (BudgetConfig, AuditConfig, AIIntegrationConfig) are +// deliberately NOT here — they are process configuration, never serialized +// across a reasoner boundary, and live in internal/config. + +// PolicyEvalResult is the flat schema for AI policy evaluation. 4 fields. +// +// Ports policies.py PolicyEvalResult. Every field is required. +type PolicyEvalResult struct { + // Violated reports whether the policy is violated. + Violated bool `json:"violated"` + // Description explains how the policy is violated, or is 'No violation'. + Description string `json:"description"` + // FilePath is the primary file where the violation occurs, or 'N/A'. + FilePath string `json:"file_path"` + // Severity is "high", "medium" or "low". + Severity string `json:"severity"` +} diff --git a/go/internal/schemas/prove.go b/go/internal/schemas/prove.go new file mode 100644 index 0000000..1db5280 --- /dev/null +++ b/go/internal/schemas/prove.go @@ -0,0 +1,637 @@ +package schemas + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" +) + +// This file ports src/sec_af/schemas/prove.py — the PROVE phase enums, the +// flat sub-agent harness/ai schemas, the evidence artifacts and VerifiedFinding +// (DESIGN.md §6.3-§6.4, §7.1). +// +// prove.py also re-declares Location, CvssV4Score, EpssScore and +// ReproductionStep byte-identically to output.py. Go keeps ONE struct each, +// declared in output.go (which is what schemas/__init__.py re-exports); the +// parity test proves the two Python declarations really do match. + +// Verdict is the exploitability verdict vocabulary (DESIGN.md §6.3). +// +// Ports schemas/prove.py Verdict (`class Verdict(str, Enum)`). +type Verdict string + +// Verdict members, in Python declaration order. +const ( + VerdictConfirmed Verdict = "confirmed" + VerdictLikely Verdict = "likely" + VerdictInconclusive Verdict = "inconclusive" + VerdictNotExploitable Verdict = "not_exploitable" +) + +// AllVerdicts lists every Verdict in Python declaration order. +var AllVerdicts = []Verdict{ + VerdictConfirmed, VerdictLikely, VerdictInconclusive, VerdictNotExploitable, +} + +// Valid reports whether v is one of the declared members. +func (v Verdict) Valid() bool { + for _, m := range AllVerdicts { + if v == m { + return true + } + } + return false +} + +// ParseVerdict is the Go equivalent of `Verdict(s)`. +func ParseVerdict(s string) (Verdict, error) { + v := Verdict(s) + if !v.Valid() { + return "", &EnumValueError{Enum: "Verdict", Value: s} + } + return v, nil +} + +// EvidenceLevel is the six-level evidence strength hierarchy (DESIGN.md §6.3). +// +// Ports schemas/prove.py EvidenceLevel (`class EvidenceLevel(IntEnum)`), so it +// is an INT on the wire — `model_dump(mode="json")` emits 1..6, and +// `EvidenceLevel.FULL_EXPLOIT == 6` is True in Python. +type EvidenceLevel int + +// EvidenceLevel members, in Python declaration order (increasing strength). +const ( + EvidenceLevelStaticMatch EvidenceLevel = 1 + EvidenceLevelFlowIdentified EvidenceLevel = 2 + EvidenceLevelReachabilityConfirmed EvidenceLevel = 3 + EvidenceLevelSanitizationBypassable EvidenceLevel = 4 + EvidenceLevelExploitScenarioValidated EvidenceLevel = 5 + EvidenceLevelFullExploit EvidenceLevel = 6 +) + +// AllEvidenceLevels lists every EvidenceLevel in Python declaration order. +var AllEvidenceLevels = []EvidenceLevel{ + EvidenceLevelStaticMatch, EvidenceLevelFlowIdentified, + EvidenceLevelReachabilityConfirmed, EvidenceLevelSanitizationBypassable, + EvidenceLevelExploitScenarioValidated, EvidenceLevelFullExploit, +} + +// evidenceLevelNames maps each level to its Python member name. +var evidenceLevelNames = map[EvidenceLevel]string{ + EvidenceLevelStaticMatch: "STATIC_MATCH", + EvidenceLevelFlowIdentified: "FLOW_IDENTIFIED", + EvidenceLevelReachabilityConfirmed: "REACHABILITY_CONFIRMED", + EvidenceLevelSanitizationBypassable: "SANITIZATION_BYPASSABLE", + EvidenceLevelExploitScenarioValidated: "EXPLOIT_SCENARIO_VALIDATED", + EvidenceLevelFullExploit: "FULL_EXPLOIT", +} + +// Valid reports whether e is one of the six declared levels. +func (e EvidenceLevel) Valid() bool { _, ok := evidenceLevelNames[e]; return ok } + +// Name returns the Python member name ("FULL_EXPLOIT"), or "" for an +// undeclared value. +func (e EvidenceLevel) Name() string { return evidenceLevelNames[e] } + +// String renders the level the way Python 3.11 renders an IntEnum: as the +// decimal number, NOT as "EvidenceLevel.FULL_EXPLOIT" (3.11 gave IntEnum +// int.__str__). Use Name() for the symbolic form. +func (e EvidenceLevel) String() string { return strconv.Itoa(int(e)) } + +// ParseEvidenceLevel is the Go equivalent of `EvidenceLevel(i)`. +func ParseEvidenceLevel(i int) (EvidenceLevel, error) { + v := EvidenceLevel(i) + if !v.Valid() { + return 0, &EnumValueError{Enum: "EvidenceLevel", Value: strconv.Itoa(i)} + } + return v, nil +} + +// --------------------------------------------------------------------------- +// Flat sub-agent schemas +// --------------------------------------------------------------------------- + +// DataFlowTrace is the flat schema for the data-flow tracing sub-agent. +// +// Ports schemas/prove.py DataFlowTrace, including its three `mode="before"` +// field_validators (see UnmarshalJSON). +type DataFlowTrace struct { + // Source is where tainted input enters, e.g. "request.params.id". + Source string `json:"source"` + // Sink is the security-sensitive operation reached, e.g. "sql.execute(query)". + Sink string `json:"sink"` + // Steps is an ordered list of file:line descriptions showing the flow path. + Steps []string `json:"steps"` + // SinkReached reports whether tainted data actually reaches the sink. + SinkReached bool `json:"sink_reached"` +} + +// UnmarshalJSON ports DataFlowTrace's `_coerce_to_str` / `_coerce_steps` +// before-validators: LLMs sometimes return dicts or lists where a flat string +// (or list of strings) is declared, and Python coerces rather than failing. +func (d *DataFlowTrace) UnmarshalJSON(b []byte) error { + var raw struct { + Source json.RawMessage `json:"source"` + Sink json.RawMessage `json:"sink"` + Steps json.RawMessage `json:"steps"` + SinkReached bool `json:"sink_reached"` + } + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + d.Source = coerceToStr(raw.Source) + d.Sink = coerceToStr(raw.Sink) + d.Steps = coerceToStrList(raw.Steps) + d.SinkReached = raw.SinkReached + return nil +} + +// ReachabilityProof is the flat schema for dependency reachability analysis. +// +// Ports schemas/prove.py ReachabilityProof, including its two before-validators. +type ReachabilityProof struct { + // VulnerableFunction is the vulnerable function/method in the dependency. + VulnerableFunction string `json:"vulnerable_function"` + // CallChain is the import/call chain from app code to that function. + CallChain []string `json:"call_chain"` + // Reachable reports whether the vulnerable function is actually called. + Reachable bool `json:"reachable"` + // Direct reports whether the dependency is direct or transitive. + Direct bool `json:"direct"` +} + +// UnmarshalJSON ports ReachabilityProof's `_coerce_to_str` / +// `_coerce_call_chain` before-validators. +// +// Python parity: `_coerce_to_str` here does NOT have the dict-key probing that +// DataFlowTrace's version has — a dict lands on `str(v)`. coerceToStr handles +// both by taking a `probeDictKeys` flag; ReachabilityProof passes false. +func (r *ReachabilityProof) UnmarshalJSON(b []byte) error { + var raw struct { + VulnerableFunction json.RawMessage `json:"vulnerable_function"` + CallChain json.RawMessage `json:"call_chain"` + Reachable bool `json:"reachable"` + Direct bool `json:"direct"` + } + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + r.VulnerableFunction = coerceToStrNoProbe(raw.VulnerableFunction) + r.CallChain = coerceToStrList(raw.CallChain) + r.Reachable = raw.Reachable + r.Direct = raw.Direct + return nil +} + +// SanitizationResult is the flat schema for the sanitization analysis +// sub-agent. +// +// Ports schemas/prove.py SanitizationResult. +type SanitizationResult struct { + // Found reports whether any sanitization/validation was found on the path. + Found bool `json:"found"` + // Type is e.g. "parameterized query", "html encoding". + Type *string `json:"type"` + // Sufficient reports whether the sanitization prevents the exploit. + Sufficient *bool `json:"sufficient"` + // BypassMethod describes how the sanitization could be bypassed. + BypassMethod *string `json:"bypass_method"` +} + +// ExploitHypothesis is the flat schema for the exploit construction sub-agent. +// +// Ports schemas/prove.py ExploitHypothesis, including its before-validators. +type ExploitHypothesis struct { + // Hypothesis is a natural-language description of the exploit scenario. + Hypothesis string `json:"hypothesis"` + // Payload is a concrete exploit payload or input. + Payload *string `json:"payload"` + // ExpectedOutcome is what would happen if the exploit succeeds. + ExpectedOutcome string `json:"expected_outcome"` +} + +// UnmarshalJSON ports ExploitHypothesis's `_coerce_to_str` / `_coerce_payload` +// before-validators. Python parity: `_coerce_payload` maps None to None but +// stringifies anything else (including a falsy 0 or "" — unlike +// `_coerce_to_str`, which maps falsy values to "unknown"). +func (e *ExploitHypothesis) UnmarshalJSON(b []byte) error { + var raw struct { + Hypothesis json.RawMessage `json:"hypothesis"` + Payload json.RawMessage `json:"payload"` + ExpectedOutcome json.RawMessage `json:"expected_outcome"` + } + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + e.Hypothesis = coerceToStrNoProbe(raw.Hypothesis) + e.ExpectedOutcome = coerceToStrNoProbe(raw.ExpectedOutcome) + e.Payload = coercePayload(raw.Payload) + return nil +} + +// DastVerificationResult is the flat schema for DAST-like runtime verification. +// +// Ports schemas/prove.py DastVerificationResult. +type DastVerificationResult struct { + PayloadSent string `json:"payload_sent"` + ResponseSummary string `json:"response_summary"` + ExploitConfirmed bool `json:"exploit_confirmed"` + SafetyNotes string `json:"safety_notes"` +} + +// VerdictDecision is the flat schema for the verdict sub-agent. It goes +// through `.ai()`, not `.harness()`. +// +// Ports schemas/prove.py VerdictDecision. Verdict/EvidenceLevel are a plain +// string/int here because this is the raw model output, validated downstream. +type VerdictDecision struct { + // Verdict is one of: "confirmed", "likely", "inconclusive", "not_exploitable". + Verdict string `json:"verdict"` + // EvidenceLevel is 1..6: 1=STATIC_MATCH to 6=FULL_EXPLOIT. + EvidenceLevel int `json:"evidence_level"` + Rationale string `json:"rationale"` + // Confidence is one of: "high", "medium", "low". + Confidence string `json:"confidence"` +} + +// RemediationSuggestion is the flat schema for an AI-generated remediation. +// +// Ports schemas/prove.py RemediationSuggestion. +type RemediationSuggestion struct { + FixDescription string `json:"fix_description"` + // PatchDiff is a unified-diff patch showing the code changes needed. + PatchDiff string `json:"patch_diff"` + // Confidence is "high", "medium" or "low". + Confidence string `json:"confidence"` +} + +// --------------------------------------------------------------------------- +// Evidence artifacts +// --------------------------------------------------------------------------- + +// DataFlowStep is one step in a source-to-sink proof trace (DESIGN.md §6.4). +// +// Ports schemas/prove.py DataFlowStep — the model schemas/__init__.py +// re-exports under the bare name. recon.py's same-named model is +// ReconDataFlowStep. +type DataFlowStep struct { + File string `json:"file"` + Line int `json:"line"` + Description string `json:"description"` + Tainted bool `json:"tainted"` +} + +// DataFlowEvidence is the grouped data-flow evidence artifact +// (DESIGN.md §6.4). +// +// Ports schemas/prove.py DataFlowEvidence. Seeded (defaults.go): steps `[]`. +type DataFlowEvidence struct { + Steps []DataFlowStep `json:"steps"` + Source *string `json:"source"` + Sink *string `json:"sink"` + SinkReached bool `json:"sink_reached"` +} + +// SanitizationAnalysis is the sanitization effectiveness analysis +// (DESIGN.md §6.4). +// +// Ports schemas/prove.py SanitizationAnalysis. +type SanitizationAnalysis struct { + SanitizationFound bool `json:"sanitization_found"` + SanitizationType *string `json:"sanitization_type"` + SanitizationSufficient *bool `json:"sanitization_sufficient"` + BypassPossible *bool `json:"bypass_possible"` + BypassMethod *string `json:"bypass_method"` +} + +// HttpEvidence is the HTTP request/response evidence artifact +// (DESIGN.md §6.4). +// +// Ports schemas/prove.py HttpEvidence. `headers` is `dict[str, str] | None`; +// a nil Go map already marshals to null, so no pointer is needed. +type HttpEvidence struct { + Method *string `json:"method"` + URL *string `json:"url"` + Headers map[string]string `json:"headers"` + Body *string `json:"body"` + HighlightedSegment *string `json:"highlighted_segment"` +} + +// ReachabilityEvidence is the reachability evidence for dependency findings +// (DESIGN.md §6.4). +// +// Ports schemas/prove.py ReachabilityEvidence. Seeded (defaults.go): +// call_chain `[]`. +type ReachabilityEvidence struct { + VulnerableFunction string `json:"vulnerable_function"` + CallChain []string `json:"call_chain"` + Reachable bool `json:"reachable"` + DirectDependency bool `json:"direct_dependency"` +} + +// ChainStep is a chain evidence link across findings (DESIGN.md §6.4). +// +// Ports schemas/prove.py ChainStep. +type ChainStep struct { + StepNumber int `json:"step_number"` + FindingID string `json:"finding_id"` + Description string `json:"description"` + Enables string `json:"enables"` +} + +// Proof is the evidence artifact supporting a final verdict (DESIGN.md §6.4). +// +// Ports schemas/prove.py Proof. Every optional field is `X | None` with NO +// default_factory, so a nil slice marshaling to null is exactly right. +type Proof struct { + ExploitHypothesis string `json:"exploit_hypothesis"` + VerificationMethod string `json:"verification_method"` + EvidenceLevel EvidenceLevel `json:"evidence_level"` + DataFlowTrace []DataFlowStep `json:"data_flow_trace"` + DataFlowEvidence *DataFlowEvidence `json:"data_flow_evidence"` + SanitizationAnalysis *SanitizationAnalysis `json:"sanitization_analysis"` + VulnerableCode *string `json:"vulnerable_code"` + ExploitPayload *string `json:"exploit_payload"` + ExpectedOutcome *string `json:"expected_outcome"` + PocCode *string `json:"poc_code"` + PocExecutionOutput *string `json:"poc_execution_output"` + HTTPRequest *HttpEvidence `json:"http_request"` + HTTPResponse *HttpEvidence `json:"http_response"` + Reachability *ReachabilityEvidence `json:"reachability"` + ChainSteps []ChainStep `json:"chain_steps"` +} + +// ProverSignal is the depth-first expansion signal from a prover +// (DESIGN.md §6.6). +// +// Ports schemas/prove.py ProverSignal. +type ProverSignal struct { + Expand bool `json:"expand"` + ExpansionReason *string `json:"expansion_reason"` + ExpansionStrategy *string `json:"expansion_strategy"` + ExpansionTarget *string `json:"expansion_target"` +} + +// VerifiedFinding is a finding fully assessed by the PROVE phase +// (DESIGN.md §7.1). +// +// Ports schemas/prove.py VerifiedFinding. Seeded (defaults.go): tags, +// related_locations, compliance and reproduction_steps `[]`, and +// NewVerifiedFinding mints ID as a fresh uuid4. +type VerifiedFinding struct { + ID string `json:"id"` + Fingerprint string `json:"fingerprint"` + Title string `json:"title"` + Description string `json:"description"` + FindingType FindingType `json:"finding_type"` + CweID string `json:"cwe_id"` + CweName string `json:"cwe_name"` + OwaspCategory *string `json:"owasp_category"` + Tags []string `json:"tags"` + Verdict Verdict `json:"verdict"` + EvidenceLevel EvidenceLevel `json:"evidence_level"` + Rationale string `json:"rationale"` + Severity Severity `json:"severity"` + CvssV4 *CvssV4Score `json:"cvss_v4"` + Epss *EpssScore `json:"epss"` + ExploitabilityScore float64 `json:"exploitability_score"` + Proof *Proof `json:"proof"` + Location Location `json:"location"` + RelatedLocations []Location `json:"related_locations"` + ChainID *string `json:"chain_id"` + ChainStep *int `json:"chain_step"` + Enables []string `json:"enables"` + Compliance []ComplianceMapping `json:"compliance"` + ReproductionSteps []ReproductionStep `json:"reproduction_steps"` + Remediation *RemediationSuggestion `json:"remediation"` + SarifRuleID string `json:"sarif_rule_id"` + SarifSecuritySeverity float64 `json:"sarif_security_severity"` + DropReason *string `json:"drop_reason"` +} + +// --------------------------------------------------------------------------- +// before-validator helpers (schemas/prove.py @field_validator(mode="before")) +// --------------------------------------------------------------------------- + +// coerceToStr ports DataFlowTrace._coerce_to_str: a JSON string passes through +// UNCHANGED (Python's `if isinstance(v, str): return v` runs before the falsy +// check, so "" stays ""); a JSON object is probed for the first of +// value/name/description/path/text whose value is a string; anything else +// (including a probed-but-unmatched object) falls back to Python's +// `str(v) if v else "unknown"`. +func coerceToStr(raw json.RawMessage) string { + if len(raw) == 0 { + // Absent key. Python raises ValidationError on a missing required + // field; the Go port keeps the zero value and lets the caller decide. + return "" + } + if s, ok := jsonString(raw); ok { + return s + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err == nil { + // Python parity: the probe requires the value to BE a string; a + // non-string under "name" falls through to the next candidate key. + for _, key := range []string{"value", "name", "description", "path", "text"} { + if v, ok := obj[key]; ok { + if sv, isStr := jsonString(v); isStr { + return sv + } + } + } + } + return coerceToStrNoProbe(raw) +} + +// coerceToStrNoProbe ports the plainer `_coerce_to_str` on ReachabilityProof +// and ExploitHypothesis: a string passes through UNCHANGED (including ""), +// everything else becomes `str(v) if v else "unknown"`. +func coerceToStrNoProbe(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + if s, ok := jsonString(raw); ok { + return s + } + if isFalsyJSON(raw) { + return "unknown" + } + return pyStr(raw) +} + +// coercePayload ports ExploitHypothesis._coerce_payload: None stays None, a +// string passes through, anything else is `str(v)` — with NO falsy special +// case, so a JSON 0 becomes "0" rather than "unknown". +func coercePayload(raw json.RawMessage) *string { + if len(raw) == 0 || isJSONNull(raw) { + return nil + } + if s, ok := jsonString(raw); ok { + return &s + } + out := pyStr(raw) + return &out +} + +// coerceToStrList ports `_coerce_steps` / `_coerce_call_chain`: a JSON array +// becomes a list with every non-string element stringified via `str()`; +// anything else becomes `[str(v)] if v else []`. +func coerceToStrList(raw json.RawMessage) []string { + if len(raw) == 0 { + return []string{} + } + var items []json.RawMessage + if err := json.Unmarshal(raw, &items); err == nil { + out := make([]string, 0, len(items)) + for _, item := range items { + if s, ok := jsonString(item); ok { + out = append(out, s) // Python parity: str items are kept as-is + continue + } + out = append(out, pyStr(item)) + } + return out + } + if isFalsyJSON(raw) { + return []string{} + } + if s, ok := jsonString(raw); ok { + return []string{s} + } + return []string{pyStr(raw)} +} + +// jsonString reports whether raw is a JSON STRING and returns its value. It +// exists because json.Unmarshal happily decodes a JSON null into a Go string +// (leaving it ""), which would make a null look like an empty string — and the +// two take different branches in every one of Python's before-validators. +func jsonString(raw json.RawMessage) (string, bool) { + var s *string + if err := json.Unmarshal(raw, &s); err != nil || s == nil { + return "", false + } + return *s, true +} + +// isJSONNull reports whether raw decodes to JSON null. +func isJSONNull(raw json.RawMessage) bool { + var v any + return json.Unmarshal(raw, &v) == nil && v == nil +} + +// isFalsyJSON reports whether raw is one of the JSON values Python considers +// falsy: null, false, 0, 0.0, "", [] and {}. +func isFalsyJSON(raw json.RawMessage) bool { + var v any + if err := json.Unmarshal(raw, &v); err != nil { + return false + } + switch t := v.(type) { + case nil: + return true + case bool: + return !t + case float64: + return t == 0 + case string: + return t == "" + case []any: + return len(t) == 0 + case map[string]any: + return len(t) == 0 + } + return false +} + +// pyStr renders a decoded JSON value the way Python's `str()` renders the +// object pydantic received: True/False/None, single-quoted strings, +// {'k': v} dicts in INSERTION order, [a, b] lists. +// +// It walks the raw JSON with a token decoder rather than decoding into +// map[string]any precisely so dict key order survives — Python's dict repr +// follows insertion order and a Go map would scramble it. +// +// These branches only fire on malformed model output, and the resulting string +// is prose fed back to an LLM, never parsed. Known divergence: a number is +// emitted as its JSON literal, so an exponent-form literal like `1e3` renders +// as "1e3" where Python's `str(json.loads("1e3"))` gives "1000.0". +func pyStr(raw json.RawMessage) string { + var buf bytes.Buffer + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + if err := writePyRepr(&buf, dec); err != nil { + return string(raw) + } + return buf.String() +} + +// writePyRepr consumes exactly one JSON value from dec and writes its Python +// repr to buf. +func writePyRepr(buf *bytes.Buffer, dec *json.Decoder) error { + tok, err := dec.Token() + if err != nil { + return err + } + switch t := tok.(type) { + case json.Delim: + switch t { + case '{': + buf.WriteByte('{') + first := true + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return err + } + if !first { + buf.WriteString(", ") + } + first = false + key, _ := keyTok.(string) + buf.WriteString("'" + key + "': ") + if err := writePyRepr(buf, dec); err != nil { + return err + } + } + if _, err := dec.Token(); err != nil { // closing '}' + return err + } + buf.WriteByte('}') + return nil + case '[': + buf.WriteByte('[') + first := true + for dec.More() { + if !first { + buf.WriteString(", ") + } + first = false + if err := writePyRepr(buf, dec); err != nil { + return err + } + } + if _, err := dec.Token(); err != nil { // closing ']' + return err + } + buf.WriteByte(']') + return nil + } + return fmt.Errorf("unexpected delimiter %v", t) + case nil: + buf.WriteString("None") + case bool: + if t { + buf.WriteString("True") + } else { + buf.WriteString("False") + } + case json.Number: + buf.WriteString(t.String()) + case string: + buf.WriteString("'" + t + "'") + default: + buf.WriteString(fmt.Sprintf("%v", t)) + } + return nil +} diff --git a/go/internal/schemas/recon.go b/go/internal/schemas/recon.go new file mode 100644 index 0000000..f9e1375 --- /dev/null +++ b/go/internal/schemas/recon.go @@ -0,0 +1,353 @@ +package schemas + +// This file ports src/sec_af/schemas/recon.py — the RECON phase schemas +// (DESIGN.md §4.3), both the structured models that flow downstream and the +// FLAT `*Raw` models the harness actually produces. +// +// Python parity: recon.py declares a `DataFlowStep` that is a DIFFERENT model +// from prove.py's `DataFlowStep`. `schemas/__init__.py` re-exports prove's +// under the bare name and recon's as `ReconDataFlowStep`, so Go does the same. + +// Module is a module-level architecture element (DESIGN.md §4.3). +// +// Ports schemas/recon.py Module. +type Module struct { + Name string `json:"name"` + Path string `json:"path"` + Language string `json:"language"` + Description *string `json:"description"` + Dependencies []string `json:"dependencies"` +} + +// EntryPoint is an executable entry point (HTTP, CLI, event) (DESIGN.md §4.3). +// +// Ports schemas/recon.py EntryPoint. +type EntryPoint struct { + Kind string `json:"kind"` + Identifier string `json:"identifier"` + FilePath string `json:"file_path"` + Line int `json:"line"` + Method *string `json:"method"` + Route *string `json:"route"` + AuthRequired *bool `json:"auth_required"` +} + +// TrustBoundary is a trust transition location (DESIGN.md §4.3). +// +// Ports schemas/recon.py TrustBoundary. +type TrustBoundary struct { + Name string `json:"name"` + SourceZone string `json:"source_zone"` + TargetZone string `json:"target_zone"` + Description string `json:"description"` + Enforcement []string `json:"enforcement"` +} + +// Service is a service dependency or external integration (DESIGN.md §4.3). +// +// Ports schemas/recon.py Service. +type Service struct { + Name string `json:"name"` + ServiceType string `json:"service_type"` + Endpoint *string `json:"endpoint"` + Purpose *string `json:"purpose"` + AuthMechanism *string `json:"auth_mechanism"` +} + +// APIEndpoint is one exposed API surface entry (DESIGN.md §4.3). +// +// Ports schemas/recon.py APIEndpoint. +type APIEndpoint struct { + Method string `json:"method"` + Path string `json:"path"` + Handler string `json:"handler"` + FilePath string `json:"file_path"` + Line int `json:"line"` + AuthRequired *bool `json:"auth_required"` + RateLimited *bool `json:"rate_limited"` +} + +// ArchitectureMap is the architecture mapper output (DESIGN.md §4.3). +// +// Ports schemas/recon.py ArchitectureMap. Seeded (defaults.go): all five list +// fields default to `[]`. +type ArchitectureMap struct { + AppType *string `json:"app_type"` + Modules []Module `json:"modules"` + EntryPoints []EntryPoint `json:"entry_points"` + TrustBoundaries []TrustBoundary `json:"trust_boundaries"` + Services []Service `json:"services"` + APISurface []APIEndpoint `json:"api_surface"` +} + +// ReconDataFlowStep is an intermediate transformation step in a data flow +// (DESIGN.md §4.3). +// +// Ports schemas/recon.py DataFlowStep, which `schemas/__init__.py` re-exports +// as `ReconDataFlowStep` to leave the bare name to prove.py's different model. +// This is the one used by DataFlow.path and RawFinding.data_flow. +type ReconDataFlowStep struct { + FilePath string `json:"file_path"` + Line int `json:"line"` + Component string `json:"component"` + Operation string `json:"operation"` +} + +// SanitizationPoint is a location where tainted data is sanitized +// (DESIGN.md §4.3). +// +// Ports schemas/recon.py SanitizationPoint. Seeded (defaults.go): +// protects_against defaults to `[]`. +type SanitizationPoint struct { + FilePath string `json:"file_path"` + Line int `json:"line"` + FunctionName *string `json:"function_name"` + SanitizationType string `json:"sanitization_type"` + ProtectsAgainst []string `json:"protects_against"` +} + +// Sink is a security-critical sink reached by application data +// (DESIGN.md §4.3). +// +// Ports schemas/recon.py Sink. +type Sink struct { + SinkType string `json:"sink_type"` + FilePath string `json:"file_path"` + Line int `json:"line"` + FunctionName *string `json:"function_name"` + ExploitabilityNotes *string `json:"exploitability_notes"` +} + +// DataFlow is one input-to-sink path (DESIGN.md §4.3). +// +// Ports schemas/recon.py DataFlow. Seeded (defaults.go): path and files +// default to `[]`. +type DataFlow struct { + Source string `json:"source"` + Path []ReconDataFlowStep `json:"path"` + Sink string `json:"sink"` + Sanitized bool `json:"sanitized"` + Files []string `json:"files"` +} + +// DataFlowMap is the aggregated data-flow analysis output (DESIGN.md §4.3). +// +// Ports schemas/recon.py DataFlowMap. Seeded (defaults.go): all three list +// fields default to `[]`. +type DataFlowMap struct { + Flows []DataFlow `json:"flows"` + SanitizationPoints []SanitizationPoint `json:"sanitization_points"` + Sinks []Sink `json:"sinks"` +} + +// Dependency is one software bill-of-materials entry (DESIGN.md §4.3). +// +// Ports schemas/recon.py Dependency. +type Dependency struct { + Name string `json:"name"` + Version string `json:"version"` + Ecosystem string `json:"ecosystem"` + Direct bool `json:"direct"` + License *string `json:"license"` +} + +// KnownCVE is a known CVE affecting a dependency (DESIGN.md §4.3). +// +// Ports schemas/recon.py KnownCVE. +type KnownCVE struct { + CveID string `json:"cve_id"` + Package string `json:"package"` + InstalledVersion string `json:"installed_version"` + FixedVersion *string `json:"fixed_version"` + CvssV4Score *float64 `json:"cvss_v4_score"` + EpssScore *float64 `json:"epss_score"` + Direct bool `json:"direct"` + Reachable *bool `json:"reachable"` +} + +// OutdatedDep is a dependency that lags behind the latest available version +// (DESIGN.md §4.3). +// +// Ports schemas/recon.py OutdatedDep. +type OutdatedDep struct { + Package string `json:"package"` + CurrentVersion string `json:"current_version"` + LatestVersion string `json:"latest_version"` + Direct bool `json:"direct"` +} + +// DependencyReport is the dependency auditor output (DESIGN.md §4.3). +// +// Ports schemas/recon.py DependencyReport. Seeded (defaults.go): sbom, +// known_cves and outdated default to `[]`. +type DependencyReport struct { + Sbom []Dependency `json:"sbom"` + KnownCves []KnownCVE `json:"known_cves"` + Outdated []OutdatedDep `json:"outdated"` + DirectCount int `json:"direct_count"` + TransitiveCount int `json:"transitive_count"` +} + +// SecretFinding is a discovered hardcoded secret or credential +// (DESIGN.md §4.3). +// +// Ports schemas/recon.py SecretFinding. Seeded (defaults.go): ID gets a fresh +// uuid4 from NewSecretFinding. +type SecretFinding struct { + ID string `json:"id"` + SecretType string `json:"secret_type"` + FilePath string `json:"file_path"` + Line int `json:"line"` + Match string `json:"match"` + Confidence string `json:"confidence"` + IsTestValue *bool `json:"is_test_value"` +} + +// MisconfigFinding is an insecure configuration finding (DESIGN.md §4.3). +// +// Ports schemas/recon.py MisconfigFinding. Seeded (defaults.go): ID gets a +// fresh uuid4 from NewMisconfigFinding. +type MisconfigFinding struct { + ID string `json:"id"` + Category string `json:"category"` + FilePath string `json:"file_path"` + Line *int `json:"line"` + Key *string `json:"key"` + Value *string `json:"value"` + Risk string `json:"risk"` + Remediation *string `json:"remediation"` +} + +// ConfigReport is the config scanner output (DESIGN.md §4.3). +// +// Ports schemas/recon.py ConfigReport. Seeded (defaults.go): secrets and +// misconfigs default to `[]`. +type ConfigReport struct { + Secrets []SecretFinding `json:"secrets"` + Misconfigs []MisconfigFinding `json:"misconfigs"` +} + +// CryptoUsage is one cryptography usage profile entry (DESIGN.md §4.3). +// +// Ports schemas/recon.py CryptoUsage. +type CryptoUsage struct { + Algorithm string `json:"algorithm"` + KeySize *int `json:"key_size"` + Mode *string `json:"mode"` + UsageContext *string `json:"usage_context"` + IsWeak *bool `json:"is_weak"` +} + +// SecurityContext is the security context profiler output (DESIGN.md §4.3). +// +// Ports schemas/recon.py SecurityContext. Seeded (defaults.go): the four list +// fields default to `[]`. +type SecurityContext struct { + AuthModel string `json:"auth_model"` + AuthDetails string `json:"auth_details"` + CryptoUsage []CryptoUsage `json:"crypto_usage"` + FrameworkSecurity []string `json:"framework_security"` + SecurityHeaders []string `json:"security_headers"` + DeploymentSignals []string `json:"deployment_signals"` +} + +// ReconResult is the comprehensive RECON context (DESIGN.md §4.3). +// +// Ports schemas/recon.py ReconResult. Seeded (defaults.go): languages and +// frameworks default to `[]`; the five nested models are REQUIRED in Python, +// so NewReconResult seeds them with their own defaults rather than leaving +// half-built zero values. +type ReconResult struct { + Architecture ArchitectureMap `json:"architecture"` + DataFlows DataFlowMap `json:"data_flows"` + Dependencies DependencyReport `json:"dependencies"` + Config ConfigReport `json:"config"` + SecurityContext SecurityContext `json:"security_context"` + Languages []string `json:"languages"` + Frameworks []string `json:"frameworks"` + LinesOfCode int `json:"lines_of_code"` + FileCount int `json:"file_count"` + ReconDurationSeconds float64 `json:"recon_duration_seconds"` +} + +// --------------------------------------------------------------------------- +// Flat harness schemas for RECON agents +// --------------------------------------------------------------------------- +// These are what the LLM actually produces via .harness() calls: only flat +// fields (string, []string), no nesting. agents/recon/_parsers.py converts +// them into the structured schemas above. +// --------------------------------------------------------------------------- + +// ArchitectureMapRaw is the flat harness output for the architecture mapper. +// +// Ports schemas/recon.py ArchitectureMapRaw. Seeded (defaults.go): +// app_type="unknown", all five list fields `[]`. +type ArchitectureMapRaw struct { + // AppType is one of: web_api, cli_tool, library, microservice, monolith. + AppType string `json:"app_type"` + // Modules: one string per module, "name | path | language | description". + Modules []string `json:"modules"` + // EntryPoints: "kind | route_or_id | file_path:line | auth_required". + EntryPoints []string `json:"entry_points"` + // TrustBoundaries: "name | source_zone | target_zone | description". + TrustBoundaries []string `json:"trust_boundaries"` + // Services: "name | type | endpoint | auth_mechanism". + Services []string `json:"services"` + // APIEndpoints: "method | path | handler | file_path:line | auth_required | rate_limited". + APIEndpoints []string `json:"api_endpoints"` +} + +// DataFlowMapRaw is the flat harness output for the data flow mapper. +// +// Ports schemas/recon.py DataFlowMapRaw. Seeded (defaults.go): all three list +// fields `[]`. +type DataFlowMapRaw struct { + // Flows: "source | sink | sanitized(true/false) | file1, file2, ...". + Flows []string `json:"flows"` + // SanitizationPoints: "file_path:line | function_name | type | protects_against". + SanitizationPoints []string `json:"sanitization_points"` + // Sinks: "sink_type | file_path:line | function_name | notes". + Sinks []string `json:"sinks"` +} + +// DependencyReportRaw is the flat harness output for the dependency auditor. +// +// Ports schemas/recon.py DependencyReportRaw. Seeded (defaults.go): all three +// list fields `[]`. +type DependencyReportRaw struct { + // Sbom: "name | version | ecosystem | direct(true/false) | license". + Sbom []string `json:"sbom"` + // KnownCves: "cve_id | package | installed_version | fixed_version | cvss_score | direct | reachable". + KnownCves []string `json:"known_cves"` + // Outdated: "package | current_version | latest_version | direct(true/false)". + Outdated []string `json:"outdated"` +} + +// ConfigReportRaw is the flat harness output for the config scanner. +// +// Ports schemas/recon.py ConfigReportRaw. Seeded (defaults.go): both list +// fields `[]`. +type ConfigReportRaw struct { + // Secrets: "type | file_path:line | match_preview | confidence | is_test(true/false)". + Secrets []string `json:"secrets"` + // Misconfigs: "category | file_path:line | key | risk | remediation". + Misconfigs []string `json:"misconfigs"` +} + +// SecurityContextRaw is the flat harness output for the security context +// profiler. +// +// Ports schemas/recon.py SecurityContextRaw. Seeded (defaults.go): +// auth_details="" (the Go zero value, so only the list fields actually need +// seeding), crypto_usage and security_signals `[]`. +type SecurityContextRaw struct { + // AuthModel is one of: jwt, session_cookie, oauth2, api_key, none, other. + AuthModel string `json:"auth_model"` + // AuthDetails is a brief description of the auth implementation. + AuthDetails string `json:"auth_details"` + // CryptoUsage: "algorithm | key_size | mode | usage_context | is_weak(true/false)". + CryptoUsage []string `json:"crypto_usage"` + // SecuritySignals: framework security features, security headers and + // deployment signals, one per entry. + SecuritySignals []string `json:"security_signals"` +} diff --git a/go/internal/schemas/schemas_test.go b/go/internal/schemas/schemas_test.go new file mode 100644 index 0000000..8727c5d --- /dev/null +++ b/go/internal/schemas/schemas_test.go @@ -0,0 +1,895 @@ +package schemas + +import ( + "encoding/json" + "reflect" + "testing" +) + +// This file ports tests/test_schemas.py plus the conftest.py fixtures it +// consumes, and adds the default-seeding / projection / coercion tests the +// Python suite gets for free from pydantic. +// +// Every expected value here was verified against the live models with +// `PYTHONPATH=src ~/.agentfield/packages/sec-af/venv/bin/python`. + +func strp(s string) *string { return &s } +func intp(i int) *int { return &i } + +func mustMarshal(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal %T: %v", v, err) + } + return b +} + +func mustUnmarshal[T any](t *testing.T, data string) T { + t.Helper() + var v T + if err := json.Unmarshal([]byte(data), &v); err != nil { + t.Fatalf("unmarshal %q into %T: %v", data, v, err) + } + return v +} + +// --------------------------------------------------------------------------- +// conftest.py fixtures +// --------------------------------------------------------------------------- + +// sampleVerifiedFindings ports conftest.py::sample_verified_findings. +// +// Python parity: the fixture passes `tags={"a","b"}` — a SET — into a +// `list[str]` field, which pydantic coerces in lax mode. Set iteration order is +// undefined in Python, so the Go port fixes a deterministic order +// (docs/DESIGN.md §0.2: make non-determinism explicit). No assertion depends +// on the order. +func sampleVerifiedFindings() []VerifiedFinding { + sql := NewVerifiedFinding() + sql.ID = "finding-confirmed" + sql.Fingerprint = "fp-sql-1" + sql.Title = "SQL Injection" + sql.Description = "Unsanitized user input reaches SQL query execution." + sql.FindingType = FindingTypeSast + sql.CweID = "CWE-89" + sql.CweName = "SQL Injection" + sql.OwaspCategory = strp("A03:2021") + sql.Tags = []string{"externally_reachable", "user-input"} + sql.Verdict = VerdictConfirmed + sql.EvidenceLevel = EvidenceLevelFullExploit + sql.Rationale = "Source-to-sink path is confirmed and exploitable." + sql.Severity = SeverityCritical + sql.ExploitabilityScore = 10.0 + sql.Proof = &Proof{ + ExploitHypothesis: "Inject through id parameter.", + VerificationMethod: "manual-review+trace", + EvidenceLevel: EvidenceLevelFullExploit, + DataFlowTrace: []DataFlowStep{ + {File: "src/routes.py", Line: 15, Description: "Input source", Tainted: true}, + {File: "src/users.py", Line: 42, Description: "SQL sink", Tainted: true}, + }, + VulnerableCode: strp(`cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")`), + ExploitPayload: strp(`{"id": "1 OR 1=1"}`), + ExpectedOutcome: strp("Unauthorized data access"), + } + sql.Location = Location{ + FilePath: "src/users.py", + StartLine: 42, + EndLine: 42, + StartColumn: intp(9), + EndColumn: intp(66), + FunctionName: strp("lookup_user"), + CodeSnippet: strp(`cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")`), + } + sql.RelatedLocations = []Location{{ + FilePath: "src/routes.py", + StartLine: 15, + EndLine: 15, + CodeSnippet: strp("user_id = request.json['id']"), + }} + sql.ChainID = strp("chain-1") + sql.ChainStep = intp(1) + sql.Enables = []string{"finding-likely"} + sql.Compliance = []ComplianceMapping{{ + Framework: "PCI-DSS", ControlID: "Req 6.2.4", ControlName: "Prevent injection", + }} + sql.SarifRuleID = "sec-af/sast/sql-injection" + sql.SarifSecuritySeverity = 9.9 + + likely := NewVerifiedFinding() + likely.ID = "finding-likely" + likely.Fingerprint = "fp-auth-1" + likely.Title = "Missing Authentication" + likely.Description = "Admin endpoint can be accessed without auth." + likely.FindingType = FindingTypeAPI + likely.CweID = "CWE-306" + likely.CweName = "Missing Authentication for Critical Function" + likely.OwaspCategory = strp("A07:2021") + likely.Tags = []string{"requires_auth"} + likely.Verdict = VerdictLikely + likely.EvidenceLevel = EvidenceLevelFlowIdentified + likely.Rationale = "Guard checks appear absent on route." + likely.Severity = SeverityHigh + likely.ExploitabilityScore = 4.8 + likely.Location = Location{FilePath: "src/api/admin.py", StartLine: 11, EndLine: 11} + likely.SarifRuleID = "sec-af/api/missing-authentication" + likely.SarifSecuritySeverity = 7.6 + + notExploitable := NewVerifiedFinding() + notExploitable.ID = "finding-noise" + notExploitable.Fingerprint = "fp-noise-1" + notExploitable.Title = "Potential XSS" + notExploitable.Description = "Output is escaped by template engine." + notExploitable.FindingType = FindingTypeSast + notExploitable.CweID = "CWE-79" + notExploitable.CweName = "Cross-site Scripting" + notExploitable.Verdict = VerdictNotExploitable + notExploitable.EvidenceLevel = EvidenceLevelStaticMatch + notExploitable.Rationale = "Sink auto-escapes output." + notExploitable.Severity = SeverityLow + notExploitable.ExploitabilityScore = 0.6 + notExploitable.Location = Location{FilePath: "src/views.py", StartLine: 88, EndLine: 89} + notExploitable.SarifRuleID = "sec-af/sast/xss" + notExploitable.SarifSecuritySeverity = 1.9 + + return []VerifiedFinding{sql, likely, notExploitable} +} + +// sampleSecurityAuditResult ports conftest.py::sample_security_audit_result. +func sampleSecurityAuditResult() SecurityAuditResult { + r := NewSecurityAuditResult() + r.Repository = "Agent-Field/sec-af" + r.CommitSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + r.Branch = strp("issue-23-tests") + r.Timestamp = mustUnmarshalNoT[Timestamp](t0JSON) + r.DepthProfile = "standard" + r.StrategiesUsed = []string{"injection", "auth"} + r.Provider = "opencode" + r.Findings = sampleVerifiedFindings() + + chain := NewAttackChain() + chain.ChainID = "chain-1" + chain.Title = "Input to DB read" + chain.Description = "Untrusted input reaches SQL sink" + chain.Findings = []string{"finding-confirmed", "finding-likely"} + chain.CombinedSeverity = SeverityCritical + chain.CombinedImpact = "Unauthorized DB disclosure" + chain.MitreAttackMapping = []MitreMapping{{ + Tactic: "Initial Access", TechniqueID: "T1190", + TechniqueName: "Exploit Public-Facing Application", + }} + r.AttackChains = []AttackChain{chain} + + r.TotalRawFindings = 6 + r.Confirmed = 1 + r.Likely = 1 + r.Inconclusive = 0 + r.NotExploitable = 1 + r.NoiseReductionPct = 66.7 + r.BySeverity = map[string]int{"critical": 1, "high": 1, "low": 1} + r.ComplianceGaps = []ComplianceGap{{ + Framework: "PCI-DSS", ControlID: "Req 6.2.4", ControlName: "Prevent injection", + FindingCount: 1, MaxSeverity: "critical", CweIDs: []string{"CWE-89"}, + }} + r.DurationSeconds = 182.4 + r.AgentInvocations = 24 + r.CostUsd = 3.21 + r.CostBreakdown = map[string]float64{"recon": 0.5, "hunt": 1.2, "prove": 1.51} + r.Sarif = "{}" + return r +} + +// t0JSON is conftest.py's datetime(2026, 3, 4, 10, 30, 0, tzinfo=UTC), in the +// exact wire form Python emits (see timestamp.go). +const t0JSON = `"2026-03-04T10:30:00+00:00"` + +// mustUnmarshalNoT is mustUnmarshal without a *testing.T, for fixture builders. +func mustUnmarshalNoT[T any](data string) T { + var v T + if err := json.Unmarshal([]byte(data), &v); err != nil { + panic(err) + } + return v +} + +// --------------------------------------------------------------------------- +// test_schemas.py::test_schema_validation_and_required_fields +// --------------------------------------------------------------------------- + +// Python asserts pydantic raises ValidationError for three payloads that omit +// required fields. Go's json.Unmarshal has no notion of "required", so the +// port splits the assertion: +// +// - AuditInput, the ONE model the port validates at runtime (it is the +// reasoner's public input contract), gets a real Validate() check. +// - For RawFinding and VerifiedFinding the contract is asserted against the +// generated pydantic ground truth: the fields Python's test omits really +// are required, so any Go code building one must set them. +func TestSchemaValidationAndRequiredFields(t *testing.T) { + // _validate(AuditInput, {"branch": "main"}) raises: repo_url is required. + in := mustUnmarshal[AuditInput](t, `{"branch":"main"}`) + if err := in.Validate(); err == nil { + t.Error("AuditInput{branch: main}.Validate() = nil, want a missing-repo_url error") + } + // ...and the defaults still seeded around the missing field. + if in.Branch != "main" || in.Depth != "standard" { + t.Errorf("AuditInput = %+v, want branch=main depth=standard", in) + } + // A payload WITH repo_url validates. + ok := mustUnmarshal[AuditInput](t, `{"repo_url":"https://github.com/Agent-Field/sec-af"}`) + if err := ok.Validate(); err != nil { + t.Errorf("AuditInput{repo_url: ...}.Validate() = %v, want nil", err) + } + + gt := loadGroundTruth(t) + requiredOf := func(goName string) map[string]bool { + for _, spec := range gt.Models { + if spec.GoName == goName && spec.DuplicateOf == nil { + set := map[string]bool{} + for _, f := range spec.Required { + set[f] = true + } + return set + } + } + t.Fatalf("no ground truth for %s", goName) + return nil + } + // RawFinding payload in the Python test omits everything but + // hunter_strategy/title/description — those omissions must be required. + rawRequired := requiredOf("RawFinding") + for _, field := range []string{"finding_type", "cwe_id", "cwe_name", "file_path", + "start_line", "end_line", "code_snippet", "estimated_severity", "confidence"} { + if !rawRequired[field] { + t.Errorf("RawFinding.%s should be required in pydantic", field) + } + } + // VerifiedFinding payload in the Python test omits fingerprint and location. + verifiedRequired := requiredOf("VerifiedFinding") + for _, field := range []string{"fingerprint", "location", "sarif_rule_id", "sarif_security_severity"} { + if !verifiedRequired[field] { + t.Errorf("VerifiedFinding.%s should be required in pydantic", field) + } + } + + // assert sample_verified_findings[0].location.file_path == "src/users.py" + if got := sampleVerifiedFindings()[0].Location.FilePath; got != "src/users.py" { + t.Errorf("sample_verified_findings[0].location.file_path = %q, want src/users.py", got) + } +} + +// --------------------------------------------------------------------------- +// test_schemas.py::test_schema_roundtrip_serialization +// --------------------------------------------------------------------------- + +func TestSchemaRoundtripSerialization(t *testing.T) { + sample := sampleSecurityAuditResult() + payload := mustMarshal(t, sample) + restored := mustUnmarshal[SecurityAuditResult](t, string(payload)) + + if restored.Repository != sample.Repository { + t.Errorf("repository = %q, want %q", restored.Repository, sample.Repository) + } + if restored.Findings[0].Fingerprint != "fp-sql-1" { + t.Errorf("findings[0].fingerprint = %q, want fp-sql-1", restored.Findings[0].Fingerprint) + } + if restored.Findings[1].Verdict != VerdictLikely { + t.Errorf("findings[1].verdict = %q, want likely", restored.Findings[1].Verdict) + } + if restored.Findings[2].Verdict != VerdictNotExploitable { + t.Errorf("findings[2].verdict = %q, want not_exploitable", restored.Findings[2].Verdict) + } + // The round trip must be byte-stable: model_dump -> model_validate -> + // model_dump is the identity in Python, and must be here too. + if again := mustMarshal(t, restored); string(again) != string(payload) { + t.Errorf("round trip changed the JSON\n got: %s\nwant: %s", again, payload) + } +} + +// --------------------------------------------------------------------------- +// test_schemas.py::test_enum_values_are_stable +// --------------------------------------------------------------------------- + +func TestEnumValuesAreStable(t *testing.T) { + if FindingTypeSast != "sast" { + t.Errorf("FindingType.SAST = %q, want sast", FindingTypeSast) + } + if FindingTypeAPI != "api" { + t.Errorf("FindingType.API = %q, want api", FindingTypeAPI) + } + if SeverityCritical != "critical" { + t.Errorf("Severity.CRITICAL = %q, want critical", SeverityCritical) + } + if ConfidenceHigh != "high" { + t.Errorf("Confidence.HIGH = %q, want high", ConfidenceHigh) + } + if VerdictConfirmed != "confirmed" { + t.Errorf("Verdict.CONFIRMED = %q, want confirmed", VerdictConfirmed) + } + if EvidenceLevelFullExploit != 6 { + t.Errorf("EvidenceLevel.FULL_EXPLOIT = %d, want 6", EvidenceLevelFullExploit) + } + if HuntStrategyDos != "dos" { + t.Errorf("HuntStrategy.DOS = %q, want dos", HuntStrategyDos) + } + if HuntStrategyConfigSecrets != "config_secrets" { + t.Errorf("HuntStrategy.CONFIG_SECRETS = %q, want config_secrets", HuntStrategyConfigSecrets) + } + // Python parity: LOGIC_BUGS is an ALIAS of BUSINESS_LOGIC, not a member. + if HuntStrategyLogicBugs != HuntStrategyBusinessLogic { + t.Errorf("HuntStrategy.LOGIC_BUGS = %q, want it to alias BUSINESS_LOGIC (%q)", + HuntStrategyLogicBugs, HuntStrategyBusinessLogic) + } + if len(AllHuntStrategies) != 13 { + t.Errorf("len(list(HuntStrategy)) = %d, want 13 (14 constants, one alias)", len(AllHuntStrategies)) + } +} + +// TestEnumValidAndParse covers the Valid()/ParseX helpers, which stand in for +// Python's `Severity("high")` constructor and its ValueError. +func TestEnumValidAndParse(t *testing.T) { + if !SeverityHigh.Valid() || Severity("nope").Valid() { + t.Error("Severity.Valid() is wrong") + } + if v, err := ParseSeverity("critical"); err != nil || v != SeverityCritical { + t.Errorf("ParseSeverity(critical) = %q, %v", v, err) + } + if _, err := ParseSeverity("blocker"); err == nil { + t.Error("ParseSeverity(blocker) = nil error, want ValueError equivalent") + } else if err.Error() != "'blocker' is not a valid Severity" { + t.Errorf("ParseSeverity(blocker) error = %q", err.Error()) + } + if _, err := ParseFindingType("nope"); err == nil { + t.Error("ParseFindingType(nope) should fail") + } + if _, err := ParseConfidence("nope"); err == nil { + t.Error("ParseConfidence(nope) should fail") + } + if _, err := ParseVerdict("nope"); err == nil { + t.Error("ParseVerdict(nope) should fail") + } + // The alias parses, because it is the same VALUE. + if v, err := ParseHuntStrategy("business_logic"); err != nil || v != HuntStrategyLogicBugs { + t.Errorf("ParseHuntStrategy(business_logic) = %q, %v", v, err) + } + if _, err := ParseHuntStrategy("logic_bugs"); err == nil { + t.Error("ParseHuntStrategy(logic_bugs) should fail — it is a member NAME, not a value") + } + for _, level := range AllEvidenceLevels { + if v, err := ParseEvidenceLevel(int(level)); err != nil || v != level { + t.Errorf("ParseEvidenceLevel(%d) = %d, %v", level, v, err) + } + } + if _, err := ParseEvidenceLevel(7); err == nil { + t.Error("ParseEvidenceLevel(7) should fail") + } + // Python parity: str(IntEnum) is the number in 3.11; Name() gives the + // symbolic form. + if got := EvidenceLevelFullExploit.String(); got != "6" { + t.Errorf("EvidenceLevel.String() = %q, want 6", got) + } + if got := EvidenceLevelFullExploit.Name(); got != "FULL_EXPLOIT" { + t.Errorf("EvidenceLevel.Name() = %q, want FULL_EXPLOIT", got) + } + if got := EvidenceLevel(9).Name(); got != "" { + t.Errorf("EvidenceLevel(9).Name() = %q, want empty", got) + } +} + +// --------------------------------------------------------------------------- +// test_schemas.py::test_json_schema_generation_contains_expected_fields +// +// Python inspects model_json_schema(); the Go port has no jsonschema +// generator in this package (harnessx embeds the pydantic-generated fixtures), +// so the same four assertions run against the generated ground truth, which is +// the same source: the live pydantic models. +// --------------------------------------------------------------------------- + +func TestJSONSchemaGenerationContainsExpectedFields(t *testing.T) { + gt := loadGroundTruth(t) + find := func(goName string) modelSpec { + for _, spec := range gt.Models { + if spec.GoName == goName && spec.DuplicateOf == nil { + return spec + } + } + t.Fatalf("no ground truth for %s", goName) + return modelSpec{} + } + has := func(list []string, want string) bool { + for _, v := range list { + if v == want { + return true + } + } + return false + } + + auditInput := find("AuditInput") + finding := find("VerifiedFinding") + result := find("SecurityAuditResult") + + if !has(auditInput.Keys, "repo_url") { + t.Error("repo_url missing from AuditInput properties") + } + if !has(auditInput.Required, "repo_url") { + t.Error("repo_url missing from AuditInput required") + } + if !has(finding.Keys, "fingerprint") { + t.Error("fingerprint missing from VerifiedFinding properties") + } + if !has(finding.Keys, "location") { + t.Error("location missing from VerifiedFinding properties") + } + if !has(result.Keys, "findings") { + t.Error("findings missing from SecurityAuditResult properties") + } + if !has(result.Required, "repository") { + t.Error("repository missing from SecurityAuditResult required") + } +} + +// --------------------------------------------------------------------------- +// test_schemas.py::test_recon_hunt_output_and_gate_models_instantiate +// --------------------------------------------------------------------------- + +func TestReconHuntOutputAndGateModelsInstantiate(t *testing.T) { + recon := NewReconResult() + recon.Architecture = NewArchitectureMap() + recon.Architecture.AppType = strp("web") + recon.DataFlows = NewDataFlowMap() + recon.Dependencies = NewDependencyReport() + recon.Dependencies.DirectCount = 1 + recon.Dependencies.TransitiveCount = 2 + recon.Config = NewConfigReport() + recon.SecurityContext = NewSecurityContext() + recon.SecurityContext.AuthModel = "jwt" + recon.SecurityContext.AuthDetails = "bearer token" + recon.Languages = []string{"python"} + recon.Frameworks = []string{"fastapi"} + recon.LinesOfCode = 1200 + recon.FileCount = 34 + recon.ReconDurationSeconds = 12.5 + + hunt := NewHuntResult() + hunt.TotalRaw = 2 + hunt.DeduplicatedCount = 2 + hunt.ChainCount = 1 + hunt.StrategiesRun = []string{"injection"} + + attackChain := NewAttackChain() + attackChain.ChainID = "chain-123" + attackChain.Title = "Privilege escalation path" + attackChain.Description = "Two-step attack" + attackChain.Findings = []string{"f1", "f2"} + attackChain.CombinedSeverity = SeverityHigh + attackChain.CombinedImpact = "Privilege escalation" + attackChain.MitreAttackMapping = []MitreMapping{{ + Tactic: "Privilege Escalation", + TechniqueID: "T1068", + TechniqueName: "Exploitation for Privilege Escalation", + }} + + progress := AuditProgress{ + Phase: "prove", PhaseProgress: 0.75, AgentsTotal: 6, AgentsCompleted: 4, + AgentsRunning: 2, FindingsSoFar: 3, ElapsedSeconds: 45.0, + EstimatedRemainingSeconds: 15.0, CostSoFarUsd: 1.23, + } + metrics := NewAuditMetrics() + metrics.DurationSeconds = 180.0 + metrics.AgentInvocations = 22 + metrics.CostUsd = 2.9 + + compliance := ComplianceGap{ + Framework: "PCI-DSS", ControlID: "Req 6.2.4", ControlName: "Prevent injection", + FindingCount: 2, MaxSeverity: "critical", CweIDs: []string{"CWE-79", "CWE-89"}, + } + gate := SeverityClassification{Severity: "high", Confidence: 0.9, Rationale: "validated"} + complianceGate := ComplianceGate{ + Mappings: []ComplianceSuggestion{{ + Framework: "OWASP", ControlID: "A03:2021", ControlName: "Injection", + }}, + Confidence: "high", + } + + if recon.LinesOfCode != 1200 { + t.Errorf("recon.lines_of_code = %d, want 1200", recon.LinesOfCode) + } + if hunt.TotalRaw != 2 { + t.Errorf("hunt.total_raw = %d, want 2", hunt.TotalRaw) + } + if attackChain.MitreAttackMapping == nil { + t.Error("attack_chain.mitre_attack_mapping is nil") + } + if progress.Phase != "prove" { + t.Errorf("progress.phase = %q, want prove", progress.Phase) + } + if metrics.BudgetExhausted { + t.Error("metrics.budget_exhausted = true, want false") + } + if compliance.Framework != "PCI-DSS" { + t.Errorf("compliance.framework = %q, want PCI-DSS", compliance.Framework) + } + if gate.Severity != "high" { + t.Errorf("gate.severity = %q, want high", gate.Severity) + } + if complianceGate.Mappings[0].Framework != "OWASP" { + t.Errorf("compliance_gate.mappings[0].framework = %q, want OWASP", complianceGate.Mappings[0].Framework) + } +} + +// --------------------------------------------------------------------------- +// test_schemas.py::test_model_validate_accepts_nested_dictionaries +// --------------------------------------------------------------------------- + +func TestModelValidateAcceptsNestedDictionaries(t *testing.T) { + payload := `{ + "repository": "Agent-Field/sec-af", + "commit_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "branch": "main", + "timestamp": "2026-03-04T12:00:00+00:00", + "depth_profile": "quick", + "provider": "opencode", + "sarif": "{}", + "findings": [ + { + "fingerprint": "fp-1", + "title": "Weak hash", + "description": "Uses md5", + "finding_type": "sast", + "cwe_id": "CWE-327", + "cwe_name": "Broken crypto", + "verdict": "likely", + "evidence_level": 2, + "rationale": "hash algorithm is weak", + "severity": "medium", + "exploitability_score": 1.5, + "location": {"file_path": "src/auth.py", "start_line": 7, "end_line": 7}, + "sarif_rule_id": "sec-af/sast/weak-hash", + "sarif_security_severity": 4.1, + "compliance": [ + {"framework": "OWASP", "control_id": "A02:2021", "control_name": "Cryptographic Failures"} + ] + } + ] + }` + + model := mustUnmarshal[SecurityAuditResult](t, payload) + if model.Findings[0].Location.FilePath != "src/auth.py" { + t.Errorf("findings[0].location.file_path = %q, want src/auth.py", model.Findings[0].Location.FilePath) + } + if model.Findings[0].Location.StartLine != 7 || model.Findings[0].Location.EndLine != 7 { + t.Errorf("findings[0].location lines = %d..%d, want 7..7", + model.Findings[0].Location.StartLine, model.Findings[0].Location.EndLine) + } + if len(model.Findings[0].Compliance) != 1 || model.Findings[0].Compliance[0].Framework != "OWASP" { + t.Errorf("findings[0].compliance = %+v, want one OWASP mapping", model.Findings[0].Compliance) + } + if model.Findings[0].EvidenceLevel != EvidenceLevelFlowIdentified { + t.Errorf("findings[0].evidence_level = %d, want 2", model.Findings[0].EvidenceLevel) + } + // The unspecified nested defaults survive the decode. + if model.Findings[0].Tags == nil || len(model.Findings[0].Tags) != 0 { + t.Errorf("findings[0].tags = %#v, want []", model.Findings[0].Tags) + } + if model.AttackChains == nil || len(model.AttackChains) != 0 { + t.Errorf("attack_chains = %#v, want []", model.AttackChains) + } + if model.Metadata == nil || len(model.Metadata) != 0 { + t.Errorf("metadata = %#v, want {}", model.Metadata) + } +} + +// --------------------------------------------------------------------------- +// Default seeding: "{}" seeds pydantic's defaults, a present key overrides — +// including false / 0 / "" / null. Derived from the package contract. +// --------------------------------------------------------------------------- + +func TestAuditInputDefaultSeeding(t *testing.T) { + in := mustUnmarshal[AuditInput](t, "{}") + if in.Branch != "main" { + t.Errorf("Branch = %q, want main", in.Branch) + } + if in.Depth != "standard" { + t.Errorf("Depth = %q, want standard", in.Depth) + } + if in.SeverityThreshold != "low" { + t.Errorf("SeverityThreshold = %q, want low", in.SeverityThreshold) + } + if !reflect.DeepEqual(in.ScanTypes, []string{"sast", "sca", "secrets", "config"}) { + t.Errorf("ScanTypes = %v", in.ScanTypes) + } + if !reflect.DeepEqual(in.OutputFormats, []string{"json"}) { + t.Errorf("OutputFormats = %v", in.OutputFormats) + } + if !reflect.DeepEqual(in.ExcludePaths, []string{"tests/", "vendor/", "node_modules/", ".git/"}) { + t.Errorf("ExcludePaths = %v", in.ExcludePaths) + } + if in.IncludePaths != nil { + t.Errorf("IncludePaths = %v, want nil (Optional default None)", in.IncludePaths) + } + if in.MaxCostUsd != nil || in.MaxProvers != nil || in.MaxDurationSeconds != nil { + t.Error("budget caps should default to nil") + } + + // Present values — including explicitly empty ones — override. + override := mustUnmarshal[AuditInput](t, + `{"repo_url":"r","branch":"","depth":"quick","scan_types":[],"is_pr":true,"exclude_paths":null}`) + if override.Branch != "" { + t.Errorf("explicit empty branch = %q, want \"\"", override.Branch) + } + if override.Depth != "quick" { + t.Errorf("Depth = %q, want quick", override.Depth) + } + if override.ScanTypes == nil || len(override.ScanTypes) != 0 { + t.Errorf("explicit [] scan_types = %#v, want empty non-nil", override.ScanTypes) + } + if !override.IsPr { + t.Error("is_pr = false, want true") + } + if override.ExcludePaths != nil { + t.Errorf("explicit null exclude_paths = %#v, want nil", override.ExcludePaths) + } +} + +func TestNestedDefaultSeeding(t *testing.T) { + // A ReconResult decoded from "{}" has every nested model at ITS defaults, + // which is what reasoners/phases.py::_recon_model normalization relies on. + recon := mustUnmarshal[ReconResult](t, "{}") + b := mustMarshal(t, recon) + var got map[string]any + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + arch, _ := got["architecture"].(map[string]any) + if arch == nil { + t.Fatalf("architecture = %#v", got["architecture"]) + } + for _, key := range []string{"modules", "entry_points", "trust_boundaries", "services", "api_surface"} { + list, ok := arch[key].([]any) + if !ok || len(list) != 0 { + t.Errorf("architecture.%s = %#v, want []", key, arch[key]) + } + } + // A PRESENT nested key re-seeds through the nested UnmarshalJSON. + recon2 := mustUnmarshal[ReconResult](t, `{"architecture":{"app_type":"web_api"}}`) + if recon2.Architecture.AppType == nil || *recon2.Architecture.AppType != "web_api" { + t.Errorf("architecture.app_type = %v", recon2.Architecture.AppType) + } + if recon2.Architecture.Modules == nil || len(recon2.Architecture.Modules) != 0 { + t.Errorf("architecture.modules = %#v, want []", recon2.Architecture.Modules) + } +} + +func TestArchitectureMapRawDefaultSeeding(t *testing.T) { + raw := mustUnmarshal[ArchitectureMapRaw](t, "{}") + if raw.AppType != "unknown" { + t.Errorf("app_type = %q, want unknown", raw.AppType) + } + // An explicit empty string overrides the default (pydantic parity). + raw2 := mustUnmarshal[ArchitectureMapRaw](t, `{"app_type":""}`) + if raw2.AppType != "" { + t.Errorf("explicit empty app_type = %q, want \"\"", raw2.AppType) + } +} + +func TestUUIDDefaultsAreNotSeededOnDecode(t *testing.T) { + // Python parity note (doc.go): a decode must NOT mint a new identity. + rf := mustUnmarshal[RawFinding](t, "{}") + if rf.ID != "" || rf.Fingerprint != "" { + t.Errorf("decoded RawFinding minted id=%q fingerprint=%q, want empty", rf.ID, rf.Fingerprint) + } + if rf.RelatedFiles == nil || len(rf.RelatedFiles) != 0 { + t.Errorf("related_files = %#v, want []", rf.RelatedFiles) + } + // The constructor DOES mint them. + built := NewRawFinding() + if built.ID == "" || built.Fingerprint == "" || built.ID == built.Fingerprint { + t.Errorf("NewRawFinding gave id=%q fingerprint=%q", built.ID, built.Fingerprint) + } + if vf := mustUnmarshal[VerifiedFinding](t, "{}"); vf.ID != "" { + t.Errorf("decoded VerifiedFinding minted id=%q", vf.ID) + } + if pc := mustUnmarshal[PotentialChain](t, "{}"); pc.ChainID != "" { + t.Errorf("decoded PotentialChain minted chain_id=%q", pc.ChainID) + } +} + +// --------------------------------------------------------------------------- +// views.py projections (RawFinding.for_verifier / for_dedup) +// --------------------------------------------------------------------------- + +func TestRawFindingForVerifier(t *testing.T) { + base := NewRawFinding() + base.ID = "raw-1" + base.HunterStrategy = "injection" + base.Title = "SQLi" + base.Description = "desc" + base.FindingType = FindingTypeSast + base.CweID = "CWE-89" + base.CweName = "SQL Injection" + base.OwaspCategory = strp("A03:2021") + base.FilePath = "src/users.py" + base.StartLine = 42 + base.EndLine = 44 + base.FunctionName = strp("lookup_user") + base.CodeSnippet = "cursor.execute(q)" + base.EstimatedSeverity = SeverityCritical + base.Confidence = ConfidenceHigh + base.RelatedFiles = []string{"src/routes.py"} + base.Fingerprint = "fp-1" + + withFlow := base + withFlow.DataFlow = []ReconDataFlowStep{ + {FilePath: "src/routes.py", Line: 15, Component: "route", Operation: "read body"}, + {FilePath: "src/users.py", Line: 42, Component: "db", Operation: "execute"}, + } + + got := mustMarshal(t, withFlow.ForVerifier()) + want := `{"id":"raw-1","title":"SQLi","description":"desc","file_path":"src/users.py",` + + `"start_line":42,"end_line":44,"code_snippet":"cursor.execute(q)","cwe_id":"CWE-89",` + + `"function_name":"lookup_user","data_flow_summary":"src/routes.py:15 read body\nsrc/users.py:42 execute"}` + if string(got) != want { + t.Errorf("for_verifier()\n got: %s\nwant: %s", got, want) + } + + // Python parity: `if self.data_flow:` — an EMPTY list yields "" exactly + // like None does. + empty := base + empty.DataFlow = []ReconDataFlowStep{} + if s := empty.ForVerifier().DataFlowSummary; s != "" { + t.Errorf("empty data_flow summary = %q, want \"\"", s) + } + if s := base.ForVerifier().DataFlowSummary; s != "" { + t.Errorf("nil data_flow summary = %q, want \"\"", s) + } + + gotDedup := mustMarshal(t, withFlow.ForDedup()) + wantDedup := `{"id":"raw-1","fingerprint":"fp-1","title":"SQLi","file_path":"src/users.py",` + + `"start_line":42,"cwe_id":"CWE-89","finding_type":"sast","estimated_severity":"critical"}` + if string(gotDedup) != wantDedup { + t.Errorf("for_dedup()\n got: %s\nwant: %s", gotDedup, wantDedup) + } +} + +// --------------------------------------------------------------------------- +// prove.py @field_validator(mode="before") coercions. +// Every expectation below was produced by the Python models directly. +// --------------------------------------------------------------------------- + +func TestDataFlowTraceCoercion(t *testing.T) { + cases := []struct{ in, want string }{ + {`{"source":{"value":"req.id"},"sink":"sql","steps":["a","b"],"sink_reached":true}`, + `{"source":"req.id","sink":"sql","steps":["a","b"],"sink_reached":true}`}, + {`{"source":{"zzz":1},"sink":["x",2],"steps":"oneval","sink_reached":false}`, + `{"source":"{'zzz': 1}","sink":"['x', 2]","steps":["oneval"],"sink_reached":false}`}, + {`{"source":{},"sink":0,"steps":null,"sink_reached":false}`, + `{"source":"unknown","sink":"unknown","steps":[],"sink_reached":false}`}, + {`{"source":42,"sink":"","steps":[1,true,null],"sink_reached":true}`, + `{"source":"42","sink":"","steps":["1","True","None"],"sink_reached":true}`}, + {`{"source":{"name":5,"description":"d"},"sink":"s","steps":[],"sink_reached":false}`, + `{"source":"d","sink":"s","steps":[],"sink_reached":false}`}, + // null / false are falsy non-strings -> "unknown"; nested containers + // stringify with Python's repr. + {`{"source":null,"sink":false,"steps":[[1,"a"],{"k":2}],"sink_reached":false}`, + `{"source":"unknown","sink":"unknown","steps":["[1, 'a']","{'k': 2}"],"sink_reached":false}`}, + // The dict probe skips a null "value" and lands on "path". + {`{"source":{"path":"p","value":null},"sink":1.5,"steps":{},"sink_reached":false}`, + `{"source":"p","sink":"1.5","steps":[],"sink_reached":false}`}, + } + for _, tc := range cases { + got := mustMarshal(t, mustUnmarshal[DataFlowTrace](t, tc.in)) + if string(got) != tc.want { + t.Errorf("DataFlowTrace(%s)\n got: %s\nwant: %s", tc.in, got, tc.want) + } + } +} + +func TestReachabilityProofCoercion(t *testing.T) { + cases := []struct{ in, want string }{ + {`{"vulnerable_function":{"a":1},"call_chain":"x","reachable":true,"direct":false}`, + `{"vulnerable_function":"{'a': 1}","call_chain":["x"],"reachable":true,"direct":false}`}, + {`{"vulnerable_function":"","call_chain":null,"reachable":false,"direct":true}`, + `{"vulnerable_function":"","call_chain":[],"reachable":false,"direct":true}`}, + {`{"vulnerable_function":7,"call_chain":[1,"b"],"reachable":false,"direct":true}`, + `{"vulnerable_function":"7","call_chain":["1","b"],"reachable":false,"direct":true}`}, + // Python parity: ReachabilityProof's _coerce_to_str has NO dict probe, + // and null is falsy -> "unknown". + {`{"vulnerable_function":null,"call_chain":{},"reachable":false,"direct":false}`, + `{"vulnerable_function":"unknown","call_chain":[],"reachable":false,"direct":false}`}, + } + for _, tc := range cases { + got := mustMarshal(t, mustUnmarshal[ReachabilityProof](t, tc.in)) + if string(got) != tc.want { + t.Errorf("ReachabilityProof(%s)\n got: %s\nwant: %s", tc.in, got, tc.want) + } + } +} + +func TestExploitHypothesisCoercion(t *testing.T) { + cases := []struct{ in, want string }{ + {`{"hypothesis":{"a":1},"payload":null,"expected_outcome":""}`, + `{"hypothesis":"{'a': 1}","payload":null,"expected_outcome":""}`}, + {`{"hypothesis":"h","payload":0,"expected_outcome":"o"}`, + `{"hypothesis":"h","payload":"0","expected_outcome":"o"}`}, + {`{"hypothesis":"h","payload":{"k":"v"},"expected_outcome":"o"}`, + `{"hypothesis":"h","payload":"{'k': 'v'}","expected_outcome":"o"}`}, + {`{"hypothesis":"h","payload":"","expected_outcome":"o"}`, + `{"hypothesis":"h","payload":"","expected_outcome":"o"}`}, + {`{"hypothesis":null,"payload":[1,2],"expected_outcome":false}`, + `{"hypothesis":"unknown","payload":"[1, 2]","expected_outcome":"unknown"}`}, + } + for _, tc := range cases { + got := mustMarshal(t, mustUnmarshal[ExploitHypothesis](t, tc.in)) + if string(got) != tc.want { + t.Errorf("ExploitHypothesis(%s)\n got: %s\nwant: %s", tc.in, got, tc.want) + } + } +} + +// TestPyStrPreservesDictOrder pins the ordered dict repr — a Go map would +// scramble it, which is why pyStr walks the raw JSON with a token decoder. +func TestPyStrPreservesDictOrder(t *testing.T) { + in := `{"vulnerable_function":{"zeta":1,"alpha":2,"mid":{"b":true,"a":null}},"call_chain":[],"reachable":false,"direct":false}` + got := mustUnmarshal[ReachabilityProof](t, in).VulnerableFunction + want := "{'zeta': 1, 'alpha': 2, 'mid': {'b': True, 'a': None}}" + if got != want { + t.Errorf("pyStr\n got: %s\nwant: %s", got, want) + } +} + +// --------------------------------------------------------------------------- +// Optional / null parity spot-checks that the generated gate cannot express: +// a POINTER field set to a value round-trips, and dict/list-typed Optionals +// distinguish null from empty. +// --------------------------------------------------------------------------- + +func TestOptionalPointerRoundTrip(t *testing.T) { + ev := HttpEvidence{ + Method: strp("POST"), + URL: strp("https://example.test/login"), + Headers: map[string]string{"content-type": "application/json"}, + } + b := mustMarshal(t, ev) + want := `{"method":"POST","url":"https://example.test/login",` + + `"headers":{"content-type":"application/json"},"body":null,"highlighted_segment":null}` + if string(b) != want { + t.Errorf("HttpEvidence\n got: %s\nwant: %s", b, want) + } + back := mustUnmarshal[HttpEvidence](t, string(b)) + if !reflect.DeepEqual(back, ev) { + t.Errorf("round trip = %+v, want %+v", back, ev) + } + + // An empty (but non-nil) headers map marshals to {} — distinct from null. + empty := HttpEvidence{Headers: map[string]string{}} + if got := string(mustMarshal(t, empty)); got != + `{"method":null,"url":null,"headers":{},"body":null,"highlighted_segment":null}` { + t.Errorf("empty headers = %s", got) + } +} + +func TestProofOptionalListsMarshalNull(t *testing.T) { + // Python parity: Proof's list fields are `list[X] | None` with NO + // default_factory, so an unset one is null, not []. + b := mustMarshal(t, Proof{}) + var got map[string]any + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + for _, key := range []string{"data_flow_trace", "chain_steps"} { + if got[key] != nil { + t.Errorf("Proof.%s = %#v, want null", key, got[key]) + } + } + // ...while an explicitly empty list stays []. + b2 := mustMarshal(t, Proof{DataFlowTrace: []DataFlowStep{}}) + var got2 map[string]any + if err := json.Unmarshal(b2, &got2); err != nil { + t.Fatal(err) + } + if list, ok := got2["data_flow_trace"].([]any); !ok || len(list) != 0 { + t.Errorf("Proof.data_flow_trace = %#v, want []", got2["data_flow_trace"]) + } +} diff --git a/go/internal/schemas/testdata/model_keys.json b/go/internal/schemas/testdata/model_keys.json new file mode 100644 index 0000000..1098293 --- /dev/null +++ b/go/internal/schemas/testdata/model_keys.json @@ -0,0 +1,2944 @@ +{ + "_generated_by": "go/scripts/gen_model_keys.py", + "models": [ + { + "python_module": "sec_af.schemas.compliance", + "python_class": "ComplianceMapping", + "go_name": "ComplianceMapping", + "duplicate_of": null, + "keys": [ + "framework", + "control_id", + "control_name" + ], + "required": [ + "framework", + "control_id", + "control_name" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.compliance", + "python_class": "ComplianceGap", + "go_name": "ComplianceGap", + "duplicate_of": null, + "keys": [ + "framework", + "control_id", + "control_name", + "finding_count", + "max_severity", + "cwe_ids" + ], + "required": [ + "framework", + "control_id", + "control_name", + "finding_count", + "max_severity", + "cwe_ids" + ], + "accepts_null": [], + "int_fields": [ + "finding_count" + ], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.gates", + "python_class": "SeverityClassification", + "go_name": "SeverityClassification", + "duplicate_of": null, + "keys": [ + "severity", + "confidence", + "rationale" + ], + "required": [ + "severity", + "confidence", + "rationale" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [ + "confidence" + ], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.gates", + "python_class": "DuplicateCheck", + "go_name": "DuplicateCheck", + "duplicate_of": null, + "keys": [ + "is_duplicate", + "duplicate_of", + "reason" + ], + "required": [ + "is_duplicate", + "reason" + ], + "accepts_null": [ + "duplicate_of" + ], + "int_fields": [], + "float_fields": [], + "null_fields": [ + "duplicate_of" + ], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.gates", + "python_class": "StrategySelection", + "go_name": "StrategySelection", + "duplicate_of": null, + "keys": [ + "strategies", + "rationale" + ], + "required": [ + "strategies", + "rationale" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.gates", + "python_class": "CWEExpansion", + "go_name": "CWEExpansion", + "duplicate_of": null, + "keys": [ + "additional_cwes", + "rationale" + ], + "required": [ + "additional_cwes", + "rationale" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.gates", + "python_class": "RelevanceGate", + "go_name": "RelevanceGate", + "duplicate_of": null, + "keys": [ + "is_relevant", + "confidence", + "reason" + ], + "required": [ + "is_relevant", + "confidence", + "reason" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [ + "confidence" + ], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.gates", + "python_class": "VerdictGate", + "go_name": "VerdictGate", + "duplicate_of": null, + "keys": [ + "confirmed", + "confidence", + "reason" + ], + "required": [ + "confirmed", + "confidence", + "reason" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [ + "confidence" + ], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.gates", + "python_class": "ComplianceSuggestion", + "go_name": "ComplianceSuggestion", + "duplicate_of": null, + "keys": [ + "framework", + "control_id", + "control_name" + ], + "required": [ + "framework", + "control_id", + "control_name" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.gates", + "python_class": "ComplianceGate", + "go_name": "ComplianceGate", + "duplicate_of": null, + "keys": [ + "mappings", + "confidence" + ], + "required": [ + "mappings", + "confidence" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.gates", + "python_class": "ReachabilityGate", + "go_name": "ReachabilityGate", + "duplicate_of": null, + "keys": [ + "reachability", + "rationale", + "confidence" + ], + "required": [ + "reachability", + "rationale", + "confidence" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.hunt", + "python_class": "VulnLocation", + "go_name": "VulnLocation", + "duplicate_of": null, + "keys": [ + "file_path", + "start_line", + "code_snippet", + "pattern_type" + ], + "required": [ + "file_path", + "start_line", + "code_snippet", + "pattern_type" + ], + "accepts_null": [], + "int_fields": [ + "start_line" + ], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.hunt", + "python_class": "EnrichedFinding", + "go_name": "EnrichedFinding", + "duplicate_of": null, + "keys": [ + "title", + "description", + "cwe_id", + "severity", + "confidence", + "data_flow_summary" + ], + "required": [ + "title", + "description", + "cwe_id", + "severity", + "confidence", + "data_flow_summary" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.hunt", + "python_class": "ScanLocationsResult", + "go_name": "ScanLocationsResult", + "duplicate_of": null, + "keys": [ + "locations" + ], + "required": [], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [ + "locations" + ], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.hunt", + "python_class": "RawFinding", + "go_name": "RawFinding", + "duplicate_of": null, + "keys": [ + "id", + "hunter_strategy", + "title", + "description", + "finding_type", + "cwe_id", + "cwe_name", + "owasp_category", + "file_path", + "start_line", + "end_line", + "function_name", + "code_snippet", + "estimated_severity", + "confidence", + "data_flow", + "related_files", + "fingerprint" + ], + "required": [ + "hunter_strategy", + "title", + "description", + "finding_type", + "cwe_id", + "cwe_name", + "file_path", + "start_line", + "end_line", + "code_snippet", + "estimated_severity", + "confidence" + ], + "accepts_null": [ + "owasp_category", + "function_name", + "data_flow" + ], + "int_fields": [ + "start_line", + "end_line" + ], + "float_fields": [], + "null_fields": [ + "owasp_category", + "function_name", + "data_flow" + ], + "empty_list_fields": [ + "related_files" + ], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [ + "id", + "fingerprint" + ] + }, + { + "python_module": "sec_af.schemas.hunt", + "python_class": "PotentialChain", + "go_name": "PotentialChain", + "duplicate_of": null, + "keys": [ + "chain_id", + "title", + "finding_ids", + "combined_impact", + "estimated_severity" + ], + "required": [ + "title", + "combined_impact", + "estimated_severity" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [ + "finding_ids" + ], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [ + "chain_id" + ] + }, + { + "python_module": "sec_af.schemas.hunt", + "python_class": "HuntResult", + "go_name": "HuntResult", + "duplicate_of": null, + "keys": [ + "findings", + "chains", + "total_raw", + "deduplicated_count", + "chain_count", + "strategies_run", + "hunt_duration_seconds" + ], + "required": [], + "accepts_null": [], + "int_fields": [ + "total_raw", + "deduplicated_count", + "chain_count" + ], + "float_fields": [ + "hunt_duration_seconds" + ], + "null_fields": [], + "empty_list_fields": [ + "findings", + "chains", + "strategies_run" + ], + "empty_dict_fields": [], + "scalar_defaults": { + "total_raw": 0, + "deduplicated_count": 0, + "chain_count": 0, + "hunt_duration_seconds": 0.0 + }, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.hunt", + "python_class": "DeduplicatedResult", + "go_name": "DeduplicatedResult", + "duplicate_of": null, + "keys": [ + "findings", + "chains", + "dropped_duplicates", + "kept_findings" + ], + "required": [], + "accepts_null": [], + "int_fields": [ + "dropped_duplicates", + "kept_findings" + ], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [ + "findings", + "chains" + ], + "empty_dict_fields": [], + "scalar_defaults": { + "dropped_duplicates": 0, + "kept_findings": 0 + }, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.hunt", + "python_class": "ChainCorrelationResult", + "go_name": "ChainCorrelationResult", + "duplicate_of": null, + "keys": [ + "chains", + "duplicate_ids" + ], + "required": [], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [ + "chains", + "duplicate_ids" + ], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.input", + "python_class": "AuditInput", + "go_name": "AuditInput", + "duplicate_of": null, + "keys": [ + "repo_url", + "branch", + "commit_sha", + "base_commit_sha", + "depth", + "severity_threshold", + "scan_types", + "output_formats", + "compliance_frameworks", + "max_cost_usd", + "max_provers", + "max_duration_seconds", + "include_paths", + "exclude_paths", + "is_pr", + "pr_id", + "post_pr_comments", + "fail_on_findings", + "dast_enabled", + "repo_urls", + "monitoring_mode", + "baseline_path", + "custom_policies" + ], + "required": [ + "repo_url" + ], + "accepts_null": [ + "commit_sha", + "base_commit_sha", + "max_cost_usd", + "max_provers", + "max_duration_seconds", + "include_paths", + "pr_id", + "baseline_path" + ], + "int_fields": [ + "max_provers", + "max_duration_seconds" + ], + "float_fields": [ + "max_cost_usd" + ], + "null_fields": [ + "commit_sha", + "base_commit_sha", + "max_cost_usd", + "max_provers", + "max_duration_seconds", + "include_paths", + "pr_id", + "baseline_path" + ], + "empty_list_fields": [ + "compliance_frameworks", + "repo_urls", + "custom_policies" + ], + "empty_dict_fields": [], + "scalar_defaults": { + "branch": "main", + "depth": "standard", + "severity_threshold": "low", + "scan_types": [ + "sast", + "sca", + "secrets", + "config" + ], + "output_formats": [ + "json" + ], + "exclude_paths": [ + "tests/", + "vendor/", + "node_modules/", + ".git/" + ], + "is_pr": false, + "post_pr_comments": false, + "fail_on_findings": false, + "dast_enabled": false, + "monitoring_mode": false + }, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.output", + "python_class": "Location", + "go_name": "Location", + "duplicate_of": null, + "keys": [ + "file_path", + "start_line", + "end_line", + "start_column", + "end_column", + "function_name", + "code_snippet" + ], + "required": [ + "file_path", + "start_line", + "end_line" + ], + "accepts_null": [ + "start_column", + "end_column", + "function_name", + "code_snippet" + ], + "int_fields": [ + "start_line", + "end_line", + "start_column", + "end_column" + ], + "float_fields": [], + "null_fields": [ + "start_column", + "end_column", + "function_name", + "code_snippet" + ], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.output", + "python_class": "CvssV4Score", + "go_name": "CvssV4Score", + "duplicate_of": null, + "keys": [ + "vector", + "base_score", + "severity", + "automatable", + "subsequent_impact" + ], + "required": [ + "vector", + "base_score", + "severity", + "automatable", + "subsequent_impact" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [ + "base_score" + ], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.output", + "python_class": "EpssScore", + "go_name": "EpssScore", + "duplicate_of": null, + "keys": [ + "score", + "percentile", + "date" + ], + "required": [ + "score", + "percentile", + "date" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [ + "score", + "percentile" + ], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.output", + "python_class": "MitreMapping", + "go_name": "MitreMapping", + "duplicate_of": null, + "keys": [ + "tactic", + "technique_id", + "technique_name" + ], + "required": [ + "tactic", + "technique_id", + "technique_name" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.output", + "python_class": "AttackChain", + "go_name": "AttackChain", + "duplicate_of": null, + "keys": [ + "chain_id", + "title", + "description", + "findings", + "combined_severity", + "combined_impact", + "mitre_attack_mapping" + ], + "required": [ + "chain_id", + "title", + "description", + "combined_severity", + "combined_impact" + ], + "accepts_null": [ + "mitre_attack_mapping" + ], + "int_fields": [], + "float_fields": [], + "null_fields": [ + "mitre_attack_mapping" + ], + "empty_list_fields": [ + "findings" + ], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.output", + "python_class": "ReproductionStep", + "go_name": "ReproductionStep", + "duplicate_of": null, + "keys": [ + "step", + "description", + "command", + "expected_output" + ], + "required": [ + "step", + "description" + ], + "accepts_null": [ + "command", + "expected_output" + ], + "int_fields": [ + "step" + ], + "float_fields": [], + "null_fields": [ + "command", + "expected_output" + ], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.output", + "python_class": "ServiceDefinition", + "go_name": "ServiceDefinition", + "duplicate_of": null, + "keys": [ + "name", + "repo_url", + "api_endpoints", + "dependencies" + ], + "required": [ + "name", + "repo_url" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [ + "api_endpoints", + "dependencies" + ], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.output", + "python_class": "CrossServiceFinding", + "go_name": "CrossServiceFinding", + "duplicate_of": null, + "keys": [ + "chain_description", + "services_involved", + "entry_point", + "impact" + ], + "required": [ + "chain_description", + "services_involved", + "entry_point", + "impact" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.output", + "python_class": "RegressionFinding", + "go_name": "RegressionFinding", + "duplicate_of": null, + "keys": [ + "finding_title", + "finding_id", + "severity", + "cwe_id", + "status" + ], + "required": [ + "finding_title", + "finding_id", + "severity", + "cwe_id", + "status" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.output", + "python_class": "MonitoringResult", + "go_name": "MonitoringResult", + "duplicate_of": null, + "keys": [ + "baseline_commit", + "current_commit", + "new_findings", + "fixed_findings", + "unchanged_count", + "regression_detected" + ], + "required": [ + "baseline_commit", + "current_commit" + ], + "accepts_null": [], + "int_fields": [ + "unchanged_count" + ], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [ + "new_findings", + "fixed_findings" + ], + "empty_dict_fields": [], + "scalar_defaults": { + "unchanged_count": 0, + "regression_detected": false + }, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.output", + "python_class": "PolicyViolation", + "go_name": "PolicyViolation", + "duplicate_of": null, + "keys": [ + "policy", + "violation_description", + "file_path", + "severity" + ], + "required": [ + "policy", + "violation_description", + "file_path" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": { + "severity": "medium" + }, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.output", + "python_class": "SecurityAuditResult", + "go_name": "SecurityAuditResult", + "duplicate_of": null, + "keys": [ + "repository", + "commit_sha", + "branch", + "timestamp", + "depth_profile", + "strategies_used", + "provider", + "findings", + "attack_chains", + "total_raw_findings", + "confirmed", + "likely", + "inconclusive", + "not_exploitable", + "noise_reduction_pct", + "by_severity", + "compliance_gaps", + "policy_violations", + "duration_seconds", + "agent_invocations", + "cost_usd", + "cost_breakdown", + "metadata", + "sarif" + ], + "required": [ + "repository", + "commit_sha", + "timestamp", + "depth_profile", + "provider", + "sarif" + ], + "accepts_null": [ + "branch" + ], + "int_fields": [ + "total_raw_findings", + "confirmed", + "likely", + "inconclusive", + "not_exploitable", + "agent_invocations" + ], + "float_fields": [ + "noise_reduction_pct", + "duration_seconds", + "cost_usd" + ], + "null_fields": [ + "branch" + ], + "empty_list_fields": [ + "strategies_used", + "findings", + "attack_chains", + "compliance_gaps", + "policy_violations" + ], + "empty_dict_fields": [ + "by_severity", + "cost_breakdown", + "metadata" + ], + "scalar_defaults": { + "total_raw_findings": 0, + "confirmed": 0, + "likely": 0, + "inconclusive": 0, + "not_exploitable": 0, + "noise_reduction_pct": 0.0, + "duration_seconds": 0.0, + "agent_invocations": 0, + "cost_usd": 0.0 + }, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.output", + "python_class": "AuditProgress", + "go_name": "AuditProgress", + "duplicate_of": null, + "keys": [ + "phase", + "phase_progress", + "agents_total", + "agents_completed", + "agents_running", + "findings_so_far", + "elapsed_seconds", + "estimated_remaining_seconds", + "cost_so_far_usd" + ], + "required": [ + "phase", + "phase_progress", + "agents_total", + "agents_completed", + "agents_running", + "findings_so_far", + "elapsed_seconds", + "estimated_remaining_seconds", + "cost_so_far_usd" + ], + "accepts_null": [], + "int_fields": [ + "agents_total", + "agents_completed", + "agents_running", + "findings_so_far" + ], + "float_fields": [ + "phase_progress", + "elapsed_seconds", + "estimated_remaining_seconds", + "cost_so_far_usd" + ], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.output", + "python_class": "AuditMetrics", + "go_name": "AuditMetrics", + "duplicate_of": null, + "keys": [ + "duration_seconds", + "agent_invocations", + "cost_usd", + "cost_breakdown", + "budget_exhausted", + "findings_not_verified" + ], + "required": [ + "duration_seconds", + "agent_invocations", + "cost_usd" + ], + "accepts_null": [], + "int_fields": [ + "agent_invocations", + "findings_not_verified" + ], + "float_fields": [ + "duration_seconds", + "cost_usd" + ], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [ + "cost_breakdown" + ], + "scalar_defaults": { + "budget_exhausted": false, + "findings_not_verified": 0 + }, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.prove", + "python_class": "DataFlowTrace", + "go_name": "DataFlowTrace", + "duplicate_of": null, + "keys": [ + "source", + "sink", + "steps", + "sink_reached" + ], + "required": [ + "source", + "sink", + "steps", + "sink_reached" + ], + "accepts_null": [ + "source", + "sink", + "steps" + ], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.prove", + "python_class": "ReachabilityProof", + "go_name": "ReachabilityProof", + "duplicate_of": null, + "keys": [ + "vulnerable_function", + "call_chain", + "reachable", + "direct" + ], + "required": [ + "vulnerable_function", + "call_chain", + "reachable", + "direct" + ], + "accepts_null": [ + "vulnerable_function", + "call_chain" + ], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.prove", + "python_class": "SanitizationResult", + "go_name": "SanitizationResult", + "duplicate_of": null, + "keys": [ + "found", + "type", + "sufficient", + "bypass_method" + ], + "required": [ + "found" + ], + "accepts_null": [ + "type", + "sufficient", + "bypass_method" + ], + "int_fields": [], + "float_fields": [], + "null_fields": [ + "type", + "sufficient", + "bypass_method" + ], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.prove", + "python_class": "ExploitHypothesis", + "go_name": "ExploitHypothesis", + "duplicate_of": null, + "keys": [ + "hypothesis", + "payload", + "expected_outcome" + ], + "required": [ + "hypothesis", + "expected_outcome" + ], + "accepts_null": [ + "hypothesis", + "payload", + "expected_outcome" + ], + "int_fields": [], + "float_fields": [], + "null_fields": [ + "payload" + ], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.prove", + "python_class": "DastVerificationResult", + "go_name": "DastVerificationResult", + "duplicate_of": null, + "keys": [ + "payload_sent", + "response_summary", + "exploit_confirmed", + "safety_notes" + ], + "required": [ + "payload_sent", + "response_summary", + "exploit_confirmed", + "safety_notes" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.prove", + "python_class": "VerdictDecision", + "go_name": "VerdictDecision", + "duplicate_of": null, + "keys": [ + "verdict", + "evidence_level", + "rationale", + "confidence" + ], + "required": [ + "verdict", + "evidence_level", + "rationale", + "confidence" + ], + "accepts_null": [], + "int_fields": [ + "evidence_level" + ], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.prove", + "python_class": "RemediationSuggestion", + "go_name": "RemediationSuggestion", + "duplicate_of": null, + "keys": [ + "fix_description", + "patch_diff", + "confidence" + ], + "required": [ + "fix_description", + "patch_diff", + "confidence" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.prove", + "python_class": "DataFlowStep", + "go_name": "DataFlowStep", + "duplicate_of": null, + "keys": [ + "file", + "line", + "description", + "tainted" + ], + "required": [ + "file", + "line", + "description", + "tainted" + ], + "accepts_null": [], + "int_fields": [ + "line" + ], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.prove", + "python_class": "DataFlowEvidence", + "go_name": "DataFlowEvidence", + "duplicate_of": null, + "keys": [ + "steps", + "source", + "sink", + "sink_reached" + ], + "required": [], + "accepts_null": [ + "source", + "sink" + ], + "int_fields": [], + "float_fields": [], + "null_fields": [ + "source", + "sink" + ], + "empty_list_fields": [ + "steps" + ], + "empty_dict_fields": [], + "scalar_defaults": { + "sink_reached": false + }, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.prove", + "python_class": "SanitizationAnalysis", + "go_name": "SanitizationAnalysis", + "duplicate_of": null, + "keys": [ + "sanitization_found", + "sanitization_type", + "sanitization_sufficient", + "bypass_possible", + "bypass_method" + ], + "required": [ + "sanitization_found" + ], + "accepts_null": [ + "sanitization_type", + "sanitization_sufficient", + "bypass_possible", + "bypass_method" + ], + "int_fields": [], + "float_fields": [], + "null_fields": [ + "sanitization_type", + "sanitization_sufficient", + "bypass_possible", + "bypass_method" + ], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.prove", + "python_class": "HttpEvidence", + "go_name": "HttpEvidence", + "duplicate_of": null, + "keys": [ + "method", + "url", + "headers", + "body", + "highlighted_segment" + ], + "required": [], + "accepts_null": [ + "method", + "url", + "headers", + "body", + "highlighted_segment" + ], + "int_fields": [], + "float_fields": [], + "null_fields": [ + "method", + "url", + "headers", + "body", + "highlighted_segment" + ], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.prove", + "python_class": "ReachabilityEvidence", + "go_name": "ReachabilityEvidence", + "duplicate_of": null, + "keys": [ + "vulnerable_function", + "call_chain", + "reachable", + "direct_dependency" + ], + "required": [ + "vulnerable_function", + "reachable", + "direct_dependency" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [ + "call_chain" + ], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.prove", + "python_class": "ChainStep", + "go_name": "ChainStep", + "duplicate_of": null, + "keys": [ + "step_number", + "finding_id", + "description", + "enables" + ], + "required": [ + "step_number", + "finding_id", + "description", + "enables" + ], + "accepts_null": [], + "int_fields": [ + "step_number" + ], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.prove", + "python_class": "Proof", + "go_name": "Proof", + "duplicate_of": null, + "keys": [ + "exploit_hypothesis", + "verification_method", + "evidence_level", + "data_flow_trace", + "data_flow_evidence", + "sanitization_analysis", + "vulnerable_code", + "exploit_payload", + "expected_outcome", + "poc_code", + "poc_execution_output", + "http_request", + "http_response", + "reachability", + "chain_steps" + ], + "required": [ + "exploit_hypothesis", + "verification_method", + "evidence_level" + ], + "accepts_null": [ + "data_flow_trace", + "data_flow_evidence", + "sanitization_analysis", + "vulnerable_code", + "exploit_payload", + "expected_outcome", + "poc_code", + "poc_execution_output", + "http_request", + "http_response", + "reachability", + "chain_steps" + ], + "int_fields": [ + "evidence_level" + ], + "float_fields": [], + "null_fields": [ + "data_flow_trace", + "data_flow_evidence", + "sanitization_analysis", + "vulnerable_code", + "exploit_payload", + "expected_outcome", + "poc_code", + "poc_execution_output", + "http_request", + "http_response", + "reachability", + "chain_steps" + ], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.prove", + "python_class": "ProverSignal", + "go_name": "ProverSignal", + "duplicate_of": null, + "keys": [ + "expand", + "expansion_reason", + "expansion_strategy", + "expansion_target" + ], + "required": [], + "accepts_null": [ + "expansion_reason", + "expansion_strategy", + "expansion_target" + ], + "int_fields": [], + "float_fields": [], + "null_fields": [ + "expansion_reason", + "expansion_strategy", + "expansion_target" + ], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": { + "expand": false + }, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.prove", + "python_class": "VerifiedFinding", + "go_name": "VerifiedFinding", + "duplicate_of": null, + "keys": [ + "id", + "fingerprint", + "title", + "description", + "finding_type", + "cwe_id", + "cwe_name", + "owasp_category", + "tags", + "verdict", + "evidence_level", + "rationale", + "severity", + "cvss_v4", + "epss", + "exploitability_score", + "proof", + "location", + "related_locations", + "chain_id", + "chain_step", + "enables", + "compliance", + "reproduction_steps", + "remediation", + "sarif_rule_id", + "sarif_security_severity", + "drop_reason" + ], + "required": [ + "fingerprint", + "title", + "description", + "finding_type", + "cwe_id", + "cwe_name", + "verdict", + "evidence_level", + "rationale", + "severity", + "exploitability_score", + "location", + "sarif_rule_id", + "sarif_security_severity" + ], + "accepts_null": [ + "owasp_category", + "cvss_v4", + "epss", + "proof", + "chain_id", + "chain_step", + "enables", + "remediation", + "drop_reason" + ], + "int_fields": [ + "evidence_level", + "chain_step" + ], + "float_fields": [ + "exploitability_score", + "sarif_security_severity" + ], + "null_fields": [ + "owasp_category", + "cvss_v4", + "epss", + "proof", + "chain_id", + "chain_step", + "enables", + "remediation", + "drop_reason" + ], + "empty_list_fields": [ + "tags", + "related_locations", + "compliance", + "reproduction_steps" + ], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [ + "id" + ] + }, + { + "python_module": "sec_af.schemas.prove", + "python_class": "Location", + "go_name": "Location", + "duplicate_of": "Location", + "keys": [ + "file_path", + "start_line", + "end_line", + "start_column", + "end_column", + "function_name", + "code_snippet" + ], + "required": [ + "file_path", + "start_line", + "end_line" + ], + "accepts_null": [ + "start_column", + "end_column", + "function_name", + "code_snippet" + ], + "int_fields": [ + "start_line", + "end_line", + "start_column", + "end_column" + ], + "float_fields": [], + "null_fields": [ + "start_column", + "end_column", + "function_name", + "code_snippet" + ], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.prove", + "python_class": "CvssV4Score", + "go_name": "CvssV4Score", + "duplicate_of": "CvssV4Score", + "keys": [ + "vector", + "base_score", + "severity", + "automatable", + "subsequent_impact" + ], + "required": [ + "vector", + "base_score", + "severity", + "automatable", + "subsequent_impact" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [ + "base_score" + ], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.prove", + "python_class": "EpssScore", + "go_name": "EpssScore", + "duplicate_of": "EpssScore", + "keys": [ + "score", + "percentile", + "date" + ], + "required": [ + "score", + "percentile", + "date" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [ + "score", + "percentile" + ], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.prove", + "python_class": "ReproductionStep", + "go_name": "ReproductionStep", + "duplicate_of": "ReproductionStep", + "keys": [ + "step", + "description", + "command", + "expected_output" + ], + "required": [ + "step", + "description" + ], + "accepts_null": [ + "command", + "expected_output" + ], + "int_fields": [ + "step" + ], + "float_fields": [], + "null_fields": [ + "command", + "expected_output" + ], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "Module", + "go_name": "Module", + "duplicate_of": null, + "keys": [ + "name", + "path", + "language", + "description", + "dependencies" + ], + "required": [ + "name", + "path", + "language" + ], + "accepts_null": [ + "description" + ], + "int_fields": [], + "float_fields": [], + "null_fields": [ + "description" + ], + "empty_list_fields": [ + "dependencies" + ], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "EntryPoint", + "go_name": "EntryPoint", + "duplicate_of": null, + "keys": [ + "kind", + "identifier", + "file_path", + "line", + "method", + "route", + "auth_required" + ], + "required": [ + "kind", + "identifier", + "file_path", + "line" + ], + "accepts_null": [ + "method", + "route", + "auth_required" + ], + "int_fields": [ + "line" + ], + "float_fields": [], + "null_fields": [ + "method", + "route", + "auth_required" + ], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "TrustBoundary", + "go_name": "TrustBoundary", + "duplicate_of": null, + "keys": [ + "name", + "source_zone", + "target_zone", + "description", + "enforcement" + ], + "required": [ + "name", + "source_zone", + "target_zone", + "description" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [ + "enforcement" + ], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "Service", + "go_name": "Service", + "duplicate_of": null, + "keys": [ + "name", + "service_type", + "endpoint", + "purpose", + "auth_mechanism" + ], + "required": [ + "name", + "service_type" + ], + "accepts_null": [ + "endpoint", + "purpose", + "auth_mechanism" + ], + "int_fields": [], + "float_fields": [], + "null_fields": [ + "endpoint", + "purpose", + "auth_mechanism" + ], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "APIEndpoint", + "go_name": "APIEndpoint", + "duplicate_of": null, + "keys": [ + "method", + "path", + "handler", + "file_path", + "line", + "auth_required", + "rate_limited" + ], + "required": [ + "method", + "path", + "handler", + "file_path", + "line" + ], + "accepts_null": [ + "auth_required", + "rate_limited" + ], + "int_fields": [ + "line" + ], + "float_fields": [], + "null_fields": [ + "auth_required", + "rate_limited" + ], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "ArchitectureMap", + "go_name": "ArchitectureMap", + "duplicate_of": null, + "keys": [ + "app_type", + "modules", + "entry_points", + "trust_boundaries", + "services", + "api_surface" + ], + "required": [], + "accepts_null": [ + "app_type" + ], + "int_fields": [], + "float_fields": [], + "null_fields": [ + "app_type" + ], + "empty_list_fields": [ + "modules", + "entry_points", + "trust_boundaries", + "services", + "api_surface" + ], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "DataFlowStep", + "go_name": "ReconDataFlowStep", + "duplicate_of": null, + "keys": [ + "file_path", + "line", + "component", + "operation" + ], + "required": [ + "file_path", + "line", + "component", + "operation" + ], + "accepts_null": [], + "int_fields": [ + "line" + ], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "SanitizationPoint", + "go_name": "SanitizationPoint", + "duplicate_of": null, + "keys": [ + "file_path", + "line", + "function_name", + "sanitization_type", + "protects_against" + ], + "required": [ + "file_path", + "line", + "sanitization_type" + ], + "accepts_null": [ + "function_name" + ], + "int_fields": [ + "line" + ], + "float_fields": [], + "null_fields": [ + "function_name" + ], + "empty_list_fields": [ + "protects_against" + ], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "Sink", + "go_name": "Sink", + "duplicate_of": null, + "keys": [ + "sink_type", + "file_path", + "line", + "function_name", + "exploitability_notes" + ], + "required": [ + "sink_type", + "file_path", + "line" + ], + "accepts_null": [ + "function_name", + "exploitability_notes" + ], + "int_fields": [ + "line" + ], + "float_fields": [], + "null_fields": [ + "function_name", + "exploitability_notes" + ], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "DataFlow", + "go_name": "DataFlow", + "duplicate_of": null, + "keys": [ + "source", + "path", + "sink", + "sanitized", + "files" + ], + "required": [ + "source", + "sink", + "sanitized" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [ + "path", + "files" + ], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "DataFlowMap", + "go_name": "DataFlowMap", + "duplicate_of": null, + "keys": [ + "flows", + "sanitization_points", + "sinks" + ], + "required": [], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [ + "flows", + "sanitization_points", + "sinks" + ], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "Dependency", + "go_name": "Dependency", + "duplicate_of": null, + "keys": [ + "name", + "version", + "ecosystem", + "direct", + "license" + ], + "required": [ + "name", + "version", + "ecosystem", + "direct" + ], + "accepts_null": [ + "license" + ], + "int_fields": [], + "float_fields": [], + "null_fields": [ + "license" + ], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "KnownCVE", + "go_name": "KnownCVE", + "duplicate_of": null, + "keys": [ + "cve_id", + "package", + "installed_version", + "fixed_version", + "cvss_v4_score", + "epss_score", + "direct", + "reachable" + ], + "required": [ + "cve_id", + "package", + "installed_version", + "direct" + ], + "accepts_null": [ + "fixed_version", + "cvss_v4_score", + "epss_score", + "reachable" + ], + "int_fields": [], + "float_fields": [ + "cvss_v4_score", + "epss_score" + ], + "null_fields": [ + "fixed_version", + "cvss_v4_score", + "epss_score", + "reachable" + ], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "OutdatedDep", + "go_name": "OutdatedDep", + "duplicate_of": null, + "keys": [ + "package", + "current_version", + "latest_version", + "direct" + ], + "required": [ + "package", + "current_version", + "latest_version", + "direct" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "DependencyReport", + "go_name": "DependencyReport", + "duplicate_of": null, + "keys": [ + "sbom", + "known_cves", + "outdated", + "direct_count", + "transitive_count" + ], + "required": [], + "accepts_null": [], + "int_fields": [ + "direct_count", + "transitive_count" + ], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [ + "sbom", + "known_cves", + "outdated" + ], + "empty_dict_fields": [], + "scalar_defaults": { + "direct_count": 0, + "transitive_count": 0 + }, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "SecretFinding", + "go_name": "SecretFinding", + "duplicate_of": null, + "keys": [ + "id", + "secret_type", + "file_path", + "line", + "match", + "confidence", + "is_test_value" + ], + "required": [ + "secret_type", + "file_path", + "line", + "match", + "confidence" + ], + "accepts_null": [ + "is_test_value" + ], + "int_fields": [ + "line" + ], + "float_fields": [], + "null_fields": [ + "is_test_value" + ], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [ + "id" + ] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "MisconfigFinding", + "go_name": "MisconfigFinding", + "duplicate_of": null, + "keys": [ + "id", + "category", + "file_path", + "line", + "key", + "value", + "risk", + "remediation" + ], + "required": [ + "category", + "file_path", + "risk" + ], + "accepts_null": [ + "line", + "key", + "value", + "remediation" + ], + "int_fields": [ + "line" + ], + "float_fields": [], + "null_fields": [ + "line", + "key", + "value", + "remediation" + ], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [ + "id" + ] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "ConfigReport", + "go_name": "ConfigReport", + "duplicate_of": null, + "keys": [ + "secrets", + "misconfigs" + ], + "required": [], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [ + "secrets", + "misconfigs" + ], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "CryptoUsage", + "go_name": "CryptoUsage", + "duplicate_of": null, + "keys": [ + "algorithm", + "key_size", + "mode", + "usage_context", + "is_weak" + ], + "required": [ + "algorithm" + ], + "accepts_null": [ + "key_size", + "mode", + "usage_context", + "is_weak" + ], + "int_fields": [ + "key_size" + ], + "float_fields": [], + "null_fields": [ + "key_size", + "mode", + "usage_context", + "is_weak" + ], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "SecurityContext", + "go_name": "SecurityContext", + "duplicate_of": null, + "keys": [ + "auth_model", + "auth_details", + "crypto_usage", + "framework_security", + "security_headers", + "deployment_signals" + ], + "required": [ + "auth_model", + "auth_details" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [ + "crypto_usage", + "framework_security", + "security_headers", + "deployment_signals" + ], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "ReconResult", + "go_name": "ReconResult", + "duplicate_of": null, + "keys": [ + "architecture", + "data_flows", + "dependencies", + "config", + "security_context", + "languages", + "frameworks", + "lines_of_code", + "file_count", + "recon_duration_seconds" + ], + "required": [ + "architecture", + "data_flows", + "dependencies", + "config", + "security_context" + ], + "accepts_null": [], + "int_fields": [ + "lines_of_code", + "file_count" + ], + "float_fields": [ + "recon_duration_seconds" + ], + "null_fields": [], + "empty_list_fields": [ + "languages", + "frameworks" + ], + "empty_dict_fields": [], + "scalar_defaults": { + "lines_of_code": 0, + "file_count": 0, + "recon_duration_seconds": 0.0 + }, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "ArchitectureMapRaw", + "go_name": "ArchitectureMapRaw", + "duplicate_of": null, + "keys": [ + "app_type", + "modules", + "entry_points", + "trust_boundaries", + "services", + "api_endpoints" + ], + "required": [], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [ + "modules", + "entry_points", + "trust_boundaries", + "services", + "api_endpoints" + ], + "empty_dict_fields": [], + "scalar_defaults": { + "app_type": "unknown" + }, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "DataFlowMapRaw", + "go_name": "DataFlowMapRaw", + "duplicate_of": null, + "keys": [ + "flows", + "sanitization_points", + "sinks" + ], + "required": [], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [ + "flows", + "sanitization_points", + "sinks" + ], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "DependencyReportRaw", + "go_name": "DependencyReportRaw", + "duplicate_of": null, + "keys": [ + "sbom", + "known_cves", + "outdated" + ], + "required": [], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [ + "sbom", + "known_cves", + "outdated" + ], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "ConfigReportRaw", + "go_name": "ConfigReportRaw", + "duplicate_of": null, + "keys": [ + "secrets", + "misconfigs" + ], + "required": [], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [ + "secrets", + "misconfigs" + ], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.recon", + "python_class": "SecurityContextRaw", + "go_name": "SecurityContextRaw", + "duplicate_of": null, + "keys": [ + "auth_model", + "auth_details", + "crypto_usage", + "security_signals" + ], + "required": [ + "auth_model" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [ + "crypto_usage", + "security_signals" + ], + "empty_dict_fields": [], + "scalar_defaults": { + "auth_details": "" + }, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.views", + "python_class": "FindingForVerifier", + "go_name": "FindingForVerifier", + "duplicate_of": null, + "keys": [ + "id", + "title", + "description", + "file_path", + "start_line", + "end_line", + "code_snippet", + "cwe_id", + "function_name", + "data_flow_summary" + ], + "required": [ + "id", + "title", + "description", + "file_path", + "start_line", + "end_line", + "code_snippet", + "cwe_id" + ], + "accepts_null": [ + "function_name" + ], + "int_fields": [ + "start_line", + "end_line" + ], + "float_fields": [], + "null_fields": [ + "function_name" + ], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": { + "data_flow_summary": "" + }, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.views", + "python_class": "FindingForDedup", + "go_name": "FindingForDedup", + "duplicate_of": null, + "keys": [ + "id", + "fingerprint", + "title", + "file_path", + "start_line", + "cwe_id", + "finding_type", + "estimated_severity" + ], + "required": [ + "id", + "fingerprint", + "title", + "file_path", + "start_line", + "cwe_id", + "finding_type", + "estimated_severity" + ], + "accepts_null": [], + "int_fields": [ + "start_line" + ], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.schemas.views", + "python_class": "FindingForReachability", + "go_name": "FindingForReachability", + "duplicate_of": null, + "keys": [ + "title", + "description", + "cwe_id", + "file_path", + "start_line", + "verdict" + ], + "required": [ + "title", + "description", + "cwe_id", + "file_path", + "start_line", + "verdict" + ], + "accepts_null": [], + "int_fields": [ + "start_line" + ], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + }, + { + "python_module": "sec_af.policies", + "python_class": "PolicyEvalResult", + "go_name": "PolicyEvalResult", + "duplicate_of": null, + "keys": [ + "violated", + "description", + "file_path", + "severity" + ], + "required": [ + "violated", + "description", + "file_path", + "severity" + ], + "accepts_null": [], + "int_fields": [], + "float_fields": [], + "null_fields": [], + "empty_list_fields": [], + "empty_dict_fields": [], + "scalar_defaults": {}, + "uuid_defaults": [] + } + ], + "enums": { + "FindingType": { + "python_module": "sec_af.schemas.hunt", + "members": { + "SAST": "sast", + "SCA": "sca", + "SECRETS": "secrets", + "CONFIG": "config", + "LOGIC": "logic", + "API": "api" + }, + "kind": "str" + }, + "Severity": { + "python_module": "sec_af.schemas.hunt", + "members": { + "CRITICAL": "critical", + "HIGH": "high", + "MEDIUM": "medium", + "LOW": "low", + "INFO": "info" + }, + "kind": "str" + }, + "Confidence": { + "python_module": "sec_af.schemas.hunt", + "members": { + "HIGH": "high", + "MEDIUM": "medium", + "LOW": "low" + }, + "kind": "str" + }, + "HuntStrategy": { + "python_module": "sec_af.schemas.hunt", + "members": { + "INJECTION": "injection", + "XSS": "xss", + "DOS": "dos", + "SSRF": "ssrf", + "AUTH": "auth", + "CRYPTO": "crypto", + "BUSINESS_LOGIC": "business_logic", + "LOGIC_BUGS": "business_logic", + "DATA_EXPOSURE": "data_exposure", + "SUPPLY_CHAIN": "supply_chain", + "CONFIG_SECRETS": "config_secrets", + "API_SECURITY": "api_security", + "PYTHON_SPECIFIC": "python_specific", + "JAVASCRIPT_SPECIFIC": "javascript_specific" + }, + "kind": "str" + }, + "Verdict": { + "python_module": "sec_af.schemas.prove", + "members": { + "CONFIRMED": "confirmed", + "LIKELY": "likely", + "INCONCLUSIVE": "inconclusive", + "NOT_EXPLOITABLE": "not_exploitable" + }, + "kind": "str" + }, + "EvidenceLevel": { + "python_module": "sec_af.schemas.prove", + "members": { + "STATIC_MATCH": 1, + "FLOW_IDENTIFIED": 2, + "REACHABILITY_CONFIRMED": 3, + "SANITIZATION_BYPASSABLE": 4, + "EXPLOIT_SCENARIO_VALIDATED": 5, + "FULL_EXPLOIT": 6 + }, + "kind": "int" + } + }, + "schemas_all": [ + "APIEndpoint", + "ArchitectureMap", + "AttackChain", + "AuditInput", + "AuditMetrics", + "AuditProgress", + "ChainStep", + "ComplianceGate", + "ComplianceGap", + "ComplianceMapping", + "ComplianceSuggestion", + "Confidence", + "ConfigReport", + "CryptoUsage", + "CvssV4Score", + "DataFlow", + "DataFlowEvidence", + "DataFlowMap", + "DataFlowStep", + "DeduplicatedResult", + "Dependency", + "DependencyReport", + "DuplicateCheck", + "EntryPoint", + "EpssScore", + "EvidenceLevel", + "FindingForDedup", + "FindingForReachability", + "FindingForVerifier", + "FindingType", + "HttpEvidence", + "HuntResult", + "HuntStrategy", + "KnownCVE", + "Location", + "MisconfigFinding", + "MitreMapping", + "Module", + "OutdatedDep", + "PotentialChain", + "Proof", + "ProverSignal", + "RawFinding", + "ReachabilityEvidence", + "ReconDataFlowStep", + "ReconResult", + "RelevanceGate", + "ReproductionStep", + "SanitizationAnalysis", + "SanitizationPoint", + "SecretFinding", + "SecurityAuditResult", + "SecurityContext", + "Service", + "Severity", + "SeverityClassification", + "Sink", + "StrategySelection", + "TrustBoundary", + "Verdict", + "VerdictGate", + "VerifiedFinding" + ] +} diff --git a/go/internal/schemas/timestamp.go b/go/internal/schemas/timestamp.go new file mode 100644 index 0000000..d4c9876 --- /dev/null +++ b/go/internal/schemas/timestamp.go @@ -0,0 +1,129 @@ +package schemas + +import ( + "encoding/json" + "fmt" + "math" + "strings" + "time" +) + +// Timestamp is the JSON representation of a pydantic `datetime` field +// (`SecurityAuditResult.timestamp`). +// +// Python parity (docs/DESIGN.md §2, VERIFIED): a reasoner returns +// `model_dump()`, which leaves the field as a real datetime object; the +// AgentField Python SDK then hands the result to FastAPI, whose +// `jsonable_encoder` serialises a datetime with `datetime.isoformat()`. That +// produces +// +// 2026-01-02T03:04:05.123456+00:00 (microseconds present) +// 2026-01-02T03:04:05+00:00 (microseconds exactly zero -> omitted) +// +// i.e. always a numeric UTC offset (never the "Z" shorthand), and a fractional +// part that is either absent or exactly six digits — `isoformat()` never emits +// 1, 3 or 9 digits, and never a `.000000` fraction (a zero microsecond +// component means no fraction at all). +// +// UnmarshalJSON is deliberately more permissive than MarshalJSON: it accepts +// RFC 3339 with or without a fractional part, with "Z" or a numeric offset, and +// a naive (offset-less) timestamp — pydantic accepts all of those, and +// `model_dump(mode="json")` (used by the schema-fixture generator) emits the +// "Z" form. +type Timestamp struct { + time.Time +} + +// NewTimestamp wraps t. +func NewTimestamp(t time.Time) Timestamp { return Timestamp{Time: t} } + +// pythonISOLayoutMicros is `datetime.isoformat()` with microseconds. +const pythonISOLayoutMicros = "2006-01-02T15:04:05.000000-07:00" + +// pythonISOLayoutSeconds is `datetime.isoformat()` when microseconds are zero. +const pythonISOLayoutSeconds = "2006-01-02T15:04:05-07:00" + +// String renders the timestamp exactly as Python's `datetime.isoformat()`. +// +// The guard tests the MICROSECOND component, not the nanosecond one. Go's clock +// has nanosecond resolution while `datetime` has only microsecond resolution +// (`datetime.resolution == 1µs`), so a reading whose sub-second part is 1-999ns +// has a microsecond component of zero: Python prints no fraction, and a +// `t.Nanosecond() == 0` guard would print `.000000`. The window is the first +// microsecond of every second, and the producer is an untruncated +// `time.Now().UTC()` (orch.nowUTC), so it reaches the audit result timestamp, +// the checkpoint `created_at`, the SARIF automationDetails.id and the +// monitoring baseline. +func (t Timestamp) String() string { + truncated := t.Truncate(time.Microsecond) + if truncated.Nanosecond() == 0 { + return truncated.Format(pythonISOLayoutSeconds) + } + // Python parity: isoformat() emits microseconds, so truncate the extra + // nanosecond digits Go carries rather than rounding them. + return truncated.Format(pythonISOLayoutMicros) +} + +// MarshalJSON emits the `datetime.isoformat()` string as a JSON string. +func (t Timestamp) MarshalJSON() ([]byte, error) { + return json.Marshal(t.String()) +} + +// timestampLayouts are tried in order by UnmarshalJSON. time.RFC3339Nano +// handles both "Z" and numeric offsets with or without a fraction; the rest +// cover the naive (offset-less) and space-separated forms pydantic also +// accepts (`datetime(...)` from "2026-01-02 03:04:05+00:00" parses fine). +var timestampLayouts = []string{ + time.RFC3339Nano, + "2006-01-02T15:04:05.999999999", + "2006-01-02T15:04:05", + "2006-01-02 15:04:05.999999999Z07:00", + "2006-01-02 15:04:05Z07:00", + "2006-01-02 15:04:05.999999999", + "2006-01-02 15:04:05", + "2006-01-02", +} + +// UnmarshalJSON accepts everything pydantic accepts for a datetime field: +// +// - a JSON string in any of timestampLayouts (the wire form is always the +// first one), +// - a JSON number, read as a Unix epoch in seconds with a fractional part +// carrying sub-second precision — VERIFIED: pydantic turns 1767322445 into +// 2026-01-02T02:54:05+00:00 and 1767322445.5 into ...:05.500000+00:00, +// - JSON null, which leaves the zero Timestamp (Go-only: the field is +// required in pydantic, but a null must not fail the whole decode). +func (t *Timestamp) UnmarshalJSON(b []byte) error { + trimmed := strings.TrimSpace(string(b)) + if trimmed == "null" { + *t = Timestamp{} + return nil + } + if len(trimmed) > 0 && trimmed[0] != '"' { + var epoch float64 + if err := json.Unmarshal(b, &epoch); err != nil { + return fmt.Errorf("schemas.Timestamp: expected a JSON string or epoch number: %w", err) + } + sec, frac := math.Modf(epoch) + // Python parity: pydantic keeps microsecond precision on a float epoch. + nsec := math.Round(frac*1e6) * 1e3 + *t = Timestamp{Time: time.Unix(int64(sec), int64(nsec)).UTC()} + return nil + } + var s string + if err := json.Unmarshal(b, &s); err != nil { + return fmt.Errorf("schemas.Timestamp: expected a JSON string: %w", err) + } + s = strings.TrimSpace(s) + if s == "" { + *t = Timestamp{} + return nil + } + for _, layout := range timestampLayouts { + if parsed, err := time.Parse(layout, s); err == nil { + *t = Timestamp{Time: parsed} + return nil + } + } + return fmt.Errorf("schemas.Timestamp: cannot parse %q as an ISO-8601 datetime", s) +} diff --git a/go/internal/schemas/timestamp_test.go b/go/internal/schemas/timestamp_test.go new file mode 100644 index 0000000..d132b99 --- /dev/null +++ b/go/internal/schemas/timestamp_test.go @@ -0,0 +1,241 @@ +package schemas + +import ( + "encoding/json" + "testing" + "time" +) + +// Every expectation here was produced by the CPython 3.11 datetime the port +// mirrors, e.g. +// +// datetime(2026,1,2,3,4,5,123456,tzinfo=UTC).isoformat() +// -> '2026-01-02T03:04:05.123456+00:00' +// +// See docs/DESIGN.md §2 (VERIFIED) and timestamp.go. + +func TestTimestampMarshalMatchesPythonIsoformat(t *testing.T) { + cases := []struct { + name string + in time.Time + want string + }{ + { + "microseconds present", + time.Date(2026, 1, 2, 3, 4, 5, 123456000, time.UTC), + `"2026-01-02T03:04:05.123456+00:00"`, + }, + { + // Python parity: isoformat() omits the fraction entirely when + // microseconds are exactly 0 — it never writes ".000000". + "microseconds zero", + time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), + `"2026-01-02T03:04:05+00:00"`, + }, + { + // ...and never trims to fewer than 6 digits when non-zero. + "trailing-zero microseconds keep all six digits", + time.Date(2026, 1, 2, 3, 4, 5, 100000000, time.UTC), + `"2026-01-02T03:04:05.100000+00:00"`, + }, + { + "one microsecond", + time.Date(2026, 1, 2, 3, 4, 5, 1000, time.UTC), + `"2026-01-02T03:04:05.000001+00:00"`, + }, + { + // Python parity: a numeric offset, never "Z". + "non-UTC offset", + time.Date(2026, 1, 2, 3, 4, 5, 123456000, time.FixedZone("", -5*3600)), + `"2026-01-02T03:04:05.123456-05:00"`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := json.Marshal(NewTimestamp(tc.in)) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(got) != tc.want { + t.Errorf("got %s, want %s", got, tc.want) + } + }) + } +} + +func TestTimestampMarshalTruncatesSubMicrosecond(t *testing.T) { + // Go carries nanoseconds; Python's isoformat() only has microseconds, and + // truncates rather than rounds. + ts := NewTimestamp(time.Date(2026, 1, 2, 3, 4, 5, 123456999, time.UTC)) + got, err := json.Marshal(ts) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(got) != `"2026-01-02T03:04:05.123456+00:00"` { + t.Errorf("got %s, want the microsecond-truncated form", got) + } +} + +// TestTimestampSubMicrosecondNanosOmitTheFraction is the 1-999ns window. +// +// Go's time.Now has nanosecond resolution, Python's datetime only microsecond +// resolution (`datetime.resolution == 1µs`), so a reading with 1-999ns has a +// ZERO microsecond component. VERIFIED on the pinned interpreter: +// +// datetime(2026,1,2,3,4,5,0,tzinfo=UTC).isoformat() -> "2026-01-02T03:04:05+00:00" +// +// — no fraction at all. `isoformat()` cannot emit ".000000". +func TestTimestampSubMicrosecondNanosOmitTheFraction(t *testing.T) { + cases := []struct { + nanos int + want string + }{ + {0, "2026-01-02T03:04:05+00:00"}, + {1, "2026-01-02T03:04:05+00:00"}, + {500, "2026-01-02T03:04:05+00:00"}, + {999, "2026-01-02T03:04:05+00:00"}, + {1000, "2026-01-02T03:04:05.000001+00:00"}, + {123456000, "2026-01-02T03:04:05.123456+00:00"}, + } + for _, tc := range cases { + ts := NewTimestamp(time.Date(2026, 1, 2, 3, 4, 5, tc.nanos, time.UTC)) + if got := ts.String(); got != tc.want { + t.Errorf("%dns -> %q, want %q", tc.nanos, got, tc.want) + } + } +} + +func TestTimestampUnmarshalAcceptsBothRepresentations(t *testing.T) { + want := time.Date(2026, 1, 2, 3, 4, 5, 123456000, time.UTC) + // The two representations the port must tolerate: FastAPI's + // jsonable_encoder form (numeric offset) and pydantic's + // model_dump(mode="json") form ("Z"), plus the fraction-free variants. + for _, in := range []string{ + `"2026-01-02T03:04:05.123456+00:00"`, + `"2026-01-02T03:04:05.123456Z"`, + `"2026-01-02T03:04:05.123456"`, + } { + var ts Timestamp + if err := json.Unmarshal([]byte(in), &ts); err != nil { + t.Fatalf("unmarshal %s: %v", in, err) + } + if !ts.UTC().Equal(want) { + t.Errorf("unmarshal %s = %v, want %v", in, ts.Time, want) + } + } + + secondsOnly := time.Date(2026, 3, 4, 10, 30, 0, 0, time.UTC) + for _, in := range []string{ + `"2026-03-04T10:30:00+00:00"`, + `"2026-03-04T10:30:00Z"`, + `"2026-03-04T10:30:00"`, + } { + var ts Timestamp + if err := json.Unmarshal([]byte(in), &ts); err != nil { + t.Fatalf("unmarshal %s: %v", in, err) + } + if !ts.UTC().Equal(secondsOnly) { + t.Errorf("unmarshal %s = %v, want %v", in, ts.Time, secondsOnly) + } + } +} + +func TestTimestampRoundTrip(t *testing.T) { + for _, in := range []string{ + `"2026-01-02T03:04:05.123456+00:00"`, + `"2026-03-04T10:30:00+00:00"`, + `"2026-01-02T03:04:05.123456-05:00"`, + } { + var ts Timestamp + if err := json.Unmarshal([]byte(in), &ts); err != nil { + t.Fatalf("unmarshal %s: %v", in, err) + } + out, err := json.Marshal(ts) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(out) != in { + t.Errorf("round trip %s -> %s", in, out) + } + } + // The "Z" form normalises to the isoformat form on the way out — the port + // always EMITS what Python emits. + var ts Timestamp + if err := json.Unmarshal([]byte(`"2026-01-02T03:04:05.123456Z"`), &ts); err != nil { + t.Fatal(err) + } + if out, _ := json.Marshal(ts); string(out) != `"2026-01-02T03:04:05.123456+00:00"` { + t.Errorf("Z form re-emitted as %s", out) + } +} + +func TestTimestampUnmarshalNullAndErrors(t *testing.T) { + var ts Timestamp + if err := json.Unmarshal([]byte(`null`), &ts); err != nil { + t.Errorf("null: %v", err) + } + if !ts.IsZero() { + t.Errorf("null gave %v, want the zero Timestamp", ts.Time) + } + if err := json.Unmarshal([]byte(`"not a date"`), &ts); err == nil { + t.Error("a garbage string should fail to parse") + } + if err := json.Unmarshal([]byte(`{}`), &ts); err == nil { + t.Error("a JSON object should fail") + } +} + +// TestTimestampUnmarshalEpochNumber matches pydantic, which reads a bare number +// as a Unix epoch. VERIFIED against the live model: +// +// SecurityAuditResult(timestamp=1767322445).timestamp.isoformat() +// -> '2026-01-02T02:54:05+00:00' +// SecurityAuditResult(timestamp=1767322445.5).timestamp.isoformat() +// -> '2026-01-02T02:54:05.500000+00:00' +func TestTimestampUnmarshalEpochNumber(t *testing.T) { + cases := []struct{ in, want string }{ + {`1767322445`, `"2026-01-02T02:54:05+00:00"`}, + {`1767322445.5`, `"2026-01-02T02:54:05.500000+00:00"`}, + } + for _, tc := range cases { + var ts Timestamp + if err := json.Unmarshal([]byte(tc.in), &ts); err != nil { + t.Fatalf("unmarshal %s: %v", tc.in, err) + } + got, err := json.Marshal(ts) + if err != nil { + t.Fatal(err) + } + if string(got) != tc.want { + t.Errorf("epoch %s -> %s, want %s", tc.in, got, tc.want) + } + } +} + +// TestTimestampUnmarshalSpaceSeparated matches pydantic, which accepts +// "2026-01-02 03:04:05+00:00" as well as the "T"-separated form. +func TestTimestampUnmarshalSpaceSeparated(t *testing.T) { + var ts Timestamp + if err := json.Unmarshal([]byte(`"2026-01-02 03:04:05+00:00"`), &ts); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got, _ := json.Marshal(ts); string(got) != `"2026-01-02T03:04:05+00:00"` { + t.Errorf("space-separated re-emitted as %s", got) + } +} + +func TestTimestampInsideSecurityAuditResult(t *testing.T) { + r := NewSecurityAuditResult() + r.Timestamp = NewTimestamp(time.Date(2026, 3, 4, 10, 30, 0, 0, time.UTC)) + b, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + if got["timestamp"] != "2026-03-04T10:30:00+00:00" { + t.Errorf("timestamp = %#v", got["timestamp"]) + } +} diff --git a/go/internal/schemas/uuid.go b/go/internal/schemas/uuid.go new file mode 100644 index 0000000..2058ec0 --- /dev/null +++ b/go/internal/schemas/uuid.go @@ -0,0 +1,40 @@ +package schemas + +import ( + "crypto/rand" + "encoding/hex" + "fmt" +) + +// NewUUID4 returns a random RFC 4122 version-4 UUID in the canonical +// 8-4-4-4-12 lowercase hex form, e.g. "1b4e28ba-2fa1-4d1d-883f-9e0d5a1e0f2b". +// +// Ports Python's `default_factory=lambda: str(uuid4())` (schemas/hunt.py, +// schemas/recon.py, schemas/prove.py). The port takes no new third-party +// dependency (docs/DESIGN.md §0.6), so this builds the value from crypto/rand +// directly: 16 random bytes with the version nibble forced to 4 and the variant +// bits forced to 10xx, exactly as CPython's `uuid.uuid4()` does. +// +// It panics only if the system CSPRNG fails, which crypto/rand documents as +// unrecoverable — the same posture as `uuid.uuid4()`, which has no failure mode +// a caller could act on. +func NewUUID4() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + panic(fmt.Sprintf("schemas.NewUUID4: crypto/rand failed: %v", err)) + } + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // variant 10xx (RFC 4122) + + var out [36]byte + hex.Encode(out[0:8], b[0:4]) + out[8] = '-' + hex.Encode(out[9:13], b[4:6]) + out[13] = '-' + hex.Encode(out[14:18], b[6:8]) + out[18] = '-' + hex.Encode(out[19:23], b[8:10]) + out[23] = '-' + hex.Encode(out[24:36], b[10:16]) + return string(out[:]) +} diff --git a/go/internal/schemas/uuid_test.go b/go/internal/schemas/uuid_test.go new file mode 100644 index 0000000..a1dbeb7 --- /dev/null +++ b/go/internal/schemas/uuid_test.go @@ -0,0 +1,55 @@ +package schemas + +import ( + "regexp" + "testing" +) + +// uuid4Re matches the canonical RFC 4122 v4 form CPython's `str(uuid4())` +// produces: 32 lowercase hex digits in 8-4-4-4-12 groups, version nibble 4, +// variant nibble 8|9|a|b. +var uuid4Re = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + +func TestNewUUID4Format(t *testing.T) { + for i := 0; i < 200; i++ { + got := NewUUID4() + if len(got) != 36 { + t.Fatalf("NewUUID4() = %q, want 36 chars", got) + } + if !uuid4Re.MatchString(got) { + t.Fatalf("NewUUID4() = %q, not an RFC 4122 v4 uuid", got) + } + } +} + +func TestNewUUID4IsUnique(t *testing.T) { + seen := make(map[string]struct{}, 1000) + for i := 0; i < 1000; i++ { + v := NewUUID4() + if _, dup := seen[v]; dup { + t.Fatalf("NewUUID4() repeated %q after %d draws", v, i) + } + seen[v] = struct{}{} + } +} + +func TestConstructorsMintDistinctUUIDs(t *testing.T) { + // Python parity: each default_factory=lambda: str(uuid4()) call is + // independent, so RawFinding gets two DIFFERENT uuids. + rf := NewRawFinding() + if rf.ID == rf.Fingerprint { + t.Errorf("NewRawFinding minted the same uuid twice: %q", rf.ID) + } + for name, got := range map[string]string{ + "RawFinding.ID": rf.ID, + "RawFinding.Fingerprint": rf.Fingerprint, + "PotentialChain.ChainID": NewPotentialChain().ChainID, + "SecretFinding.ID": NewSecretFinding().ID, + "MisconfigFinding.ID": NewMisconfigFinding().ID, + "VerifiedFinding.ID": NewVerifiedFinding().ID, + } { + if !uuid4Re.MatchString(got) { + t.Errorf("%s = %q, want a uuid4", name, got) + } + } +} diff --git a/go/internal/schemas/views.go b/go/internal/schemas/views.go new file mode 100644 index 0000000..82a50b3 --- /dev/null +++ b/go/internal/schemas/views.go @@ -0,0 +1,52 @@ +package schemas + +// This file ports src/sec_af/schemas/views.py — the phase-boundary projections +// that give each consumer only the fields it needs (contextual fidelity). +// The builders live on RawFinding (hunt.go: ForVerifier / ForDedup). + +// FindingForVerifier is what the verifier pipeline needs from a RawFinding. +// +// Ports schemas/views.py FindingForVerifier. Seeded (defaults.go): +// data_flow_summary="" — which is the Go zero value, so the struct needs no +// constructor; it is listed here for completeness. +type FindingForVerifier struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + FilePath string `json:"file_path"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` + CodeSnippet string `json:"code_snippet"` + CweID string `json:"cwe_id"` + FunctionName *string `json:"function_name"` + // DataFlowSummary is the flattened data flow path. + DataFlowSummary string `json:"data_flow_summary"` +} + +// FindingForDedup is what the deduplicator needs. 8 fields. +// +// Ports schemas/views.py FindingForDedup. Every field is required. +type FindingForDedup struct { + ID string `json:"id"` + Fingerprint string `json:"fingerprint"` + Title string `json:"title"` + FilePath string `json:"file_path"` + StartLine int `json:"start_line"` + CweID string `json:"cwe_id"` + FindingType string `json:"finding_type"` + EstimatedSeverity string `json:"estimated_severity"` +} + +// FindingForReachability is what the reachability gate needs. 6 fields. +// +// Ports schemas/views.py FindingForReachability. (Its docstring says "5 +// fields"; the class declares 6 — Python parity: the docstring is stale, the +// field list is authoritative.) Every field is required. +type FindingForReachability struct { + Title string `json:"title"` + Description string `json:"description"` + CweID string `json:"cwe_id"` + FilePath string `json:"file_path"` + StartLine int `json:"start_line"` + Verdict string `json:"verdict"` +} diff --git a/go/internal/scoring/scoring.go b/go/internal/scoring/scoring.go new file mode 100644 index 0000000..de35ea8 --- /dev/null +++ b/go/internal/scoring/scoring.go @@ -0,0 +1,238 @@ +// Package scoring ports src/sec_af/scoring.py — the deterministic +// exploitability scoring engine: the severity/evidence/reachability weight +// tables, the CWE severity floors, and the score → label mapping. +// +// Everything here is pure: no LLM, no I/O. The orchestrator calls +// ComputeExploitabilityScore for every VerifiedFinding in _generate_output and +// ApplyCWESeverityFloor before that. +package scoring + +import ( + "sort" + "strings" + + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// SeverityWeights is the base weight per severity label. +// +// Ports scoring.py SEVERITY_WEIGHTS. Keyed by the ENUM VALUE (Python indexes +// it with `finding.severity.value`), which is what schemas.Severity already is. +var SeverityWeights = map[schemas.Severity]float64{ + schemas.SeverityCritical: 10.0, + schemas.SeverityHigh: 8.0, + schemas.SeverityMedium: 5.0, + schemas.SeverityLow: 3.0, + schemas.SeverityInfo: 1.0, +} + +// EvidenceMultipliers scales the base weight by how strong the proof is. +// +// Ports scoring.py EVIDENCE_MULTIPLIERS (declared there in decreasing order; +// the map has no order, the values are what matter). +var EvidenceMultipliers = map[schemas.EvidenceLevel]float64{ + schemas.EvidenceLevelFullExploit: 1.0, + schemas.EvidenceLevelExploitScenarioValidated: 0.9, + schemas.EvidenceLevelSanitizationBypassable: 0.7, + schemas.EvidenceLevelReachabilityConfirmed: 0.5, + schemas.EvidenceLevelFlowIdentified: 0.3, + schemas.EvidenceLevelStaticMatch: 0.1, +} + +// ReachabilityMultipliers scales the score by how exposed the code path is. +// +// Ports scoring.py REACHABILITY_MULTIPLIERS. +var ReachabilityMultipliers = map[string]float64{ + "externally_reachable": 1.0, + "internally_reachable": 0.7, + "requires_auth": 0.5, + "requires_admin": 0.3, +} + +// reachabilityTagOrder is the order _reachability_multiplier probes the tag +// set. Order matters: a finding tagged both "externally_reachable" and +// "requires_auth" scores as externally reachable. +// +// Ports the tuple literal in scoring.py _reachability_multiplier. +var reachabilityTagOrder = []string{ + "externally_reachable", + "internally_reachable", + "requires_auth", + "requires_admin", +} + +// severityOrder ranks the severity labels so a floor can be compared against a +// current severity. +// +// Ports scoring.py _SEVERITY_ORDER. +var severityOrder = map[string]int{ + "critical": 4, + "high": 3, + "medium": 2, + "low": 1, + "info": 0, +} + +// CWESeverityFloor is the minimum severity for well-known vulnerability +// classes. LLMs consistently underrate injection and RCE, so this is a hard +// floor: CWE-78 can never be reported as "medium". +// +// Ports scoring.py CWE_SEVERITY_FLOOR — byte-for-byte the same 18 entries. +var CWESeverityFloor = map[string]schemas.Severity{ + // Remote Code Execution / Command Injection — always critical + "CWE-78": schemas.SeverityCritical, + "CWE-77": schemas.SeverityCritical, + "CWE-94": schemas.SeverityCritical, + "CWE-95": schemas.SeverityCritical, + "CWE-96": schemas.SeverityCritical, + // SQL Injection — always critical + "CWE-89": schemas.SeverityCritical, + // Deserialization — always critical + "CWE-502": schemas.SeverityCritical, + // SSRF — at least high + "CWE-918": schemas.SeverityHigh, + // Authentication Bypass — at least high + "CWE-287": schemas.SeverityHigh, + "CWE-290": schemas.SeverityHigh, + "CWE-306": schemas.SeverityHigh, + // Hardcoded Credentials — at least high + "CWE-798": schemas.SeverityHigh, + // Path Traversal — at least high + "CWE-22": schemas.SeverityHigh, + // XXE — at least high + "CWE-611": schemas.SeverityHigh, + // XSS — at least medium (already is, but explicit) + "CWE-79": schemas.SeverityMedium, + // Broken Access Control — at least high + "CWE-840": schemas.SeverityHigh, + "CWE-862": schemas.SeverityHigh, + "CWE-863": schemas.SeverityHigh, +} + +// ApplyCWESeverityFloor upgrades a severity when the CWE has a known minimum +// floor, and otherwise returns it unchanged. +// +// Ports scoring.py apply_cwe_severity_floor. Python parity: the comparison is +// `_SEVERITY_ORDER.get(label, 0) > _SEVERITY_ORDER.get(current.value, 0)`, so +// an UNKNOWN current severity ranks as 0 (info) and any floor beats it. +func ApplyCWESeverityFloor(cweID string, current schemas.Severity) schemas.Severity { + floor, ok := CWESeverityFloor[cweID] + if !ok { + return current + } + if severityOrder[string(floor)] > severityOrder[string(current)] { + return floor + } + return current +} + +// reachabilityMultiplier picks the multiplier from the finding's tags. +// +// Ports scoring.py _reachability_multiplier, including its two fallbacks: +// NO tags at all means "assume externally reachable" (1.0), while tags that +// exist but say nothing about reachability mean "requires_auth" (0.5). The +// comment in scoring.py records why: defaulting everything to 0.5 scored +// critical CWEs at 2.5/10 whenever reachability assessment was not wired into +// the DAG path. +// +// Tags are lower-cased before matching (Python builds a set comprehension of +// `tag.lower()`), so a finding tagged "EXTERNALLY_REACHABLE" matches. +func reachabilityMultiplier(finding schemas.VerifiedFinding) float64 { + normalized := make(map[string]struct{}, len(finding.Tags)) + for _, tag := range finding.Tags { + normalized[strings.ToLower(tag)] = struct{}{} + } + for _, key := range reachabilityTagOrder { + if _, ok := normalized[key]; ok { + return ReachabilityMultipliers[key] + } + } + if len(normalized) == 0 { + return ReachabilityMultipliers["externally_reachable"] + } + return ReachabilityMultipliers["requires_auth"] +} + +// ComputeExploitabilityScore is the 0-10 exploitability score for a finding: +// severity weight × evidence multiplier × reachability multiplier × chain +// bonus, clamped to [0, 10] and rounded to 2 decimals. +// +// Ports scoring.py compute_exploitability_score. The chain bonus is 2.0 when +// the finding belongs to an attack chain, else 1.0. +// +// Python parity divergence (deliberate, documented): Python indexes +// SEVERITY_WEIGHTS / EVIDENCE_MULTIPLIERS directly, so an out-of-vocabulary +// severity or evidence level raises KeyError. Pydantic makes that unreachable +// in Python (both fields are enums), but Go's schemas.Severity is a string +// type that a hand-built struct could set to anything. Go treats an unknown +// key as weight 0.0 — the score collapses to 0.0 rather than panicking mid +// audit. +// +// The final `round(..., 2)` goes through pyfmt.Round, which reproduces CPython's +// round-half-to-EVEN on the exact binary value — NOT math.Round(x*100)/100, +// which rounds half away from zero and accumulates the scaling error. +func ComputeExploitabilityScore(finding schemas.VerifiedFinding) float64 { + severityWeight := SeverityWeights[finding.Severity] + evidenceMultiplier := EvidenceMultipliers[finding.EvidenceLevel] + reachability := reachabilityMultiplier(finding) + chainBonus := 1.0 + if finding.ChainID != nil && *finding.ChainID != "" { + chainBonus = 2.0 + } + + score := severityWeight * evidenceMultiplier * reachability * chainBonus + if score < 0.0 { + score = 0.0 + } + if score > 10.0 { + score = 10.0 + } + return pyfmt.Round(score, 2) +} + +// ComputePriorityRank returns the findings sorted by exploitability score, +// highest first, as a NEW slice (Python's `sorted()` copies; the input order is +// untouched). +// +// Ports scoring.py compute_priority_rank. Python's sorted() is stable and +// `reverse=True` preserves the original order among equal keys, so Go uses +// sort.SliceStable — sort.Slice would be free to reorder ties and break +// tests/test_scoring.py::test_compute_priority_rank_is_stable_for_equal_scores. +// The score is computed ONCE per finding (Python's `key=` does the same), +// which also keeps the comparator cheap. +func ComputePriorityRank(findings []schemas.VerifiedFinding) []schemas.VerifiedFinding { + out := make([]schemas.VerifiedFinding, len(findings)) + copy(out, findings) + scores := make([]float64, len(out)) + idx := make([]int, len(out)) + for i := range out { + scores[i] = ComputeExploitabilityScore(out[i]) + idx[i] = i + } + sort.SliceStable(idx, func(a, b int) bool { return scores[idx[a]] > scores[idx[b]] }) + ranked := make([]schemas.VerifiedFinding, len(out)) + for i, j := range idx { + ranked[i] = out[j] + } + return ranked +} + +// AssignSeverityLabel maps an exploitability score onto a severity label. +// +// Ports scoring.py assign_severity_label. Boundaries are inclusive on the +// lower end: >=9 critical, >=7 high, >=4 medium, >=1 low, else info. +func AssignSeverityLabel(score float64) string { + switch { + case score >= 9.0: + return "critical" + case score >= 7.0: + return "high" + case score >= 4.0: + return "medium" + case score >= 1.0: + return "low" + default: + return "info" + } +} diff --git a/go/internal/scoring/scoring_test.go b/go/internal/scoring/scoring_test.go new file mode 100644 index 0000000..f18c964 --- /dev/null +++ b/go/internal/scoring/scoring_test.go @@ -0,0 +1,466 @@ +package scoring + +import ( + "reflect" + "testing" + + "github.com/Agent-Field/sec-af/go/internal/pyfmt" + "github.com/Agent-Field/sec-af/go/internal/schemas" +) + +// This file ports tests/test_scoring.py. Every expected number was produced by +// running the Python functions in the sec-af venv against the same inputs +// (`PYTHONPATH=src ~/.agentfield/packages/sec-af/venv/bin/python`). +// +// Two assertions in tests/test_scoring.py are STALE — they encode the OLD +// "missing reachability tags default to requires_auth (0.5)" behavior that +// scoring.py's own comment says was deliberately changed to +// externally_reachable (1.0): +// +// - test_compute_exploitability_score_defaults_reachability_when_missing +// expects 1.05; the live code returns 2.1. +// - test_reachability_multipliers_and_default_behavior[set()] expects 2.5; +// the live code returns 5.0. +// +// The port follows the CODE (verified against the interpreter), which is what +// the audit pipeline actually computes, and the two tests below carry a note +// where they diverge from the Python file. See the PR's parity notes. + +// makeFinding ports tests/test_scoring.py::make_finding. +// +// Python parity: the fixture passes `tags` as a SET, which pydantic coerces to +// a list in an undefined order. Go takes an ordered slice; the scoring code +// lower-cases into a set, so order never matters. +func makeFinding(severity schemas.Severity, evidenceLevel schemas.EvidenceLevel, tags []string, chainID *string) schemas.VerifiedFinding { + f := schemas.NewVerifiedFinding() + f.Fingerprint = "abc123" + f.Title = "Sample finding" + f.Description = "Sample description" + f.FindingType = schemas.FindingTypeSast + f.CweID = "CWE-89" + f.CweName = "SQL Injection" + if tags != nil { + f.Tags = tags + } + f.Verdict = schemas.VerdictConfirmed + f.EvidenceLevel = evidenceLevel + f.Rationale = "Reasonable rationale" + f.Severity = severity + f.ExploitabilityScore = 0.0 + f.Location = schemas.Location{FilePath: "app.py", StartLine: 10, EndLine: 10} + f.ChainID = chainID + f.SarifRuleID = "sec-af/sast/sql-injection" + f.SarifSecuritySeverity = 0.0 + return f +} + +func strptr(s string) *string { return &s } + +// --------------------------------------------------------------------------- +// test_compute_exploitability_score_respects_severity_weights +// --------------------------------------------------------------------------- + +func TestComputeExploitabilityScoreRespectsSeverityWeights(t *testing.T) { + cases := []struct { + severity schemas.Severity + want float64 + }{ + {schemas.SeverityCritical, 10.0}, + {schemas.SeverityHigh, 8.0}, + {schemas.SeverityMedium, 5.0}, + {schemas.SeverityLow, 3.0}, + {schemas.SeverityInfo, 1.0}, + } + for _, tc := range cases { + t.Run(string(tc.severity), func(t *testing.T) { + f := makeFinding(tc.severity, schemas.EvidenceLevelFullExploit, []string{"externally_reachable"}, nil) + if got := ComputeExploitabilityScore(f); got != tc.want { + t.Errorf("= %v, want %v", got, tc.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// test_compute_exploitability_score_applies_chain_bonus_and_clamps_to_ten +// --------------------------------------------------------------------------- + +func TestComputeExploitabilityScoreAppliesChainBonusAndClampsToTen(t *testing.T) { + f := makeFinding(schemas.SeverityCritical, schemas.EvidenceLevelFullExploit, + []string{"externally_reachable"}, strptr("chain-1")) + // 10 * 1.0 * 1.0 * 2.0 = 20 -> clamped to 10. + if got := ComputeExploitabilityScore(f); got != 10.0 { + t.Errorf("= %v, want 10.0", got) + } + // A chain bonus that does NOT hit the clamp is still visible. + mid := makeFinding(schemas.SeverityMedium, schemas.EvidenceLevelFlowIdentified, + []string{"requires_auth"}, strptr("c1")) + if got := ComputeExploitabilityScore(mid); got != 1.5 { + t.Errorf("chained medium = %v, want 1.5", got) + } + // Python parity: `if finding.chain_id:` — an EMPTY chain_id is falsy, so + // it earns no bonus. + empty := makeFinding(schemas.SeverityMedium, schemas.EvidenceLevelFullExploit, + []string{"externally_reachable"}, strptr("")) + if got := ComputeExploitabilityScore(empty); got != 5.0 { + t.Errorf("empty chain_id = %v, want 5.0 (no bonus)", got) + } +} + +// --------------------------------------------------------------------------- +// test_compute_exploitability_score_uses_partial_flow_and_internal_reachability +// --------------------------------------------------------------------------- + +func TestComputeExploitabilityScoreUsesPartialFlowAndInternalReachability(t *testing.T) { + f := makeFinding(schemas.SeverityMedium, schemas.EvidenceLevelReachabilityConfirmed, + []string{"internally_reachable"}, nil) + if got := ComputeExploitabilityScore(f); got != 1.75 { + t.Errorf("= %v, want 1.75", got) + } +} + +// --------------------------------------------------------------------------- +// test_compute_exploitability_score_uses_requires_admin_and_unverified +// --------------------------------------------------------------------------- + +func TestComputeExploitabilityScoreUsesRequiresAdminAndUnverified(t *testing.T) { + f := makeFinding(schemas.SeverityHigh, schemas.EvidenceLevelStaticMatch, + []string{"requires_admin"}, nil) + // 8.0 * 0.1 * 0.3 = 0.24000000000000002 -> round(,2) -> 0.24 + if got := ComputeExploitabilityScore(f); got != 0.24 { + t.Errorf("= %v, want 0.24", got) + } +} + +// --------------------------------------------------------------------------- +// test_compute_exploitability_score_defaults_reachability_when_missing +// +// STALE IN PYTHON: tests/test_scoring.py asserts 1.05 (the old requires_auth +// default). scoring.py now defaults an EMPTY tag set to externally_reachable +// (1.0), so the value is 3.0 * 0.7 * 1.0 = 2.1. Verified against the +// interpreter. +// --------------------------------------------------------------------------- + +func TestComputeExploitabilityScoreDefaultsReachabilityWhenMissing(t *testing.T) { + f := makeFinding(schemas.SeverityLow, schemas.EvidenceLevelSanitizationBypassable, nil, nil) + if got := ComputeExploitabilityScore(f); got != 2.1 { + t.Errorf("= %v, want 2.1 (empty tags -> externally_reachable)", got) + } +} + +// --------------------------------------------------------------------------- +// test_compute_exploitability_score_is_deterministic +// --------------------------------------------------------------------------- + +func TestComputeExploitabilityScoreIsDeterministic(t *testing.T) { + f := makeFinding(schemas.SeverityHigh, schemas.EvidenceLevelExploitScenarioValidated, + []string{"requires_auth"}, nil) + if ComputeExploitabilityScore(f) != ComputeExploitabilityScore(f) { + t.Error("two calls disagreed") + } +} + +// --------------------------------------------------------------------------- +// test_reachability_multipliers_and_default_behavior +// +// STALE IN PYTHON for the empty-set row: the file asserts 2.5, the live code +// returns 5.0 (see the file header). +// --------------------------------------------------------------------------- + +func TestReachabilityMultipliersAndDefaultBehavior(t *testing.T) { + cases := []struct { + name string + tags []string + want float64 + }{ + // Tags are lower-cased before matching. + {"EXTERNALLY_REACHABLE", []string{"EXTERNALLY_REACHABLE"}, 5.0}, + {"internally_reachable", []string{"internally_reachable"}, 3.5}, + {"requires_auth", []string{"requires_auth"}, 2.5}, + {"requires_admin", []string{"requires_admin"}, 1.5}, + // A tag that says nothing about reachability -> requires_auth (0.5). + {"custom_tag", []string{"custom_tag"}, 2.5}, + // NO tags at all -> externally_reachable (1.0). Python file says 2.5. + {"empty", []string{}, 5.0}, + {"nil", nil, 5.0}, + // Probe order: externally_reachable wins over requires_admin. + {"probe order", []string{"requires_admin", "externally_reachable"}, 5.0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := makeFinding(schemas.SeverityMedium, schemas.EvidenceLevelFullExploit, tc.tags, nil) + if got := ComputeExploitabilityScore(f); got != tc.want { + t.Errorf("= %v, want %v", got, tc.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// test_compute_exploitability_score_lower_bound_is_zeroish_for_low_signal +// --------------------------------------------------------------------------- + +func TestComputeExploitabilityScoreLowerBoundIsZeroishForLowSignal(t *testing.T) { + f := makeFinding(schemas.SeverityInfo, schemas.EvidenceLevelStaticMatch, + []string{"requires_admin"}, nil) + // 1.0 * 0.1 * 0.3 = 0.030000000000000002 -> 0.03 + if got := ComputeExploitabilityScore(f); got != 0.03 { + t.Errorf("= %v, want 0.03", got) + } +} + +// --------------------------------------------------------------------------- +// test_compute_exploitability_score_does_not_depend_on_optional_cvss_or_epss_fields +// --------------------------------------------------------------------------- + +func TestComputeExploitabilityScoreIgnoresOptionalCvssAndEpss(t *testing.T) { + plain := makeFinding(schemas.SeverityHigh, schemas.EvidenceLevelExploitScenarioValidated, + []string{"requires_auth"}, nil) + withOptional := plain + withOptional.CvssV4 = &schemas.CvssV4Score{ + Vector: "CVSS:4.0/AV:N/AC:L/PR:N/UI:N/VC:H/VI:H/VA:H", + BaseScore: 9.3, + Severity: "critical", + Automatable: true, + SubsequentImpact: true, + } + withOptional.Epss = &schemas.EpssScore{Score: 0.81, Percentile: 0.95, Date: "2026-03-04"} + + // 8.0 * 0.9 * 0.5 = 3.6000000000000005 -> 3.6 + if got := ComputeExploitabilityScore(plain); got != 3.6 { + t.Errorf("plain = %v, want 3.6", got) + } + if got := ComputeExploitabilityScore(withOptional); got != 3.6 { + t.Errorf("with cvss/epss = %v, want 3.6", got) + } +} + +// --------------------------------------------------------------------------- +// test_compute_priority_rank_sorts_descending +// --------------------------------------------------------------------------- + +func TestComputePriorityRankSortsDescending(t *testing.T) { + low := makeFinding(schemas.SeverityInfo, schemas.EvidenceLevelStaticMatch, []string{"requires_admin"}, nil) + low.ID = "low" + medium := makeFinding(schemas.SeverityMedium, schemas.EvidenceLevelFlowIdentified, []string{"requires_auth"}, nil) + medium.ID = "medium" + high := makeFinding(schemas.SeverityCritical, schemas.EvidenceLevelFullExploit, []string{"externally_reachable"}, nil) + high.ID = "high" + + input := []schemas.VerifiedFinding{medium, high, low} + ranked := ComputePriorityRank(input) + + gotIDs := []string{ranked[0].ID, ranked[1].ID, ranked[2].ID} + if !reflect.DeepEqual(gotIDs, []string{"high", "medium", "low"}) { + t.Errorf("ranked = %v, want [high medium low]", gotIDs) + } + // Python's sorted() returns a NEW list; the input must be untouched. + inputIDs := []string{input[0].ID, input[1].ID, input[2].ID} + if !reflect.DeepEqual(inputIDs, []string{"medium", "high", "low"}) { + t.Errorf("input was mutated: %v", inputIDs) + } +} + +// --------------------------------------------------------------------------- +// test_compute_priority_rank_is_stable_for_equal_scores +// --------------------------------------------------------------------------- + +func TestComputePriorityRankIsStableForEqualScores(t *testing.T) { + first := makeFinding(schemas.SeverityLow, schemas.EvidenceLevelFlowIdentified, []string{"requires_auth"}, nil) + first.ID = "first" + second := makeFinding(schemas.SeverityLow, schemas.EvidenceLevelFlowIdentified, []string{"requires_auth"}, nil) + second.ID = "second" + + ranked := ComputePriorityRank([]schemas.VerifiedFinding{first, second}) + if ranked[0].ID != "first" || ranked[1].ID != "second" { + t.Errorf("ranked = [%s %s], want [first second] (Python sorted() is stable)", ranked[0].ID, ranked[1].ID) + } + + // A longer run of ties, to catch a non-stable sort that only shows up + // above the insertion-sort cutoff. + var many []schemas.VerifiedFinding + for i := 0; i < 40; i++ { + f := makeFinding(schemas.SeverityLow, schemas.EvidenceLevelFlowIdentified, []string{"requires_auth"}, nil) + f.ID = string(rune('a' + i%26)) + f.Fingerprint = f.ID + "-" + string(rune('0'+i/26)) + many = append(many, f) + } + rankedMany := ComputePriorityRank(many) + for i := range many { + if rankedMany[i].Fingerprint != many[i].Fingerprint { + t.Fatalf("tie at %d reordered: %q != %q", i, rankedMany[i].Fingerprint, many[i].Fingerprint) + } + } + + // An empty input returns an empty (non-nil) slice, not nil. + if got := ComputePriorityRank(nil); got == nil || len(got) != 0 { + t.Errorf("ComputePriorityRank(nil) = %#v, want an empty slice", got) + } +} + +// --------------------------------------------------------------------------- +// test_assign_severity_label +// --------------------------------------------------------------------------- + +func TestAssignSeverityLabel(t *testing.T) { + cases := []struct { + score float64 + label string + }{ + {10.0, "critical"}, + {9.0, "critical"}, + {8.9, "high"}, + {7.0, "high"}, + {6.9, "medium"}, + {4.0, "medium"}, + {3.9, "low"}, + {1.0, "low"}, + {0.9, "info"}, + {0.0, "info"}, + {-1.0, "info"}, + } + for _, tc := range cases { + if got := AssignSeverityLabel(tc.score); got != tc.label { + t.Errorf("AssignSeverityLabel(%v) = %q, want %q", tc.score, got, tc.label) + } + } +} + +// --------------------------------------------------------------------------- +// apply_cwe_severity_floor — not covered by tests/test_scoring.py, so these +// are derived from the function's contract and verified against the +// interpreter. +// --------------------------------------------------------------------------- + +func TestApplyCWESeverityFloor(t *testing.T) { + cases := []struct { + cwe string + current schemas.Severity + want schemas.Severity + }{ + // A floor above the current severity upgrades it. + {"CWE-78", schemas.SeverityMedium, schemas.SeverityCritical}, + {"CWE-918", schemas.SeverityInfo, schemas.SeverityHigh}, + {"CWE-79", schemas.SeverityLow, schemas.SeverityMedium}, + // A floor at or below the current severity leaves it alone. + {"CWE-78", schemas.SeverityCritical, schemas.SeverityCritical}, + {"CWE-79", schemas.SeverityHigh, schemas.SeverityHigh}, + {"CWE-22", schemas.SeverityCritical, schemas.SeverityCritical}, + // A CWE with no floor is untouched. + {"CWE-9999", schemas.SeverityLow, schemas.SeverityLow}, + {"", schemas.SeverityInfo, schemas.SeverityInfo}, + } + for _, tc := range cases { + if got := ApplyCWESeverityFloor(tc.cwe, tc.current); got != tc.want { + t.Errorf("ApplyCWESeverityFloor(%q, %q) = %q, want %q", tc.cwe, tc.current, got, tc.want) + } + } +} + +func TestScoringTablesMatchPython(t *testing.T) { + if len(CWESeverityFloor) != 18 { + t.Errorf("CWE_SEVERITY_FLOOR has %d entries, want 18", len(CWESeverityFloor)) + } + wantCritical := []string{"CWE-78", "CWE-77", "CWE-94", "CWE-95", "CWE-96", "CWE-89", "CWE-502"} + for _, cwe := range wantCritical { + if CWESeverityFloor[cwe] != schemas.SeverityCritical { + t.Errorf("CWE_SEVERITY_FLOOR[%s] = %q, want critical", cwe, CWESeverityFloor[cwe]) + } + } + wantHigh := []string{"CWE-918", "CWE-287", "CWE-290", "CWE-306", "CWE-798", "CWE-22", "CWE-611", "CWE-840", "CWE-862", "CWE-863"} + for _, cwe := range wantHigh { + if CWESeverityFloor[cwe] != schemas.SeverityHigh { + t.Errorf("CWE_SEVERITY_FLOOR[%s] = %q, want high", cwe, CWESeverityFloor[cwe]) + } + } + if CWESeverityFloor["CWE-79"] != schemas.SeverityMedium { + t.Errorf("CWE_SEVERITY_FLOOR[CWE-79] = %q, want medium", CWESeverityFloor["CWE-79"]) + } + + wantWeights := map[schemas.Severity]float64{ + schemas.SeverityCritical: 10.0, schemas.SeverityHigh: 8.0, schemas.SeverityMedium: 5.0, + schemas.SeverityLow: 3.0, schemas.SeverityInfo: 1.0, + } + if !reflect.DeepEqual(SeverityWeights, wantWeights) { + t.Errorf("SEVERITY_WEIGHTS = %v, want %v", SeverityWeights, wantWeights) + } + wantEvidence := map[schemas.EvidenceLevel]float64{ + schemas.EvidenceLevelFullExploit: 1.0, schemas.EvidenceLevelExploitScenarioValidated: 0.9, + schemas.EvidenceLevelSanitizationBypassable: 0.7, schemas.EvidenceLevelReachabilityConfirmed: 0.5, + schemas.EvidenceLevelFlowIdentified: 0.3, schemas.EvidenceLevelStaticMatch: 0.1, + } + if !reflect.DeepEqual(EvidenceMultipliers, wantEvidence) { + t.Errorf("EVIDENCE_MULTIPLIERS = %v, want %v", EvidenceMultipliers, wantEvidence) + } + wantReach := map[string]float64{ + "externally_reachable": 1.0, "internally_reachable": 0.7, + "requires_auth": 0.5, "requires_admin": 0.3, + } + if !reflect.DeepEqual(ReachabilityMultipliers, wantReach) { + t.Errorf("REACHABILITY_MULTIPLIERS = %v, want %v", ReachabilityMultipliers, wantReach) + } +} + +// TestEvidenceMultiplierSweep pins every (severity, evidence) pair against the +// values the Python function returns for an externally-reachable finding. +func TestEvidenceMultiplierSweep(t *testing.T) { + want := map[schemas.EvidenceLevel]float64{ + schemas.EvidenceLevelStaticMatch: 1.0, + schemas.EvidenceLevelFlowIdentified: 3.0, + schemas.EvidenceLevelReachabilityConfirmed: 5.0, + schemas.EvidenceLevelSanitizationBypassable: 7.0, + schemas.EvidenceLevelExploitScenarioValidated: 9.0, + schemas.EvidenceLevelFullExploit: 10.0, + } + for level, expected := range want { + f := makeFinding(schemas.SeverityCritical, level, []string{"externally_reachable"}, nil) + if got := ComputeExploitabilityScore(f); got != expected { + t.Errorf("critical @ %s = %v, want %v", level.Name(), got, expected) + } + } +} + +// TestRoundingMatchesPythonRound pins the rounding scoring relies on +// (pyfmt.Round) against the values Python's round(x, 2) produces, including the +// cases where naive math.Round(x*100)/100 diverges. The scoring package used to +// carry a private copy of this helper; it now delegates to internal/pyfmt, and +// this test guards the values scoring actually depends on. +func TestRoundingMatchesPythonRound(t *testing.T) { + cases := []struct { + in float64 + want float64 + }{ + {0.24000000000000002, 0.24}, + {0.030000000000000002, 0.03}, + {3.6000000000000005, 3.6}, + {2.0999999999999996, 2.1}, + {1.75, 1.75}, + // Exact ties round to even, not away from zero: Python round(0.125, 2) + // is 0.12 and round(0.135, 2) is 0.14 (0.135 is really 0.13500...0028). + {0.125, 0.12}, + {0.135, 0.14}, + {10.0, 10.0}, + {0.0, 0.0}, + } + for _, tc := range cases { + if got := pyfmt.Round(tc.in, 2); got != tc.want { + t.Errorf("pyfmt.Round(%v, 2) = %v, want %v", tc.in, got, tc.want) + } + } +} + +// TestUnknownSeverityOrEvidenceCollapsesToZero documents the ONE deliberate +// divergence from Python: Python raises KeyError (pydantic makes it +// unreachable), Go treats the missing weight as 0. +func TestUnknownSeverityOrEvidenceCollapsesToZero(t *testing.T) { + bad := makeFinding(schemas.Severity("blocker"), schemas.EvidenceLevelFullExploit, + []string{"externally_reachable"}, nil) + if got := ComputeExploitabilityScore(bad); got != 0.0 { + t.Errorf("unknown severity = %v, want 0", got) + } + badLevel := makeFinding(schemas.SeverityCritical, schemas.EvidenceLevel(9), + []string{"externally_reachable"}, nil) + if got := ComputeExploitabilityScore(badLevel); got != 0.0 { + t.Errorf("unknown evidence level = %v, want 0", got) + } +} diff --git a/go/scripts/gen_compliance_table.py b/go/scripts/gen_compliance_table.py new file mode 100644 index 0000000..04fa9b7 --- /dev/null +++ b/go/scripts/gen_compliance_table.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Generate the Go port of src/sec_af/compliance/mapping.py::COMPLIANCE_MAP. + +The table is ~34 CWEs x 5 framework controls of hand-written strings; typing it +out again in Go would be an invitation to a silent transcription error, so it is +mechanically derived from the Python module instead. + +Two artifacts are written, both committed: + + internal/compliance/table_gen.go the Go table (compile-time checked) + internal/compliance/testdata/compliance_map.json + the same table as JSON, which + table_test.go compares the compiled Go + table against - so a hand edit to the + generated Go file fails the build gate. + +Run (from the repo root of the worktree): + + PYTHONPATH=src ~/.agentfield/packages/sec-af/venv/bin/python \ + go/scripts/gen_compliance_table.py +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +GO_ROOT = REPO_ROOT / "go" +sys.path.insert(0, str(REPO_ROOT / "src")) + +from sec_af.compliance.mapping import COMPLIANCE_MAP # noqa: E402 + + +def go_string(value: str) -> str: + """Render value as a Go interpreted string literal. + + Non-ASCII characters are emitted verbatim (Go source is UTF-8 and gofmt + keeps them), so the section sign in the HIPAA control ids stays readable. + """ + out = ['"'] + for ch in value: + if ch == "\\": + out.append("\\\\") + elif ch == '"': + out.append('\\"') + elif ch == "\n": + out.append("\\n") + elif ch == "\t": + out.append("\\t") + elif ch == "\r": + out.append("\\r") + elif ord(ch) < 0x20 or ord(ch) == 0x7F: + out.append(f"\\x{ord(ch):02x}") + else: + out.append(ch) + out.append('"') + return "".join(out) + + +def render_go() -> str: + lines: list[str] = [ + "// Code generated by scripts/gen_compliance_table.py. DO NOT EDIT.", + "", + "package compliance", + "", + 'import "github.com/Agent-Field/sec-af/go/internal/schemas"', + "", + "// ComplianceMap ports the module-level COMPLIANCE_MAP table of", + "// src/sec_af/compliance/mapping.py: the static CWE -> framework-control", + "// mapping every finding is scored against.", + "//", + "// Key order below is the Python dict's literal (insertion) order, and each", + "// value keeps the Python list order, because GetComplianceMappings returns the", + "// per-CWE list verbatim and callers compare it positionally.", + "//", + "// Regenerate with:", + "//", + "//\tPYTHONPATH=src ~/.agentfield/packages/sec-af/venv/bin/python \\", + "//\t go/scripts/gen_compliance_table.py", + "var ComplianceMap = map[string][]schemas.ComplianceMapping{", + ] + for cwe, mappings in COMPLIANCE_MAP.items(): + lines.append(f"\t{go_string(cwe)}: {{") + for mapping in mappings: + lines.append( + "\t\t{" + f"Framework: {go_string(mapping.framework)}, " + f"ControlID: {go_string(mapping.control_id)}, " + f"ControlName: {go_string(mapping.control_name)}" + "}," + ) + lines.append("\t},") + lines.append("}") + lines.append("") + return "\n".join(lines) + + +def render_json() -> str: + payload = { + cwe: [ + { + "framework": mapping.framework, + "control_id": mapping.control_id, + "control_name": mapping.control_name, + } + for mapping in mappings + ] + for cwe, mappings in COMPLIANCE_MAP.items() + } + return json.dumps(payload, indent=2, ensure_ascii=False) + "\n" + + +def main() -> None: + go_path = GO_ROOT / "internal" / "compliance" / "table_gen.go" + json_path = GO_ROOT / "internal" / "compliance" / "testdata" / "compliance_map.json" + json_path.parent.mkdir(parents=True, exist_ok=True) + _ = go_path.write_text(render_go(), encoding="utf-8") + _ = json_path.write_text(render_json(), encoding="utf-8") + print(f"wrote {go_path} ({len(COMPLIANCE_MAP)} CWEs)") + print(f"wrote {json_path}") + + +if __name__ == "__main__": + main() diff --git a/go/scripts/gen_golden.py b/go/scripts/gen_golden.py new file mode 100644 index 0000000..61c7b5b --- /dev/null +++ b/go/scripts/gen_golden.py @@ -0,0 +1,1520 @@ +#!/usr/bin/env python3 +"""Committed golden generator for the SEC-AF Go port. + +Every string this repo's Python code hands to an LLM — or writes into a file a +test compares textually — is produced here by calling the REAL Python function +with a fixed input, and written under the owning Go package's +``testdata/golden/`` directory. The matching Go test renders the same input and +compares byte for byte, so a divergence between the two implementations is a +test failure rather than a silent prompt drift. + +REPRODUCE (from the repo root): + + PYTHONPATH=src ~/.agentfield/packages/sec-af/venv/bin/python go/scripts/gen_golden.py + +Deterministic and idempotent: rerunning overwrites the goldens with identical +bytes unless the Python source changed. + +SECTIONS +-------- +The file is organized one section per owning Go package. Each section declares +its INPUTS as module-level constants (the Go test repeats the same literals) and +writes its outputs into that package's testdata directory. Adding a new prompt +builder means adding its inputs to the relevant section — not inventing a new +generator script. + + * internal/pyfmt — pyfmt.Dumps / DumpsCompact parity against json.dumps + * internal/recontext — src/sec_af/context.py + the hunt hint tables + * internal/gates — src/sec_af/harness.py prompt builders and gate prompts +""" + +from __future__ import annotations + +import hashlib +import json +import os +import sys +from typing import Any + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_GO_ROOT = os.path.dirname(_HERE) +_REPO_ROOT = os.path.dirname(_GO_ROOT) +_SRC = os.path.join(_REPO_ROOT, "src") +if os.path.isdir(_SRC) and _SRC not in sys.path: + sys.path.insert(0, _SRC) + +# IMPORT ORDER IS LOAD-BEARING. `sec_af.agents.hunt.__init__` builds its +# _STRATEGY_RUNNERS table with `_load_hunter`, which swallows ImportError and +# substitutes a no-op `_missing__hunter`. Each hunter module imports +# `sec_af.context`, and `sec_af.context` imports back into +# `sec_af.agents.hunt._framework_hints` — so whichever of the two is imported +# FIRST wins: import `sec_af.agents.hunt` first and every hunter loads for real; +# import `sec_af.context` first and all eleven silently become stubs. The live +# node reaches hunt first (app.py -> orchestrator.py -> `.agents.hunt`), so +# force that order here before anything pulls in sec_af.context. +import sec_af.agents.hunt as _bootstrap_hunt_import_order # noqa: E402,F401 + +from sec_af import context as sec_context # noqa: E402 +from sec_af import harness as sec_harness # noqa: E402 +from sec_af import policies # noqa: E402 +from sec_af.agents.hunt._framework_hints import get_framework_hints # noqa: E402 +from sec_af.agents.hunt._language_hints import get_language_hints # noqa: E402 +from sec_af.schemas import gates, hunt, output, prove, recon # noqa: E402 +from sec_af.schemas.hunt import HuntStrategy # noqa: E402 +from sec_af.schemas.recon import ReconResult # noqa: E402 + + +def _write(path: str, text: str) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + print(f" wrote {os.path.relpath(path, _GO_ROOT)} ({len(text.encode('utf-8'))} bytes)") + + +def _write_json(path: str, value: Any) -> None: + _write(path, json.dumps(value, indent=2, sort_keys=True) + "\n") + + +# =========================================================================== +# internal/pyfmt — json.dumps parity +# =========================================================================== + +_PYFMT_TESTDATA = os.path.join(_GO_ROOT, "internal", "pyfmt", "testdata") +_PYFMT_GOLDEN = os.path.join(_PYFMT_TESTDATA, "golden") + +# Fixture key -> pydantic model. The key is also the Go struct name, so the Go +# test can decode the same sub-object into the same shape. +PYFMT_MODELS: dict[str, Any] = { + "ArchitectureMap": recon.ArchitectureMap, + "DependencyReport": recon.DependencyReport, + "SecurityContext": recon.SecurityContext, + "ConfigReport": recon.ConfigReport, +} + + +def gen_pyfmt() -> None: + print("internal/pyfmt:") + with open(os.path.join(_PYFMT_TESTDATA, "models_fixture.json"), encoding="utf-8") as handle: + fixture = json.load(handle) + + for name, model in PYFMT_MODELS.items(): + dumped = model(**fixture[name]).model_dump() + _write(os.path.join(_PYFMT_GOLDEN, f"dumps_{name}_indent2.txt"), json.dumps(dumped, indent=2)) + _write(os.path.join(_PYFMT_GOLDEN, f"dumps_{name}_compact.txt"), json.dumps(dumped)) + + # 'edge_cases' is a plain JSON document, not a model: it pins the float + # spellings (1.0 / 0.5 / 1e-05 / -0.0 / 1e+16), the ensure_ascii escaping, + # the empty list/dict/None renderings and the sorted-key deviation. + edge = fixture["edge_cases"] + _write(os.path.join(_PYFMT_GOLDEN, "dumps_edge_cases_indent2.txt"), json.dumps(edge, indent=2, sort_keys=True)) + _write(os.path.join(_PYFMT_GOLDEN, "dumps_edge_cases_compact.txt"), json.dumps(edge, sort_keys=True)) + + +# =========================================================================== +# internal/recontext — context.py + the hunt hint tables +# =========================================================================== + +_RECONTEXT_TESTDATA = os.path.join(_GO_ROOT, "internal", "recontext", "testdata") +_RECONTEXT_GOLDEN = os.path.join(_RECONTEXT_TESTDATA, "golden") + +# One golden per builder. The key is the golden's basename; the value is the +# Python function. +RECON_BUILDERS = { + "injection": sec_context.recon_context_for_injection, + "auth": sec_context.recon_context_for_auth, + "crypto": sec_context.recon_context_for_crypto, + "data_exposure": sec_context.recon_context_for_data_exposure, + "config_secrets": sec_context.recon_context_for_config_secrets, + "supply_chain": sec_context.recon_context_for_supply_chain, + "api_security": sec_context.recon_context_for_api_security, + "logic": sec_context.recon_context_for_logic, + "generic": sec_context.recon_context_generic, +} + +# Hint-table input vectors. Each is (case name, argument list). +LANGUAGE_HINT_CASES: list[tuple[str, list[str]]] = [ + ("empty", []), + ("unknown_only", ["Rust", "haskell"]), + ("single", ["Python"]), + ("mixed_case_and_repeat", ["Python", "python", "JavaScript", "Rust", "GO"]), + ("all_known", ["python", "javascript", "typescript", "go", "java", "ruby", "csharp"]), +] + +FRAMEWORK_HINT_CASES: list[tuple[str, list[str]]] = [ + ("empty", []), + ("unknown_only", ["hanami", "phoenix"]), + ("aliases", ["Next", "next.js", "NEXTJS", "Spring Boot", "spring-boot", "ASP.NET Core"]), + ("padded", [" React ", "\tvue\n", "Django"]), + ("all_known", [ + "django", "flask", "fastapi", "express", "nextjs", + "spring", "rails", "aspnet", "react", "vue", "angular", + ]), +] + +# Strategies to emit a full pruned-dict golden for. "unknown_strategy" exercises +# the STRATEGY_CONTEXT_MAP miss that falls through to the full model_dump(). +PRUNE_GOLDEN_STRATEGIES = ["injection", "crypto", "supply_chain", "config_secrets", "unknown_strategy"] + + +def _load_recon_fixture() -> ReconResult: + with open(os.path.join(_RECONTEXT_TESTDATA, "recon_fixture.json"), encoding="utf-8") as handle: + return ReconResult(**json.load(handle)) + + +def gen_recontext() -> None: + print("internal/recontext:") + recon_result = _load_recon_fixture() + + for name, builder in RECON_BUILDERS.items(): + _write(os.path.join(_RECONTEXT_GOLDEN, f"{name}.txt"), builder(recon_result)) + + # The two *_for_context wrappers, applied to the fixture's own language and + # framework lists. + _write( + os.path.join(_RECONTEXT_GOLDEN, "language_hints_for_context.txt"), + sec_context.language_hints_for_context(recon_result), + ) + _write( + os.path.join(_RECONTEXT_GOLDEN, "framework_hints_for_context.txt"), + sec_context.framework_hints_for_context(recon_result), + ) + + for case, languages in LANGUAGE_HINT_CASES: + _write(os.path.join(_RECONTEXT_GOLDEN, f"language_hints_{case}.txt"), get_language_hints(languages)) + for case, frameworks in FRAMEWORK_HINT_CASES: + _write(os.path.join(_RECONTEXT_GOLDEN, f"framework_hints_{case}.txt"), get_framework_hints(frameworks)) + + # get_context_for_strategy dispatch: which builder each HuntStrategy lands + # on, pinned by the SHA-256 of the rendered text so the file stays small. + dispatch = {} + for strategy in HuntStrategy: + text = sec_context.get_context_for_strategy(strategy, recon_result) + dispatch[strategy.value] = hashlib.sha256(text.encode("utf-8")).hexdigest() + _write_json(os.path.join(_RECONTEXT_GOLDEN, "strategy_dispatch.json"), dispatch) + + # prune_recon_for_strategy: the surviving key SET for every strategy value + # plus two misses, and the full rendering for a representative few. + keys = {} + for strategy in [s.value for s in HuntStrategy] + ["unknown_strategy", ""]: + keys[strategy] = sorted(sec_context.prune_recon_for_strategy(recon_result, strategy)) + _write_json(os.path.join(_RECONTEXT_GOLDEN, "prune_keys.json"), keys) + + for strategy in PRUNE_GOLDEN_STRATEGIES: + pruned = sec_context.prune_recon_for_strategy(recon_result, strategy) + # Go renders this map with SORTED top-level keys (a Go map carries no + # insertion order) while the NESTED models keep declaration order, + # because pyfmt.Dumps walks Go structs field by field. Sorting only the + # top level here produces exactly the document the Go side emits, so the + # comparison stays byte-exact where it is meaningful. + top_sorted = {key: pruned[key] for key in sorted(pruned)} + _write(os.path.join(_RECONTEXT_GOLDEN, f"prune_{strategy}.json"), json.dumps(top_sorted, indent=2)) + + +# =========================================================================== +# internal/gates — harness.py prompt builders and AI-gate prompts +# =========================================================================== + +_GATES_GOLDEN = os.path.join(_GO_ROOT, "internal", "gates", "testdata", "golden") + +GATES_CWD = "/tmp/secaf-golden" +GATES_PROMPT = "Analyze the repository for SQL injection.\nCite file:line for every claim. \n\n" + +# (case name, phase argument). The phase is `str | None` in Python; the Go +# signature takes a plain string, because `(phase or "")` maps both to "". +PHASE_CASES: list[tuple[str, Any]] = [ + ("recon", "recon"), + ("hunt", "hunt"), + ("prove", "prove"), + ("none", None), + ("empty", ""), + ("padded_mixed_case", " Recon "), + ("unknown", "unknown-phase"), +] + +# (case name, prompt, cwd) +FILE_WRITE_HINT_CASES: list[tuple[str, str, str]] = [ + ("basic", "Constraints:\n- first\n- second", GATES_CWD), + ("trailing_whitespace", "keep me\t \n\n ", GATES_CWD + "/"), + ("empty_cwd", "no directory", ""), + ("relative_cwd", "relative", "./work/../work"), +] + +# Every model gen_schemas.py emits a fixture for; the Go struct of the same name +# drives both SchemaGuidance's field order and BuildSchemaRetryPrompt's +# properties order, so covering all of them pins that contract repo-wide. +GATES_SCHEMAS: dict[str, Any] = { + "ArchitectureMapRaw": recon.ArchitectureMapRaw, + "DependencyReportRaw": recon.DependencyReportRaw, + "ConfigReportRaw": recon.ConfigReportRaw, + "DataFlowMapRaw": recon.DataFlowMapRaw, + "SecurityContextRaw": recon.SecurityContextRaw, + "ScanLocationsResult": hunt.ScanLocationsResult, + "EnrichedFinding": hunt.EnrichedFinding, + "ChainCorrelationResult": hunt.ChainCorrelationResult, + "DataFlowTrace": prove.DataFlowTrace, + "SanitizationResult": prove.SanitizationResult, + "ExploitHypothesis": prove.ExploitHypothesis, + "ReachabilityProof": prove.ReachabilityProof, + "DastVerificationResult": prove.DastVerificationResult, + "CrossServiceFinding": output.CrossServiceFinding, + "RemediationSuggestion": prove.RemediationSuggestion, + "PolicyEvalResult": policies.PolicyEvalResult, + "VerdictDecision": prove.VerdictDecision, + "CWEExpansion": gates.CWEExpansion, + "SeverityClassification": gates.SeverityClassification, + "DuplicateCheck": gates.DuplicateCheck, + "StrategySelection": gates.StrategySelection, + "ReachabilityGate": gates.ReachabilityGate, + "ComplianceGate": gates.ComplianceGate, +} + +SCHEMA_RETRY_ERROR_DETAIL = "Retry attempt 1/3" + +# AI-gate prompt inputs. AIGateWrapper's four prompt builders are pure string +# assembly around these, so the goldens are produced by re-deriving the same +# f-strings the methods use (they are not separable from the awaited call). +CLASSIFY_SEVERITY_SUMMARY = ( + "SQL injection in app/db/raw.py:42 — request.args['q'] reaches cursor.execute unsanitized." +) +CHECK_DUPLICATE_CANDIDATE = { + "id": "finding-1", + "file_path": "app/db/raw.py", + "start_line": 42, + "cwe_id": "CWE-89", + "confirmed": True, + "score": 9.5, + "notes": None, +} +CHECK_DUPLICATE_EXISTING = { + "id": "finding-0", + "file_path": "app/db/raw.py", + "start_line": 41, + "cwe_id": "CWE-89", + "confirmed": False, + "score": 1.0, + "notes": "seen before", +} +SELECT_STRATEGY_SUMMARY = "General recon summary.\n\nProfile: 3 files, 120 LOC." +SELECT_STRATEGY_CASES: list[tuple[str, str, list[str]]] = [ + ("standard", "standard", ["injection", "auth", "crypto"]), + ("empty_candidates", "quick", []), +] +ASSESS_REACHABILITY_SUMMARY = "Hardcoded AWS key in config/prod.yaml:12, repository is public." + + +def _classify_severity_prompt(finding_summary: str) -> str: + return ( + "Classify severity for this potential security finding. " + "Use only critical/high/medium/low and keep rationale brief.\n\n" + f"{finding_summary}" + ) + + +def _check_duplicate_prompt(candidate: dict, existing: dict) -> str: + return ( + "Decide whether candidate finding is a duplicate of existing finding. " + "Return duplicate decision only.\n\n" + f"Candidate: {candidate}\n" + f"Existing: {existing}" + ) + + +def _select_strategy_prompt(recon_summary: str, depth: str, default_candidates: list[str]) -> str: + return ( + "Select SEC-AF hunt strategies from recon context. Return only selected strategies and rationale.\n" + f"Depth profile: {depth}\n" + f"Default candidates: {default_candidates}\n" + f"Recon summary: {recon_summary}" + ) + + +def _assess_reachability_prompt(finding_summary: str) -> str: + return ( + "Assess the reachability of this security finding. " + "Determine if it is externally_reachable, requires_auth, internal_only, or unreachable. " + "Consider the attack surface, authentication requirements, and network exposure.\n\n" + f"{finding_summary}" + ) + + +def gen_gates() -> None: + print("internal/gates:") + + # PHASE_GUIDANCE, verbatim. + _write_json(os.path.join(_GATES_GOLDEN, "phase_guidance.json"), dict(sec_harness.PHASE_GUIDANCE)) + + for case, phase in PHASE_CASES: + _write( + os.path.join(_GATES_GOLDEN, f"with_phase_guidance_{case}.txt"), + sec_harness._with_phase_guidance(GATES_PROMPT, phase, GATES_CWD), + ) + + for case, prompt, cwd in FILE_WRITE_HINT_CASES: + _write( + os.path.join(_GATES_GOLDEN, f"with_file_write_hint_{case}.txt"), + sec_harness._with_file_write_hint(prompt, cwd), + ) + + for name, model in GATES_SCHEMAS.items(): + _write( + os.path.join(_GATES_GOLDEN, f"schema_guidance_{name}.txt"), + sec_harness._schema_guidance(model), + ) + _write( + os.path.join(_GATES_GOLDEN, f"schema_retry_{name}.txt"), + sec_harness._build_schema_retry_prompt(model, SCHEMA_RETRY_ERROR_DETAIL, GATES_CWD), + ) + + _write( + os.path.join(_GATES_GOLDEN, "ai_gate_classify_severity.txt"), + _classify_severity_prompt(CLASSIFY_SEVERITY_SUMMARY), + ) + _write( + os.path.join(_GATES_GOLDEN, "ai_gate_check_duplicate.txt"), + _check_duplicate_prompt(CHECK_DUPLICATE_CANDIDATE, CHECK_DUPLICATE_EXISTING), + ) + for case, depth, candidates in SELECT_STRATEGY_CASES: + _write( + os.path.join(_GATES_GOLDEN, f"ai_gate_select_strategy_{case}.txt"), + _select_strategy_prompt(SELECT_STRATEGY_SUMMARY, depth, candidates), + ) + _write( + os.path.join(_GATES_GOLDEN, "ai_gate_assess_reachability.txt"), + _assess_reachability_prompt(ASSESS_REACHABILITY_SUMMARY), + ) + + +# --------------------------------------------------------------------------- +# internal/agents/recon (S3) +# --------------------------------------------------------------------------- +# Fixtures for the five RECON mappers, the _parsers helpers, _repo_metrics and +# the end-to-end run_recon / run_fast_recon. Everything is produced by calling +# the REAL functions in sec_af.agents.recon. + +# This section is deliberately SELF-CONTAINED — its own imports, its own path +# computation and its own writer — because several porting agents extend this +# file concurrently and the shared helpers around it have been reshaped more +# than once. Nothing here reads a module-level name defined outside the block. +import asyncio as _s3_asyncio +import json as _s3_json +import os as _s3_os + +# A stable, fixture-controlled repository path. The mappers only interpolate it +# into their CONTEXT block, and it deliberately does NOT exist on disk so +# _repo_metrics reports (0, 0) on any machine. +_S3_FIXTURE_REPO = "/fixtures/demo-repo" +_S3_GOLDEN_DIR = _s3_os.path.join( + _s3_os.path.dirname(_s3_os.path.dirname(_s3_os.path.abspath(__file__))), + "internal", "agents", "recon", "testdata", "golden", +) + + +def _s3_write(name: str, text: str) -> None: + _s3_os.makedirs(_S3_GOLDEN_DIR, exist_ok=True) + path = _s3_os.path.join(_S3_GOLDEN_DIR, name) + with open(path, "w", encoding="utf-8") as handle: + _ = handle.write(text) + print(f" wrote internal/agents/recon/testdata/golden/{name} ({len(text.encode('utf-8'))} bytes)") + + +class _S3Captured(Exception): + """Raised by the fake harness once a mapper's prompt has been recorded.""" + + +class _S3CaptureApp: + """Records the single app.harness(prompt=...) call each mapper makes. + + Aborting with _S3Captured short-circuits the mapper before + extract_harness_result runs; its `finally:` still removes the temp dir. + """ + + def __init__(self) -> None: + self.prompt = None + + async def harness(self, prompt, schema=None, cwd=None, project_dir=None, **kwargs): + self.prompt = prompt + raise _S3Captured() + + +def _s3_capture(make_coro) -> str: + app = _S3CaptureApp() + try: + _s3_asyncio.run(make_coro(app)) + except _S3Captured: + pass + assert app.prompt is not None, "mapper did not call app.harness" + return app.prompt + + +def _s3_emit_text(name: str, text: str) -> None: + _s3_write(name + ".txt", text) + + +def _s3_emit_json(name, obj) -> None: + _s3_write(name + ".json", _s3_json.dumps(obj, indent=2, sort_keys=False) + "\n") + + +def _s3_scrub_ids(obj): + """Replace nondeterministic uuid4 `id` values with a stable placeholder.""" + if isinstance(obj, dict): + return {k: ("" if k == "id" and isinstance(v, str) else _s3_scrub_ids(v)) for k, v in obj.items()} + if isinstance(obj, list): + return [_s3_scrub_ids(v) for v in obj] + return obj + + +def _s3_arch_rich(): + """Every optional populated, plus the characters json.dumps treats specially. + + `<` / `&` are NOT escaped by json.dumps (Go's encoder escapes them unless + SetEscapeHTML(false)), and `e-acute` / `->` ARE escaped as \\uXXXX by the + default ensure_ascii=True (Go never escapes non-ASCII). Both traps covered. + """ + from sec_af.schemas.recon import APIEndpoint, ArchitectureMap, EntryPoint, Module, Service, TrustBoundary + + return ArchitectureMap( + app_type="web_api", + modules=[ + Module(name="auth", path="src/auth/", language="Python", description="Sessions & tokens", dependencies=["db", "cache"]), + Module(name="ui", path="web/", language="TypeScript", description=None, dependencies=[]), + ], + entry_points=[ + EntryPoint(kind="http", identifier="POST /api/login", file_path="src/routes.py", line=42, method="POST", route="/api/login", auth_required=False), + EntryPoint(kind="cli", identifier="migrate", file_path="src/cli.py", line=8, method=None, route=None, auth_required=None), + ], + trust_boundaries=[ + TrustBoundary(name="API Gateway", source_zone="external", target_zone="internal", description="Rate limiting auth — café → app", enforcement=["waf"]), + ], + services=[ + Service(name="PostgreSQL", service_type="database", endpoint="localhost:5432", purpose="primary store", auth_mechanism="password"), + ], + api_surface=[ + APIEndpoint(method="GET", path="/api/users", handler="get_users", file_path="src/api.py", line=15, auth_required=True, rate_limited=False), + ], + ) + + +def _s3_arch_empty(): + """All pydantic defaults — the shape an empty harness run produces.""" + from sec_af.schemas.recon import ArchitectureMap + + return ArchitectureMap() + + +def _s3_raw_fixtures(): + """One deliberately nasty *Raw model per parser. + + Covers: surplus pipes kept in the last field, short rows padded, n/a and + empty optionals, unparseable booleans/ints/floats, a "file:line" with no + positive line, and every security-signal bucket. + """ + from sec_af.schemas.recon import ( + ArchitectureMapRaw, + ConfigReportRaw, + DataFlowMapRaw, + DependencyReportRaw, + SecurityContextRaw, + ) + + return { + "architecture": ArchitectureMapRaw( + app_type="web_api", + modules=[ + "auth | src/auth/ | Python | Authentication and session management", + "ui|web/|TypeScript|", + "orphan", + " a | b | c | d | e ", + ], + entry_points=[ + "http | POST /api/login | src/routes.py:42 | false", + "cli | migrate | src/cli.py | yes", + "event | queue:jobs | src/worker.py:0 | maybe", + ], + trust_boundaries=["API Gateway | external | internal | Rate limiting and auth", "edge|dmz"], + services=[ + "PostgreSQL | database | localhost:5432 | password", + "Stripe | payments | n/a | NONE", + "Redis | cache | | unknown", + ], + api_endpoints=[ + "GET | /api/users | get_users | src/api.py:15 | true | false", + "POST | /api/users | create | src/api.py | 1 | 0", + "PUT | /x | h | a:b:12 | | ", + ], + ), + "data_flow": DataFlowMapRaw( + flows=[ + "request.body | sql.execute | false | src/db.py, src/routes.py", + "argv | os.system | TRUE | ", + "env | log | garbage | a, , b ,", + ], + sanitization_points=[ + "src/valid.py:12 | sanitize | escape | sqli, xss", + "src/valid.py | | strip | ", + ], + sinks=[ + "sql | src/db.py:88 | execute | user-controlled query string", + "exec | src/run.py | | ", + ], + ), + "dependency_report": DependencyReportRaw( + sbom=[ + "django | 3.2.1 | pypi | true | BSD-3-Clause", + "urllib3 | 1.26.5 | pypi | false | n/a", + "left-pad | 1.0.0 | npm | notabool | ", + ], + known_cves=[ + "CVE-2021-1 | django | 3.2.1 | 3.2.13 | 9.8 | true | true", + "CVE-2021-2 | urllib3 | 1.26.5 | none | notafloat | 0 | ", + ], + outdated=[ + "django | 3.2.1 | 5.0.0 | true", + "requests | 2.0 | 2.31 | 0", + ], + ), + "config_report": ConfigReportRaw( + secrets=[ + 'api_key | src/config.py:7 | API_KEY = "sk-live-123" | high | false', + "password | src/settings.py | pw=hunter2 | | ", + ], + misconfigs=[ + "dangerous_config | deploy/prod.yaml:22 | DEBUG | Debug mode enabled in production | Set DEBUG=false", + "cors | deploy/nginx.conf | N/A | Wildcard origin | unknown", + ], + ), + "security_context": SecurityContextRaw( + auth_model="jwt", + auth_details="Bearer token validated by middleware", + crypto_usage=[ + "AES | 256 | GCM | data encryption | false", + "TLSv1.0 | n/a | none | legacy tls terminator | true", + "MD5 | notanint | | | TRUE", + ], + security_signals=[ + "CSRF protection enabled", + "HSTS header present", + "Runs in Docker", + "CSP configured", + "Uses Kubernetes secrets", + "Input validation via pydantic", + ], + ), + } + + +# One canned FLAT harness payload per mapper for the end-to-end run_recon +# fixture. Deliberately exercises the derivations run_recon layers on top of the +# parsers: a duplicate language in a different case, a module with an EMPTY +# language (dropped by the truthiness guard), a repeated security signal +# (collapsed by the set), and one signal for each of the three buckets. +_S3_CANNED_RAW = { + "architecture": { + "app_type": "web_api", + "modules": [ + "auth | src/auth | Python | sessions", + "ui | web | TypeScript | ", + "api | src/api | python | dup-language", + "legacy | old | | no language", + ], + "entry_points": ["http | POST /login | src/routes.py:42 | false"], + "trust_boundaries": ["edge | external | internal | tls"], + "services": ["pg | database | localhost:5432 | password"], + "api_endpoints": ["GET | /users | list | src/api.py:15 | true | false"], + }, + "dependencies": { + "sbom": ["django | 3.2 | pypi | true | BSD"], + "known_cves": ["CVE-1 | django | 3.2 | 3.3 | 9.8 | true | true"], + "outdated": ["django | 3.2 | 5.0 | true"], + }, + "config_scanner": { + "secrets": ["api_key | src/config.py:7 | KEY=1 | high | false"], + "misconfigs": ["debug | deploy/prod.yaml:22 | DEBUG | on in prod | turn it off"], + }, + "data_flow": { + "flows": ["request.body | sql.execute | false | src/db.py"], + "sanitization_points": ["src/valid.py:12 | clean | escape | sqli"], + "sinks": ["sql | src/db.py:88 | execute | tainted"], + }, + "security_context": { + "auth_model": "jwt", + "auth_details": "bearer", + "crypto_usage": ["AES | 256 | GCM | data | false"], + "security_signals": [ + "Uses Flask-Login", + "HSTS header present", + "Runs in Docker", + "Uses Flask-Login", + ], + }, +} + +# The *Raw class each mapper asks for -> its canned key, so the fake answers by +# the model requested rather than by sniffing the prompt. +_S3_SCHEMA_TO_CANNED = { + "ArchitectureMapRaw": "architecture", + "DependencyReportRaw": "dependencies", + "ConfigReportRaw": "config_scanner", + "DataFlowMapRaw": "data_flow", + "SecurityContextRaw": "security_context", +} + + +class _S3CannedResult: + """Shaped like the SDK's HarnessResult so extract_harness_result accepts it.""" + + def __init__(self, parsed) -> None: + self.is_error = False + self.parsed = parsed + self.result = "" + + +class _S3CannedApp: + async def harness(self, prompt=None, *, schema=None, cwd=None, project_dir=None, **kwargs): + key = _S3_SCHEMA_TO_CANNED[schema.__name__] + return _S3CannedResult(schema.model_validate(_S3_CANNED_RAW[key])) + + +def _s3_normalize_recon(dumped: dict) -> dict: + """Scrub the two nondeterministic parts of a ReconResult dump.""" + out = _s3_scrub_ids(dumped) + out["recon_duration_seconds"] = 0.0 + return out + + +# relpath -> raw bytes for the _repo_metrics fixture tree. Chosen to exercise: +# universal-newline counting (\n, \r\n, \r, no trailing terminator, empty file), +# Path.suffix semantics (dotfiles have NO suffix, a trailing dot has none, only +# the LAST extension counts, matching is case-insensitive), non-code files +# (counted in file_count, never in line_count), and every _SKIP_DIRS component. +_S3_REPO_TREE = { + "main.py": b"import os\nprint(1)\n", + "trailing_none.go": b"package main\nfunc main() {}", + "crlf.ts": b"const a = 1;\r\nconst b = 2;\r\n", + "cr_only.rb": b"puts 1\rputs 2\r", + "empty.py": b"", + "just_newline.sql": b"\n", + "invalid_utf8.js": b"var a = '\xff\xfe';\nvar b = 2;\n", + "UPPER.PY": b"a\nb\n", + "archive.tar.gz": b"not really gzip\n", + ".gitignore": b"node_modules\n.venv\n", + "Makefile": b"all:\n\tgo build\n", + "trailingdot.": b"x\n", + "README.md": b"# docs\nnot code\n", + "pkg/lib.go": b"package pkg\n\nfunc F() {}\n", + "pkg/deep/nested/util.rs": b"fn main() {}\n", + "conf/app.yaml": b"a: 1\nb: 2\nc: 3\n", + "conf/app.YML": b"x: 1\n", + ".git/config": b"[core]\n", + "node_modules/left-pad/index.js": b"module.exports = 1;\n", + "vendor/dep/dep.go": b"package dep\n", + ".venv/lib/site.py": b"pass\n", + "venv/lib/site.py": b"pass\n", + "__pycache__/main.cpython-311.pyc": b"\x00\x01", + "src/.hg/store.py": b"pass\n", + "src/.svn/entries.py": b"pass\n", +} + +# relpath -> symlink target (relative to the link's own directory). +_S3_REPO_SYMLINKS = { + "link_to_main.py": "main.py", + "broken_link.py": "does_not_exist.py", + "link_to_pkg": "pkg", +} + + +def _s3_materialize(root: str) -> None: + for rel, data in _S3_REPO_TREE.items(): + path = _s3_os.path.join(root, rel) + _s3_os.makedirs(_s3_os.path.dirname(path), exist_ok=True) + with open(path, "wb") as f: + _ = f.write(data) + for rel, target in _S3_REPO_SYMLINKS.items(): + path = _s3_os.path.join(root, rel) + _s3_os.makedirs(_s3_os.path.dirname(path), exist_ok=True) + _s3_os.symlink(target, path) + + +def s3_emit_goldens() -> None: + """internal/agents/recon: prompts, parser tables, metrics, run_recon.""" + import base64 + import shutil + import tempfile + + from sec_af.agents.recon import _parsers, _repo_metrics, run_fast_recon, run_recon + from sec_af.agents.recon.architecture import architecture_context_block, run_architecture_mapper + from sec_af.agents.recon.config_scanner import run_config_scanner + from sec_af.agents.recon.data_flow import run_data_flow_mapper + from sec_af.agents.recon.dependencies import run_dependency_auditor + from sec_af.agents.recon.security_context import run_security_context_profiler + + repo = _S3_FIXTURE_REPO + + # ---- prompts ----------------------------------------------------------- + _s3_emit_text("architecture_prompt", _s3_capture(lambda app: run_architecture_mapper(app, repo))) + _s3_emit_text("dependencies_prompt", _s3_capture(lambda app: run_dependency_auditor(app, repo))) + _s3_emit_text("config_scanner_prompt", _s3_capture(lambda app: run_config_scanner(app, repo))) + + for case, arch in (("A", _s3_arch_rich()), ("B", _s3_arch_empty())): + _s3_emit_text(f"architecture_context_block_{case}", architecture_context_block(arch)) + _s3_emit_text(f"data_flow_prompt_{case}", _s3_capture(lambda app, a=arch: run_data_flow_mapper(app, repo, a))) + _s3_emit_text(f"security_context_prompt_{case}", _s3_capture(lambda app, a=arch: run_security_context_profiler(app, repo, a))) + + # ---- parser primitives ------------------------------------------------- + split_cases = [ + ("a | b | c | d", 4), + ("a|b", 4), + ("", 4), + (" a | b | c | d | e ", 4), + ("a|b|c|d|e|f|g", 6), + ("only", 1), + ("a|b|c", 1), + ("|||", 4), + (" x ", 2), + ] + bool_cases = ["true", "TRUE", " True ", "yes", "1", "false", "No", "0", "", "maybe", "n/a", " 01 "] + int_cases = ["0", "12", " 42 ", "-7", "+3", "1_0", "abc", "", "3.5", "0x10", " "] + float_cases = ["1.5", " 9.8 ", "0", "-2", "1e3", "abc", "", "inf", "nan", "1_0.5", "+.5"] + file_line_cases = ["src/api.py:15", "src/api.py", "a:b:12", "src/x.py:0", "src/x.py:-3", ":", "", " a.py : 4 ", "C:/x.py:9"] + na_cases = ["", " ", "na", "N/A", "None", "UNKNOWN", "unknown ", "value", "0"] + + _s3_emit_json("parse_primitives", { + "split_pipe": [{"s": s, "expected": n, "want": _parsers._split_pipe(s, n)} for s, n in split_cases], + "parse_bool": [{"s": s, "want": _parsers._parse_bool(s)} for s in bool_cases], + "parse_int": [{"s": s, "want": _parsers._parse_int(s)} for s in int_cases], + "parse_int_default9": [{"s": s, "want": _parsers._parse_int(s, 9)} for s in int_cases], + # Emitted as Python repr() STRINGS, not JSON numbers: float("inf") / + # float("nan") are not representable in JSON and Go's decoder rejects + # the Infinity/NaN literals Python's json module writes for them. The Go + # test formats its own parsed float with pyfmt.FormatFloat (an exact + # port of Python's str(float)) and compares the strings. + "parse_float": [ + {"s": s, "want": (None if _parsers._parse_float(s) is None else repr(_parsers._parse_float(s)))} + for s in float_cases + ], + "parse_file_line": [ + {"s": s, "path": _parsers._parse_file_line(s)[0], "line": _parsers._parse_file_line(s)[1]} + for s in file_line_cases + ], + "is_na": [{"s": s, "want": _parsers._is_na(s)} for s in na_cases], + }) + + # ---- parser outputs ---------------------------------------------------- + raws = _s3_raw_fixtures() + for name, parse in ( + ("architecture", _parsers.parse_architecture_raw), + ("data_flow", _parsers.parse_data_flow_raw), + ("dependency_report", _parsers.parse_dependency_report_raw), + ("config_report", _parsers.parse_config_report_raw), + ("security_context", _parsers.parse_security_context_raw), + ): + raw = raws[name] + _s3_emit_json(f"parse_{name}", { + "input": raw.model_dump(), + "want": _s3_scrub_ids(parse(raw).model_dump()), + }) + + # ---- end-to-end run_recon --------------------------------------------- + # _S3_FIXTURE_REPO does not exist on disk, so _repo_metrics reports (0, 0) + # in both runtimes and the fixture stays machine-independent. + _s3_emit_json("run_recon", { + "repo_path": repo, + "canned": _S3_CANNED_RAW, + "standard": _s3_normalize_recon(_s3_asyncio.run(run_recon(_S3CannedApp(), repo, "standard")).model_dump()), + "quick": _s3_normalize_recon(_s3_asyncio.run(run_recon(_S3CannedApp(), repo, "quick")).model_dump()), + "fast": _s3_normalize_recon(_s3_asyncio.run(run_fast_recon(_S3CannedApp(), repo)).model_dump()), + }) + + # ---- repo metrics ------------------------------------------------------ + root = tempfile.mkdtemp(prefix="secaf-golden-metrics-") + try: + _s3_materialize(root) + lines, files = _repo_metrics(root) + _s3_emit_json("repo_metrics", { + "files": {rel: base64.b64encode(data).decode("ascii") for rel, data in _S3_REPO_TREE.items()}, + "symlinks": _S3_REPO_SYMLINKS, + "lines_of_code": lines, + "file_count": files, + }) + finally: + shutil.rmtree(root, ignore_errors=True) + + +# --------------------------------------------------------------------------- +# internal/agents/hunt (S4) +# --------------------------------------------------------------------------- +# Fixtures for the HUNT phase: the shared scan/enrich prompt builders in +# _scan_enrich.py, the twelve hunter modules, and the run_hunt orchestration in +# src/sec_af/agents/hunt/__init__.py. Everything is produced by calling the REAL +# Python functions. +# +# Like the S3 block above, this section is deliberately SELF-CONTAINED — its own +# imports, its own paths, its own writer — because several porting agents extend +# this file concurrently. +# +# GOLDEN BUDGET. A hunter's scan prompt embeds a full recon context, so a sweep +# over 3 depths x 11 hunters against the rich fixture would be a megabyte of +# testdata. Only the STANDARD sweep uses the rich ReconResult (that is where the +# per-hunter context substitution is proved); quick and thorough reuse an +# all-defaults ReconResult, because the only thing a depth change moves is the +# interpolated depth label and the early-stop value. Enrich prompts are pinned +# by SHA-256 per hunter — their template mechanics get one full-text golden of +# their own, and the recon context they carry is already pinned by the scan +# prompt goldens and by internal/recontext's. +import asyncio as _s4_asyncio +import hashlib as _s4_hashlib +import inspect as _s4_inspect +import json as _s4_json +import os as _s4_os + +# A stable, fixture-controlled repository path. The hunters only interpolate it +# into their CONTEXT block; it deliberately does not exist on disk. +_S4_FIXTURE_REPO = "/fixtures/demo-repo" +_S4_TESTDATA = _s4_os.path.join( + _s4_os.path.dirname(_s4_os.path.dirname(_s4_os.path.abspath(__file__))), + "internal", "agents", "hunt", "testdata", +) +_S4_GOLDEN_DIR = _s4_os.path.join(_S4_TESTDATA, "golden") + +# depth -> which recon fixture the cascade sweep runs against (see GOLDEN BUDGET). +_S4_DEPTH_FIXTURE = {"quick": "small", "standard": "rich", "thorough": "small"} + + +def _s4_write(name: str, text: str) -> None: + _s4_os.makedirs(_S4_GOLDEN_DIR, exist_ok=True) + path = _s4_os.path.join(_S4_GOLDEN_DIR, name) + with open(path, "w", encoding="utf-8") as handle: + _ = handle.write(text) + print(f" wrote internal/agents/hunt/testdata/golden/{name} ({len(text.encode('utf-8'))} bytes)") + + +def _s4_emit_text(name: str, text: str) -> None: + _s4_write(name + ".txt", text) + + +def _s4_emit_json(name, obj) -> None: + _s4_write(name + ".json", _s4_json.dumps(obj, indent=2, sort_keys=False) + "\n") + + +def _s4_sha(text: str) -> str: + return _s4_hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _s4_recon(): + """The shared rich ReconResult fixture (a copy of internal/recontext's).""" + from sec_af.schemas.recon import ReconResult + + with open(_s4_os.path.join(_S4_TESTDATA, "recon_fixture.json"), encoding="utf-8") as handle: + return ReconResult(**_s4_json.load(handle)) + + +def _s4_recon_small(): + """A small but COMPLETE ReconResult. + + Used for the quick/thorough sweeps: every hunter gate must open (crypto + needs crypto_usage, supply_chain needs direct_count > 0, api_security needs + api_surface) or the sweep would capture no prompt at all, while the recon + context stays a few hundred bytes so the goldens stay reviewable. It is + written to testdata/recon_small.json so the Go test binds the identical + input rather than transcribing a literal. + """ + from sec_af.schemas.recon import ( + APIEndpoint, + ArchitectureMap, + ConfigReport, + CryptoUsage, + DataFlow, + DataFlowMap, + DataFlowStep, + Dependency, + DependencyReport, + EntryPoint, + KnownCVE, + MisconfigFinding, + Module, + ReconResult, + SecretFinding, + SecurityContext, + Service, + Sink, + TrustBoundary, + ) + + return ReconResult( + architecture=ArchitectureMap( + app_type="web_api", + modules=[Module(name="api", path="app/api/", language="Python", description="HTTP layer", + dependencies=["db"])], + entry_points=[ + EntryPoint(kind="http", identifier="POST /login", file_path="app/api/auth.py", line=12, + method="POST", route="/login", auth_required=False), + EntryPoint(kind="cli", identifier="seed", file_path="app/cli.py", line=3), + ], + trust_boundaries=[TrustBoundary(name="edge", source_zone="internet", target_zone="app", + description="TLS terminator", enforcement=["waf"])], + services=[Service(name="postgres", service_type="database", endpoint="db:5432", + purpose="primary store", auth_mechanism="password")], + api_surface=[ + APIEndpoint(method="POST", path="/login", handler="login", file_path="app/api/auth.py", + line=12, auth_required=False, rate_limited=False), + APIEndpoint(method="GET", path="/users/{id}", handler="get_user", + file_path="app/api/users.py", line=40, auth_required=True, rate_limited=True), + ], + ), + data_flows=DataFlowMap( + flows=[DataFlow(source="request.json", sink="cursor.execute", sanitized=False, + files=["app/api/users.py"], + path=[DataFlowStep(file_path="app/api/users.py", line=41, component="handler", + operation="read id")])], + sinks=[Sink(sink_type="sql", file_path="app/api/users.py", line=42, function_name="get_user", + exploitability_notes="f-string query")], + ), + dependencies=DependencyReport( + sbom=[Dependency(name="django", version="4.2.1", ecosystem="pypi", direct=True, license="BSD-3")], + known_cves=[KnownCVE(cve_id="CVE-2024-0001", package="django", installed_version="4.2.1", + fixed_version="4.2.11", cvss_v4_score=7.5, epss_score=0.42, direct=True, + reachable=True)], + direct_count=3, + transitive_count=9, + ), + config=ConfigReport( + secrets=[SecretFinding(secret_type="api_key", file_path=".env", line=2, match="AKIA...", + confidence="high", is_test_value=False)], + misconfigs=[MisconfigFinding(category="debug", file_path="settings.py", line=9, key="DEBUG", + value="True", risk="high", remediation="disable in production")], + ), + security_context=SecurityContext( + auth_model="jwt", + auth_details="HS256 access tokens", + crypto_usage=[ + CryptoUsage(algorithm="MD5", usage_context="password hashing", is_weak=True), + CryptoUsage(algorithm="SHA256", usage_context="etag cache key", is_weak=False), + ], + framework_security=["django-csrf"], + security_headers=["Content-Security-Policy"], + deployment_signals=["docker"], + ), + languages=["python"], + frameworks=["django"], + lines_of_code=1234, + file_count=56, + ) + + +def _s4_recon_empty(): + """All-default ReconResult — the shape the QUICK profile's placeholders give.""" + from sec_af.schemas.recon import ( + ArchitectureMap, + ConfigReport, + DataFlowMap, + DependencyReport, + ReconResult, + SecurityContext, + ) + + return ReconResult( + architecture=ArchitectureMap(), + data_flows=DataFlowMap(), + dependencies=DependencyReport(), + config=ConfigReport(), + security_context=SecurityContext(auth_model="session", auth_details="cookie"), + ) + + +def _s4_scrub(obj): + """Replace the uuid4 `id` / `fingerprint` defaults with a stable placeholder.""" + if isinstance(obj, dict): + out = {} + for k, v in obj.items(): + if k in ("id", "fingerprint") and isinstance(v, str): + out[k] = "" + else: + out[k] = _s4_scrub(v) + return out + if isinstance(obj, list): + return [_s4_scrub(v) for v in obj] + return obj + + +class _S4Result: + """Shaped like the SDK's HarnessResult so extract_harness_result accepts it.""" + + def __init__(self, parsed) -> None: + self.is_error = False + self.parsed = parsed + self.result = "" + + +class _S4App: + """Answers app.harness by the schema requested and records every prompt. + + Step 1 (ScanLocationsResult) yields ``locations``; step 2 (EnrichedFinding) + yields ``enriched[i]`` for the i-th enrichment, in call order. + """ + + def __init__(self, locations=None, enriched=None) -> None: + from sec_af.schemas.hunt import ScanLocationsResult + + self._scan_cls = ScanLocationsResult + self.locations = list(locations or []) + self.enriched = list(enriched or []) + self.scan_prompts: list[str] = [] + self.enrich_prompts: list[str] = [] + + async def harness(self, prompt, *, schema=None, cwd=None, project_dir=None, **kwargs): + name = getattr(schema, "__name__", None) + if name == "ScanLocationsResult": + self.scan_prompts.append(prompt) + return _S4Result(self._scan_cls(locations=self.locations)) + if name == "EnrichedFinding": + index = len(self.enrich_prompts) + self.enrich_prompts.append(prompt) + return _S4Result(self.enriched[index % len(self.enriched)]) + raise AssertionError(f"unexpected harness schema {name!r}") + + +def _s4_locations(): + """Two canned VulnLocations: a multi-line snippet and a single-line one.""" + from sec_af.schemas.hunt import VulnLocation + + return [ + VulnLocation( + file_path="app/api/users.py", + start_line=42, + code_snippet='query = f"SELECT * FROM users WHERE id = {user_id}"\ncursor.execute(query)', + pattern_type="sql_injection", + ), + VulnLocation( + file_path="app/utils/hash.py", + start_line=7, + code_snippet="digest = hashlib.md5(password).hexdigest()", + pattern_type="weak_hash", + ), + ] + + +def _s4_enriched(): + """Two canned EnrichedFindings: a well-formed one and a coercion torture case.""" + from sec_af.schemas.hunt import EnrichedFinding + + return [ + EnrichedFinding( + title="SQL injection in user lookup", + description="user_id flows unescaped into an f-string query.", + cwe_id="CWE-89", + severity="HIGH", + confidence="high", + data_flow_summary=" request.args['id'] -> query -> cursor.execute ", + ), + EnrichedFinding( + title="Weak hash for password storage", + description="MD5 used to derive a credential digest.", + cwe_id="CWE-327", + severity="catastrophic", + confidence="certain", + data_flow_summary=" ", + ), + ] + + +def _s4_wrap_runner(strategy_value, runner, sink): + """Wrap a hunter so its prompts are captured WITHOUT disturbing the cascade. + + ``_run_single_hunter`` probes five different call shapes and relies on a + TypeError to move on to the next, so the wrapper must reject exactly the + argument lists the real function rejects. Binding the ORIGINAL signature + first reproduces that: ``Signature.bind`` raises TypeError for precisely the + calls Python would reject at the call site. + """ + sig = _s4_inspect.signature(runner) + + async def inner(*args, **kwargs): + bound = sig.bind(*args, **kwargs) + bound.apply_defaults() + app = _S4App() + bound.arguments["app"] = app + recorded = {} + for key, value in bound.arguments.items(): + if key == "app": + continue + recorded[key] = "" if key in ("recon", "recon_result") else value + try: + return await runner(*bound.args, **bound.kwargs) + finally: + sink[strategy_value] = {"bound": recorded, "scan_prompts": list(app.scan_prompts)} + + return inner + + +def s4_emit_goldens() -> None: + """internal/agents/hunt: scan/enrich prompts, the 12 hunters, run_hunt.""" + from sec_af.agents import hunt as _hunt + from sec_af.agents.hunt import _scan_enrich + from sec_af.agents.hunt import api_security as _api_security + from sec_af.agents.hunt import auth as _auth + from sec_af.agents.hunt import business_logic as _business_logic + from sec_af.agents.hunt import config_secrets as _config_secrets + from sec_af.agents.hunt import crypto as _crypto + from sec_af.agents.hunt import data_exposure as _data_exposure + from sec_af.agents.hunt import dos as _dos + from sec_af.agents.hunt import injection as _injection + from sec_af.agents.hunt import logic as _logic + from sec_af.agents.hunt import ssrf as _ssrf + from sec_af.agents.hunt import supply_chain as _supply_chain + from sec_af.agents.hunt import xss as _xss + from sec_af.schemas.hunt import EnrichedFinding, VulnLocation + from sec_af.schemas.recon import CryptoUsage, DependencyReport, SecurityContext + + print("internal/agents/hunt:") + # Guard the import-order trap documented at the top of this file: a stubbed + # table would silently produce empty goldens. + for _strategy, _runner in _hunt._STRATEGY_RUNNERS.items(): + assert not _runner.__name__.startswith("missing_"), ( + f"sec_af.agents.hunt._STRATEGY_RUNNERS[{_strategy.value}] is a stub " + "- sec_af.context was imported before sec_af.agents.hunt" + ) + + recon = _s4_recon() + empty = _s4_recon_empty() + small = _s4_recon_small() + repo = _S4_FIXTURE_REPO + + with open(_s4_os.path.join(_S4_TESTDATA, "recon_small.json"), "w", encoding="utf-8") as handle: + _ = handle.write(_s4_json.dumps(_s4_scrub(small.model_dump()), indent=2) + "\n") + print(" wrote internal/agents/hunt/testdata/recon_small.json") + + # ---- run_hunt end-to-end: the effective per-hunter call + scan prompt ---- + # Every hunter is reached through _run_single_hunter's TypeError cascade, so + # these prompts (and the `bound` maps) are what the LIVE pipeline produces. + original_runners = dict(_hunt._STRATEGY_RUNNERS) + cascade = {} + try: + for depth, fixture in _S4_DEPTH_FIXTURE.items(): + sink = {} + for strategy, runner in original_runners.items(): + _hunt._STRATEGY_RUNNERS[strategy] = _s4_wrap_runner(strategy.value, runner, sink) + _ = _s4_asyncio.run( + _hunt.run_hunt( + app=_S4App(), + repo_path=repo, + recon_result={"rich": recon, "small": small}[fixture], + depth=depth, + ) + ) + cascade[depth] = {"fixture": fixture, "hunters": {}} + for strategy_value in sorted(sink): + entry = sink[strategy_value] + cascade[depth]["hunters"][strategy_value] = entry["bound"] + assert len(entry["scan_prompts"]) == 1, (depth, strategy_value, len(entry["scan_prompts"])) + _s4_emit_text(f"prompt_{strategy_value}_{depth}", entry["scan_prompts"][0]) + finally: + _hunt._STRATEGY_RUNNERS.clear() + _hunt._STRATEGY_RUNNERS.update(original_runners) + + _s4_emit_json("cascade_binding", cascade) + + # ---- strategy selection ------------------------------------------------- + _s4_emit_json("select_strategies", { + case: [s.value for s in _hunt._select_strategies(_hunt._normalize_depth(case))] + for case in ["quick", "QUICK", " quick", "standard", "thorough", "Thorough", "bogus", ""] + }) + _s4_emit_json("normalize_depth", { + case: _hunt._normalize_depth(case).value + for case in ["quick", "QUICK", "Standard", "thorough", "bogus", "", " quick "] + }) + _s4_emit_json("quick_strategies", [s.value for s in _hunt._QUICK_STRATEGIES]) + _s4_emit_json("strategy_runner_order", [s.value for s in _hunt._STRATEGY_RUNNERS]) + + # ---- scan_locations: the template wrapper ------------------------------- + hunter_prompt = "HUNTER PROMPT BODY\nwith {{braces}} and & café →\n" + app = _S4App(locations=[]) + _ = _s4_asyncio.run(_scan_enrich.scan_locations(app=app, prompt=hunter_prompt, repo_path=repo)) + _s4_emit_json("scan_locations_input", {"hunter_prompt": hunter_prompt}) + _s4_emit_text("scan_locations_prompt", app.scan_prompts[0]) + + # ---- enrich_location: the template wrapper ------------------------------ + location = _s4_locations()[0] + enrich_recon_context = "RECON CONTEXT LINE 1\nRECON CONTEXT LINE 2" + app = _S4App(locations=[], enriched=_s4_enriched()[:1]) + _ = _s4_asyncio.run( + _scan_enrich.enrich_location( + app=app, + location=location, + finding_type="sast", + strategy="injection", + recon_context=enrich_recon_context, + repo_path=repo, + ) + ) + _s4_emit_json("enrich_location_input", { + "location": location.model_dump(), + "finding_type": "sast", + "strategy": "injection", + "recon_context": enrich_recon_context, + }) + _s4_emit_text("enrich_location_prompt", app.enrich_prompts[0]) + + # ---- assemble_finding --------------------------------------------------- + assemble_cases = [ + { + "name": "multiline_snippet_sast", + "location": _s4_locations()[0], + "enriched": _s4_enriched()[0], + "finding_type": "sast", + "strategy": "injection", + }, + { + "name": "single_line_bad_enums", + "location": _s4_locations()[1], + "enriched": _s4_enriched()[1], + "finding_type": "sast", + "strategy": "crypto", + }, + { + "name": "empty_snippet_unknown_type", + "location": VulnLocation(file_path="a.py", start_line=0, code_snippet="", pattern_type=""), + "enriched": EnrichedFinding( + title="t", description="d", cwe_id="", severity="", confidence="", data_flow_summary="x" + ), + "finding_type": "not_a_type", + "strategy": "logic", + }, + { + "name": "trailing_newline_snippet_uppercase_type", + "location": VulnLocation( + file_path="b/c.js", start_line=10, code_snippet="one\ntwo\n", pattern_type="p" + ), + "enriched": EnrichedFinding( + title="t", description="d", cwe_id="CWE-1", severity="INFO", confidence="LOW", + data_flow_summary="\n flow \n", + ), + "finding_type": "SCA", + "strategy": "supply_chain", + }, + ] + _s4_emit_json("assemble_finding", [ + { + "name": case["name"], + "location": case["location"].model_dump(), + "enriched": case["enriched"].model_dump(), + "finding_type": case["finding_type"], + "strategy": case["strategy"], + "want": _s4_scrub( + _scan_enrich.assemble_finding( + location=case["location"], + enriched=case["enriched"], + finding_type=case["finding_type"], + strategy=case["strategy"], + ).model_dump() + ), + } + for case in assemble_cases + ]) + + # ---- per-hunter direct call with two canned locations ------------------- + # Each hunter invoked through its OWN signature (max_files_without_signal at + # its 30 default), which is how src/sec_af/reasoners/hunt.py reaches them. + direct = { + "injection": lambda a: _injection.run_injection_hunter(a, repo, recon, "standard"), + "xss": lambda a: _xss.run_xss_hunter(a, repo, recon, "standard"), + "dos": lambda a: _dos.run_dos_hunter(a, repo, recon, "standard"), + "ssrf": lambda a: _ssrf.run_ssrf_hunter(a, repo, recon, "standard"), + "auth": lambda a: _auth.run_auth_hunter(a, repo, recon, "standard"), + "crypto": lambda a: _crypto.run_crypto_hunter(a, repo, recon), + "business_logic": lambda a: _business_logic.run_business_logic_hunter(a, repo, recon, "standard"), + "logic": lambda a: _logic.run_logic_hunter(a, repo, recon, "standard"), + "data_exposure": lambda a: _data_exposure.run_data_exposure_hunter(a, repo, recon), + "supply_chain": lambda a: _supply_chain.run_supply_chain_hunter(a, repo, recon), + "config_secrets": lambda a: _config_secrets.run_config_secrets_hunter(a, repo, recon), + "api_security": lambda a: _api_security.run_api_security_hunter(a, repo, recon), + } + # The six hunters whose direct-call prompt differs from their cascade prompt. + # The other six take a `depth` parameter, so the cascade reaches them with + # exactly the direct-call arguments and prompt__standard.txt already pins + # the text; the Go test asserts that equality instead of re-storing it. + # `logic` is omitted deliberately: run_logic_hunter forwards verbatim to + # run_business_logic_hunter, so its prompt is byte-identical to + # prompt_business_logic_standard.txt. direct_prompt_sha256.json pins that + # equality without storing a 27 KB duplicate. + direct_text_goldens = { + "crypto", "data_exposure", "supply_chain", "config_secrets", "api_security", + } + results = {} + scan_sha = {} + enrich_sha = {} + for name, make in direct.items(): + app = _S4App(locations=_s4_locations(), enriched=_s4_enriched()) + result = _s4_asyncio.run(make(app)) + results[name] = _s4_scrub(result.model_dump()) + assert len(app.scan_prompts) == 1, name + assert len(app.enrich_prompts) == 2, name + scan_sha[name] = _s4_sha(app.scan_prompts[0]) + enrich_sha[name] = [_s4_sha(text) for text in app.enrich_prompts] + if name in direct_text_goldens: + _s4_emit_text(f"direct_prompt_{name}", app.scan_prompts[0]) + _s4_emit_json("hunter_results", results) + _s4_emit_json("direct_prompt_sha256", {"scan": scan_sha, "enrich": enrich_sha}) + + # ---- guard branches (no harness call at all) ---------------------------- + no_crypto = _s4_recon() + no_crypto.security_context = SecurityContext( + auth_model="jwt", auth_details="x", crypto_usage=[], + framework_security=list(recon.security_context.framework_security), + security_headers=list(recon.security_context.security_headers), + deployment_signals=list(recon.security_context.deployment_signals), + ) + no_deps = _s4_recon() + no_deps.dependencies = DependencyReport(direct_count=0, transitive_count=9) + no_api = _s4_recon() + no_api.architecture.api_surface = [] + + skips = {} + + def _skip(name, coro_factory): + app = _S4App(locations=_s4_locations(), enriched=_s4_enriched()) + result = _s4_asyncio.run(coro_factory(app)) + skips[name] = { + "harness_calls": len(app.scan_prompts) + len(app.enrich_prompts), + "want": _s4_scrub(result.model_dump()), + } + + _skip("crypto_no_usage", lambda a: _crypto.run_crypto_hunter(a, repo, no_crypto)) + _skip("crypto_empty_recon", lambda a: _crypto.run_crypto_hunter(a, repo, empty)) + _skip("supply_chain_no_direct_deps", lambda a: _supply_chain.run_supply_chain_hunter(a, repo, no_deps)) + _skip("api_security_no_surface", lambda a: _api_security.run_api_security_hunter(a, repo, no_api)) + _skip("business_logic_quick", lambda a: _business_logic.run_business_logic_hunter(a, repo, recon, "quick")) + _skip("logic_quick", lambda a: _logic.run_logic_hunter(a, repo, recon, "quick")) + _s4_emit_json("hunter_skips", skips) + + # ---- empty-location early returns (scan ran, no enrichment) ------------- + empties = {} + for name, make in direct.items(): + app = _S4App(locations=[], enriched=_s4_enriched()) + result = _s4_asyncio.run(make(app)) + empties[name] = { + "scan_calls": len(app.scan_prompts), + "enrich_calls": len(app.enrich_prompts), + "want": _s4_scrub(result.model_dump()), + } + _s4_emit_json("hunter_empty_locations", empties) + + # ---- crypto usage-context partitioning --------------------------------- + crypto_cases = {} + for case_name, contexts in { + "mixed": ["password hashing", "file integrity checksum", "etag generation for cache", + "TLS session key derivation", "unrelated purpose", None, ""], + "none": ["unrelated purpose", "widget rendering"], + "both_terms": ["auth token cache"], + }.items(): + usage_recon = _s4_recon_empty() + usage_recon.security_context.crypto_usage = [ + CryptoUsage(algorithm="MD5", usage_context=c, is_weak=True) for c in contexts + ] + usage_contexts = _crypto._usage_contexts(usage_recon) + crypto_cases[case_name] = { + "usage_contexts": usage_contexts, + "security_critical": _crypto._filter_contexts_by_terms( + usage_contexts, _crypto._SECURITY_CRITICAL_TERMS + ), + "non_security": _crypto._filter_contexts_by_terms(usage_contexts, _crypto._NON_SECURITY_TERMS), + "should_run": _crypto.should_run_crypto_hunter(usage_recon), + } + app = _S4App(locations=[], enriched=_s4_enriched()) + _ = _s4_asyncio.run(_crypto.run_crypto_hunter(app, repo, usage_recon)) + _s4_emit_text(f"crypto_prompt_{case_name}", app.scan_prompts[0]) + _s4_emit_json("crypto_usage_partition", crypto_cases) + _s4_emit_json("crypto_term_tables", { + "security_critical": list(_crypto._SECURITY_CRITICAL_TERMS), + "non_security": list(_crypto._NON_SECURITY_TERMS), + }) + + # ---- auth depth labels + business_logic depth_prompt -------------------- + _s4_emit_json("auth_depth_label", { + case: _auth._depth_label(case) + for case in ["quick", "Standard", " THOROUGH ", "bogus", "", " "] + }) + _s4_emit_json("auth_target_cwes", list(_auth._TARGET_CWES)) + + app = _S4App(locations=[], enriched=_s4_enriched()) + _ = _s4_asyncio.run( + _business_logic.run_business_logic_hunter( + app, repo, small, "thorough", 30, + "Use deep, multi-turn analysis. Trace cross-file flows and hunt secondary pivots.", + ) + ) + _s4_emit_text("business_logic_prompt_with_depth_prompt", app.scan_prompts[0]) + + _s4_emit_json("business_logic_enabled", { + case: _business_logic.is_business_logic_hunter_enabled(case) + for case in ["quick", "QUICK", "standard", "thorough", "bogus", ""] + }) + + # ---- the JSON recon-context blocks the four inline hunters build -------- + _s4_emit_text("recon_context_block_dos", _dos._recon_context_block(recon)) + _s4_emit_text("recon_context_block_ssrf", _ssrf._recon_context_block(recon)) + _s4_emit_text("recon_context_block_xss", _xss._recon_context_block(recon)) + _s4_emit_text("recon_context_block_business_logic", _business_logic._recon_context_block(recon)) + _s4_emit_text("recon_context_block_dos_empty", _dos._recon_context_block(empty)) + _s4_emit_text("recon_context_block_business_logic_empty", _business_logic._recon_context_block(empty)) + + +# internal/output's goldens (SARIF, full/summary JSON, Markdown reports) live in +# their own module because they carry four SecurityAuditResult fixtures with +# them; gen_golden_output.py also runs standalone. +from gen_golden_output import gen_output # noqa: E402 + +# internal/agents/prove's goldens (S5) live in their own module for the same +# reason; gen_golden_prove.py also runs standalone. +from gen_golden_prove import gen_prove # noqa: E402 + +# internal/phases + internal/orch goldens (S10) live in their own module because +# they carry the recon/finding fixtures and the clock stubs that pin +# _write_checkpoint and _emit_progress; gen_golden_phases.py also runs +# standalone. +from gen_golden_phases import gen_orch, gen_phases # noqa: E402 + + +# --------------------------------------------------------------------------- +# COVERAGE GAP — read before trusting a run of this script. +# +# Four packages have committed goldens under testdata/golden/ but NO generator +# section in this file (or in the three modules imported above): +# +# internal/monitoring baseline.json, baseline_empty.json +# internal/policies build_prompt*.txt, evaluate_policy_prompt.txt +# internal/agents/dedup chain_correlation_prompt_*.txt, +# duplicate_check_prompt.txt +# internal/agents/remediation generate_prompt*.txt, run_prompt*.txt +# +# Their sections existed while the port was being written and were lost when +# several agents rewrote this file concurrently; the goldens themselves are the +# ones those sections produced from the real Python functions and their tests +# pass, so nothing is WRONG today — but running this script does NOT refresh +# them, and it will not notice if they go stale. +# +# Consequence: if you change a prompt builder in +# src/sec_af/{monitoring,policies}.py or src/sec_af/agents/{dedup,remediation}.py, +# this script's "done" is not evidence that the goldens followed. Re-derive +# those files from the Python functions by hand (each package's test file +# documents the exact fixture it was generated from) or restore the sections. +# --------------------------------------------------------------------------- + + +def main() -> None: + s3_emit_goldens() + s4_emit_goldens() + gen_pyfmt() + gen_recontext() + gen_gates() + gen_output() + gen_prove() + gen_phases() + gen_orch() + print("done") + print( + "NOTE: monitoring, policies, agents/dedup and agents/remediation goldens " + "have no generator here — see the COVERAGE GAP comment above." + ) + + +if __name__ == "__main__": + main() diff --git a/go/scripts/gen_golden_output.py b/go/scripts/gen_golden_output.py new file mode 100644 index 0000000..7191422 --- /dev/null +++ b/go/scripts/gen_golden_output.py @@ -0,0 +1,660 @@ +#!/usr/bin/env python3 +"""Committed golden generator for internal/output (SARIF, JSON, reports). + +Standalone sibling of ``gen_golden.py``: ``gen_golden.py`` calls ``gen_output()`` +from here, and this file also runs on its own. + +REPRODUCE (from the repo root of the worktree): + + PYTHONPATH=src ~/.agentfield/packages/sec-af/venv/bin/python \ + go/scripts/gen_golden_output.py + +Every golden is written by CALLING THE REAL PYTHON FUNCTION — a generator that +re-implemented the formatting would happily agree with a broken port. +Deterministic and idempotent: every input is a fixed literal and the one clock +read (compliance_report's "Generated:" header) is frozen, so rerunning +overwrites the goldens with identical bytes unless src/sec_af/output/ changed. +""" + +from __future__ import annotations + +import json +import os +import sys +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +# Make `sec_af` importable when run from the repo root without an install. +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_SRC = os.path.join(_REPO_ROOT, "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +_GO_ROOT = os.path.join(_REPO_ROOT, "go") + + +def _write(rel_path: str, text: str) -> None: + """Write text under go/ and report it, creating parent directories.""" + dest = Path(_GO_ROOT) / rel_path + dest.parent.mkdir(parents=True, exist_ok=True) + _ = dest.write_text(text, encoding="utf-8") + print(f"wrote {rel_path} ({len(text.encode('utf-8'))} bytes)") + + +# --------------------------------------------------------------------------- +# S8 - internal/output: SARIF, full/summary JSON, Markdown report, compliance +# report. +# +# These four generators are the only place SEC-AF emits bytes a third party +# reads (a SARIF uploader, a PDF converter, a reviewer), so the Go port has to +# match them exactly - down to Python's float repr ("10.0", not "10"), its +# ensure_ascii escaping ("§", not a raw section sign) and its dict +# insertion order. +# +# Rather than hand-authoring a golden per function, this section writes THREE +# result fixtures to internal/output/testdata/*.json and then, for each one, +# the five artifacts Python produces from it. The Go golden test loads the SAME +# fixture file, runs its own generators and diffs the bytes - so the fixture is +# the shared input and neither side re-implements the other. +# +# Fixture keys are dumped with sort_keys=True on purpose: the three dict-typed +# fields of SecurityAuditResult (by_severity, cost_breakdown, metadata) keep +# their file order once parsed, and a Go map cannot carry insertion order, so +# the port emits them sorted. Sorted-in, sorted-out makes the two agree. +# --------------------------------------------------------------------------- + +_S8_TESTDATA = "internal/output/testdata" +_S8_GOLDEN = "internal/output/testdata/golden" + +# The instant compliance_report._render_header would read off the clock. Pinned +# here (and passed explicitly to the Go GenerateComplianceReportAt) so the +# report is reproducible. +_S8_GENERATED_AT = datetime(2026, 5, 6, 7, 8, 9, tzinfo=UTC) + + +class _S8FrozenDatetime: + """Stand-in for the `datetime` name inside compliance_report.""" + + @staticmethod + def now(tz: Any = None) -> Any: + _ = tz + return _S8_GENERATED_AT + + +def _s8_location( + file_path: str, + start_line: int, + end_line: int, + **kw: Any, +) -> Any: + from sec_af.schemas.prove import Location + + return Location(file_path=file_path, start_line=start_line, end_line=end_line, **kw) + + +def _s8_mapping(framework: str, control_id: str, control_name: str) -> Any: + from sec_af.schemas.compliance import ComplianceMapping + + return ComplianceMapping(framework=framework, control_id=control_id, control_name=control_name) + + +def _s8_sample_result() -> Any: + """The tests/conftest.py::sample_security_audit_result fixture. + + Reproduced field for field, with the one change that `tags` is a sorted + LIST instead of a set - a Python set has no stable iteration order, and the + fixture file has to be byte-stable across regenerations. + """ + from sec_af.schemas.compliance import ComplianceGap + from sec_af.schemas.hunt import FindingType, Severity + from sec_af.schemas.output import AttackChain, MitreMapping, SecurityAuditResult + from sec_af.schemas.prove import DataFlowStep, EvidenceLevel, Proof, Verdict, VerifiedFinding + + sql = VerifiedFinding( + id="finding-confirmed", + fingerprint="fp-sql-1", + title="SQL Injection", + description="Unsanitized user input reaches SQL query execution.", + finding_type=FindingType.SAST, + cwe_id="CWE-89", + cwe_name="SQL Injection", + owasp_category="A03:2021", + tags=["externally_reachable", "user-input"], + verdict=Verdict.CONFIRMED, + evidence_level=EvidenceLevel.FULL_EXPLOIT, + rationale="Source-to-sink path is confirmed and exploitable.", + severity=Severity.CRITICAL, + exploitability_score=10.0, + proof=Proof( + exploit_hypothesis="Inject through id parameter.", + verification_method="manual-review+trace", + evidence_level=EvidenceLevel.FULL_EXPLOIT, + data_flow_trace=[ + DataFlowStep(file="src/routes.py", line=15, description="Input source", tainted=True), + DataFlowStep(file="src/users.py", line=42, description="SQL sink", tainted=True), + ], + vulnerable_code='cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")', + exploit_payload='{"id": "1 OR 1=1"}', + expected_outcome="Unauthorized data access", + ), + location=_s8_location( + "src/users.py", + 42, + 42, + start_column=9, + end_column=66, + function_name="lookup_user", + code_snippet='cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")', + ), + related_locations=[ + _s8_location("src/routes.py", 15, 15, code_snippet="user_id = request.json['id']"), + ], + chain_id="chain-1", + chain_step=1, + enables=["finding-likely"], + compliance=[_s8_mapping("PCI-DSS", "Req 6.2.4", "Prevent injection")], + sarif_rule_id="sec-af/sast/sql-injection", + sarif_security_severity=9.9, + ) + likely = VerifiedFinding( + id="finding-likely", + fingerprint="fp-auth-1", + title="Missing Authentication", + description="Admin endpoint can be accessed without auth.", + finding_type=FindingType.API, + cwe_id="CWE-306", + cwe_name="Missing Authentication for Critical Function", + owasp_category="A07:2021", + tags=["requires_auth"], + verdict=Verdict.LIKELY, + evidence_level=EvidenceLevel.FLOW_IDENTIFIED, + rationale="Guard checks appear absent on route.", + severity=Severity.HIGH, + exploitability_score=4.8, + location=_s8_location("src/api/admin.py", 11, 11), + sarif_rule_id="sec-af/api/missing-authentication", + sarif_security_severity=7.6, + ) + not_exploitable = VerifiedFinding( + id="finding-noise", + fingerprint="fp-noise-1", + title="Potential XSS", + description="Output is escaped by template engine.", + finding_type=FindingType.SAST, + cwe_id="CWE-79", + cwe_name="Cross-site Scripting", + verdict=Verdict.NOT_EXPLOITABLE, + evidence_level=EvidenceLevel.STATIC_MATCH, + rationale="Sink auto-escapes output.", + severity=Severity.LOW, + exploitability_score=0.6, + location=_s8_location("src/views.py", 88, 89), + sarif_rule_id="sec-af/sast/xss", + sarif_security_severity=1.9, + ) + return SecurityAuditResult( + repository="Agent-Field/sec-af", + commit_sha="a" * 40, + branch="issue-23-tests", + timestamp=datetime(2026, 3, 4, 10, 30, 0, tzinfo=UTC), + depth_profile="standard", + strategies_used=["injection", "auth"], + provider="opencode", + findings=[sql, likely, not_exploitable], + attack_chains=[ + AttackChain( + chain_id="chain-1", + title="Input to DB read", + description="Untrusted input reaches SQL sink", + findings=["finding-confirmed", "finding-likely"], + combined_severity=Severity.CRITICAL, + combined_impact="Unauthorized DB disclosure", + mitre_attack_mapping=[ + MitreMapping( + tactic="Initial Access", + technique_id="T1190", + technique_name="Exploit Public-Facing Application", + ) + ], + ) + ], + total_raw_findings=6, + confirmed=1, + likely=1, + inconclusive=0, + not_exploitable=1, + noise_reduction_pct=66.7, + by_severity={"critical": 1, "high": 1, "low": 1}, + compliance_gaps=[ + ComplianceGap( + framework="PCI-DSS", + control_id="Req 6.2.4", + control_name="Prevent injection", + finding_count=1, + max_severity="critical", + cwe_ids=["CWE-89"], + ) + ], + duration_seconds=182.4, + agent_invocations=24, + cost_usd=3.21, + cost_breakdown={"hunt": 1.2, "prove": 1.51, "recon": 0.5}, + sarif="{}", + ) + + +def _s8_empty_result() -> Any: + """Every "nothing to report" branch of all four generators at once.""" + from sec_af.schemas.output import SecurityAuditResult + + return SecurityAuditResult( + repository="Agent-Field/empty", + commit_sha="0" * 40, + branch=None, + timestamp=datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC), + depth_profile="quick", + strategies_used=[], + provider="aforge", + findings=[], + attack_chains=[], + total_raw_findings=0, + confirmed=0, + likely=0, + inconclusive=0, + not_exploitable=0, + noise_reduction_pct=0.0, + by_severity={}, + compliance_gaps=[], + duration_seconds=0.0, + agent_invocations=0, + cost_usd=0.0, + cost_breakdown={}, + sarif="", + ) + + +def _s8_edge_result() -> Any: + """The nasty fixture: escaping, rounding and every truthiness branch. + + Packed in deliberately: + + * non-ASCII, quotes, backslashes and angle brackets in strings that reach + both the JSON writers (ensure_ascii) and the Markdown ones (raw); + * a microsecond timestamp (isoformat keeps six digits, model_dump_json + spells the zone "Z"); + * two findings sharing one sarif_rule_id, so the rule aggregates level, + precision and security-severity (9.25 -> "9.2", a half-to-even tie); + * a rule id whose last segment is empty -> _rule_name's "SecAfRule" + fallback, and a lower-case cwe id -> _cwe_number / _base_tags upper-casing; + * a finding with NO compliance mappings -> the "Uncategorized Findings" + section, and one with two mappings in the same framework -> the + seen_frameworks de-duplication; + * chain_step 0 and a chain naming a finding id that does not exist -> + both `chain_step or index` fallbacks; + * an empty branch and an empty code_snippet -> the truthiness (not + None-ness) guards; + * a compliance gap with seven CWEs -> the "(+2 more)" truncation; + * a commit_sha shorter than the eight characters the executive summary + slices. + """ + from sec_af.schemas.compliance import ComplianceGap + from sec_af.schemas.hunt import FindingType, Severity + from sec_af.schemas.output import AttackChain, PolicyViolation, SecurityAuditResult + from sec_af.schemas.prove import DataFlowStep, EvidenceLevel, Proof, Verdict, VerifiedFinding + + shared_a = VerifiedFinding( + id="dup-a", + fingerprint="fp-dup-a", + title='Naïve "quote" & handling', + description="Backslash \\ and tab\tand newline stay in one line.", + finding_type=FindingType.SAST, + cwe_id="CWE-89", + cwe_name="SQL Injection", + owasp_category=None, + tags=["zeta", "alpha"], + verdict=Verdict.LIKELY, + evidence_level=EvidenceLevel.REACHABILITY_CONFIRMED, + rationale="", + severity=Severity.MEDIUM, + exploitability_score=4.5, + location=_s8_location("src/naïve.py", 3, 3, start_column=1, code_snippet=""), + chain_id="chain-x", + chain_step=0, + compliance=[ + _s8_mapping("OWASP", "A03:2021", "Injection"), + _s8_mapping("OWASP", "A01:2021", "Broken Access Control"), + _s8_mapping("PCI-DSS", "Req 6.2.4", "Custom software"), + ], + sarif_rule_id="sec-af/sast/sql-injection", + sarif_security_severity=11.5, + ) + shared_b = VerifiedFinding( + id="dup-b", + fingerprint="fp-dup-b", + title="Second finding on the same rule", + description="Aggregates into the same SARIF rule as dup-a.", + finding_type=FindingType.SAST, + cwe_id="CWE-89", + cwe_name="SQL Injection", + owasp_category="A03:2021", + tags=[], + verdict=Verdict.CONFIRMED, + evidence_level=EvidenceLevel.FULL_EXPLOIT, + rationale="Confirmed by trace.", + severity=Severity.CRITICAL, + exploitability_score=9.25, + proof=Proof( + exploit_hypothesis="h", + verification_method="trace", + evidence_level=EvidenceLevel.FULL_EXPLOIT, + data_flow_trace=[DataFlowStep(file="src/§.py", line=1, description="source → sink", tainted=True)], + ), + location=_s8_location("src/dup.py", 7, 9, end_column=4), + related_locations=[_s8_location("src/other.py", 2, 2)], + compliance=[], + sarif_rule_id="sec-af/sast/sql-injection", + sarif_security_severity=-1.0, + ) + fallback_rule = VerifiedFinding( + id="rule-fallback", + fingerprint="fp-fallback", + title="Rule id with an empty last segment", + description="Exercises the _rule_name fallback.", + finding_type=FindingType.CONFIG, + cwe_id="cwe-79", + cwe_name="Cross-site Scripting", + tags=["b", "a"], + verdict=Verdict.INCONCLUSIVE, + evidence_level=EvidenceLevel.STATIC_MATCH, + rationale="Unclear.", + severity=Severity.INFO, + exploitability_score=0.0, + location=_s8_location("src/views.py", 1, 1), + compliance=[_s8_mapping("ISO27001", "A.8.28", "Secure coding")], + sarif_rule_id="sec-af/", + sarif_security_severity=0.04, + ) + dropped = VerifiedFinding( + id="dropped", + fingerprint="fp-dropped", + title="Filtered out of SARIF", + description="not_exploitable findings never reach the SARIF document.", + finding_type=FindingType.SECRETS, + cwe_id="CWE-798", + cwe_name="Hard-coded Credentials", + verdict=Verdict.NOT_EXPLOITABLE, + evidence_level=EvidenceLevel.STATIC_MATCH, + rationale="False positive.", + severity=Severity.LOW, + exploitability_score=1.0, + location=_s8_location("src/config.py", 5, 5), + sarif_rule_id="sec-af/secrets/hardcoded", + sarif_security_severity=2.0, + ) + + return SecurityAuditResult( + repository="Agent-Field/sec-af—naïve", + commit_sha="abc123", + branch="", + timestamp=datetime(2026, 3, 4, 10, 30, 0, 123456, tzinfo=UTC), + depth_profile="thorough", + strategies_used=["injection", "auth", "xss"], + provider="aforge", + findings=[shared_a, shared_b, fallback_rule, dropped], + attack_chains=[ + AttackChain( + chain_id="chain-x", + title="Chain naming a missing finding", + description="The second id is not in result.findings.", + findings=["dup-a", "not-in-result"], + combined_severity=Severity.HIGH, + combined_impact="Impact