From 2ccdc80627d4fed0b99612f10e16ab093cd329bc Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 20 Aug 2026 02:11:58 -0400 Subject: [PATCH 1/5] feat(go): foundation packages for the Go port The shared substrate every ported package builds on: afx (map<->struct binding, both _unwrap variants incl. the strict phases/orchestrator one, exclude_none), pyfmt (Python round/str/repr/json.dumps parity), appx (the Harness/AI/Note/Call agent seam + recording test fake), harnessx (generic structured-harness runner resolving committed pydantic schema fixtures by Go type name), aix (the .ai(schema=) path with the Python SDK's strictify, salvage and parse-retry semantics), byte-verbatim embedded prompts with a drift test, config (env parity, depth tables, scan config), schemas (every pydantic model with default-seeding UnmarshalJSON, strict enums, pydantic-isoformat timestamps) and scoring (risk model pinned by a 600-row Python-generated matrix). Co-Authored-By: Claude Fable 5 --- go/.gitignore | 5 + go/doc.go | 4 + go/docs/DESIGN.md | 280 + go/go.mod | 29 + go/go.sum | 53 + go/internal/afx/afx_test.go | 485 ++ go/internal/afx/bind.go | 185 + go/internal/afx/bind_numbers_test.go | 153 + go/internal/afx/dropnulls.go | 231 + go/internal/afx/dropnulls_models_test.go | 166 + go/internal/afx/handlerinput.go | 397 ++ go/internal/afx/handlerinput_str_test.go | 84 + go/internal/afx/lax.go | 445 ++ go/internal/afx/lax_test.go | 250 + go/internal/afx/payload.go | 257 + go/internal/afx/payload_dictorder_test.go | 64 + go/internal/afx/required.go | 139 + go/internal/afx/unwrap.go | 240 + go/internal/aix/aix.go | 234 + go/internal/aix/aix_test.go | 523 ++ .../aix/testdata/strict_HuntResult.json | 197 + .../strict_PathInvestigationPlan.json | 52 + .../aix/testdata/strict_VerifiedFinding.json | 613 +++ go/internal/appx/appx.go | 49 + go/internal/appx/fake.go | 207 + go/internal/config/config.go | 513 ++ go/internal/config/config_test.go | 727 +++ go/internal/harnessx/harnessx_test.go | 491 ++ go/internal/harnessx/run.go | 195 + go/internal/harnessx/schema.go | 196 + .../harnessx/testdata/schemas/AttackPath.json | 151 + .../testdata/schemas/DriftReport.json | 113 + .../harnessx/testdata/schemas/HuntResult.json | 176 + .../schemas/PathInvestigationPlan.json | 45 + .../schemas/RemediationSuggestion.json | 93 + .../testdata/schemas/ResourceGraph.json | 25 + .../testdata/schemas/ResourceInventory.json | 38 + .../testdata/schemas/VerifiedFinding.json | 550 ++ go/internal/prompts/drift_test.go | 85 + .../prompts/files/chain/path_constructor.txt | 67 + go/internal/prompts/files/hunt/compliance.txt | 71 + go/internal/prompts/files/hunt/compute.txt | 71 + go/internal/prompts/files/hunt/data.txt | 71 + go/internal/prompts/files/hunt/iam.txt | 71 + go/internal/prompts/files/hunt/logging.txt | 71 + go/internal/prompts/files/hunt/network.txt | 71 + go/internal/prompts/files/hunt/secrets.txt | 71 + .../prompts/files/prove/live_prover.txt | 74 + .../prompts/files/prove/static_prover.txt | 69 + .../prompts/files/recon/cloud_connector.txt | 42 + .../prompts/files/recon/drift_detector.txt | 53 + .../prompts/files/recon/iac_reader.txt | 33 + .../files/recon/resource_graph_builder.txt | 33 + .../prompts/files/remediate/fix_generator.txt | 71 + go/internal/prompts/prompts.go | 82 + go/internal/prompts/prompts_test.go | 134 + go/internal/pyfmt/pyfmt.go | 417 ++ go/internal/pyfmt/pyfmt_test.go | 191 + go/internal/pyfmt/pyjson.go | 552 ++ go/internal/pyfmt/pyjson_hex.go | 24 + go/internal/pyfmt/pyjson_models_test.go | 188 + go/internal/pyfmt/pyjson_test.go | 306 ++ go/internal/pyfmt/pyjson_valuemodel_test.go | 141 + go/internal/pyfmt/pyload.go | 165 + go/internal/pyfmt/pyload_test.go | 149 + go/internal/pyfmt/pyset.go | 128 + go/internal/pyfmt/pyset_test.go | 80 + go/internal/pyfmt/repr_string.go | 150 + go/internal/pyfmt/repr_test.go | 214 + .../golden/dumps_AttackPath_compact.txt | 1 + .../golden/dumps_AttackPath_indent2.txt | 38 + .../golden/dumps_ChainResult_compact.txt | 1 + .../golden/dumps_ChainResult_indent2.txt | 6 + .../golden/dumps_ScanMetrics_compact.txt | 1 + .../golden/dumps_ScanMetrics_indent2.txt | 13 + .../golden/dumps_VerifiedFinding_compact.txt | 1 + .../golden/dumps_VerifiedFinding_indent2.txt | 39 + .../golden/dumps_edge_cases_compact.txt | 1 + .../golden/dumps_edge_cases_indent2.txt | 27 + .../pyfmt/testdata/models_fixture.json | 125 + go/internal/schemas/chain.go | 53 + go/internal/schemas/defaults.go | 449 ++ go/internal/schemas/defaults_test.go | 318 ++ go/internal/schemas/doc.go | 101 + go/internal/schemas/harness_schema_test.go | 173 + go/internal/schemas/hunt.go | 265 + go/internal/schemas/input.go | 78 + go/internal/schemas/model.go | 138 + go/internal/schemas/model_test.go | 108 + go/internal/schemas/output.go | 134 + go/internal/schemas/parity_test.go | 251 + go/internal/schemas/pathplan.go | 36 + go/internal/schemas/prove.go | 183 + go/internal/schemas/recon.go | 153 + go/internal/schemas/required.go | 145 + go/internal/schemas/required_test.go | 198 + go/internal/schemas/schemas_test.go | 593 ++ go/internal/schemas/testdata/model_keys.json | 764 +++ go/internal/schemas/timestamp.go | 100 + go/internal/schemas/timestamp_test.go | 167 + go/internal/schemas/uuid.go | 41 + go/internal/schemas/uuid_test.go | 44 + go/internal/schemas/views.go | 54 + go/internal/scoring/scoring.go | 327 ++ go/internal/scoring/scoring_test.go | 381 ++ .../scoring/testdata/risk_score_matrix.json | 4802 +++++++++++++++++ go/scripts/gen_golden.py | 831 +++ go/scripts/gen_golden_output.py | 665 +++ go/scripts/gen_model_keys.py | 268 + go/scripts/gen_schemas.py | 152 + go/scripts/gen_scoring_matrix.py | 55 + go/scripts/gen_strictify_golden.py | 55 + 112 files changed, 24935 insertions(+) create mode 100644 go/.gitignore create mode 100644 go/doc.go create mode 100644 go/docs/DESIGN.md create mode 100644 go/go.mod create mode 100644 go/go.sum create mode 100644 go/internal/afx/afx_test.go create mode 100644 go/internal/afx/bind.go create mode 100644 go/internal/afx/bind_numbers_test.go create mode 100644 go/internal/afx/dropnulls.go create mode 100644 go/internal/afx/dropnulls_models_test.go create mode 100644 go/internal/afx/handlerinput.go create mode 100644 go/internal/afx/handlerinput_str_test.go create mode 100644 go/internal/afx/lax.go create mode 100644 go/internal/afx/lax_test.go create mode 100644 go/internal/afx/payload.go create mode 100644 go/internal/afx/payload_dictorder_test.go create mode 100644 go/internal/afx/required.go create mode 100644 go/internal/afx/unwrap.go create mode 100644 go/internal/aix/aix.go create mode 100644 go/internal/aix/aix_test.go create mode 100644 go/internal/aix/testdata/strict_HuntResult.json create mode 100644 go/internal/aix/testdata/strict_PathInvestigationPlan.json create mode 100644 go/internal/aix/testdata/strict_VerifiedFinding.json create mode 100644 go/internal/appx/appx.go create mode 100644 go/internal/appx/fake.go create mode 100644 go/internal/config/config.go create mode 100644 go/internal/config/config_test.go create mode 100644 go/internal/harnessx/harnessx_test.go create mode 100644 go/internal/harnessx/run.go create mode 100644 go/internal/harnessx/schema.go create mode 100644 go/internal/harnessx/testdata/schemas/AttackPath.json create mode 100644 go/internal/harnessx/testdata/schemas/DriftReport.json create mode 100644 go/internal/harnessx/testdata/schemas/HuntResult.json create mode 100644 go/internal/harnessx/testdata/schemas/PathInvestigationPlan.json create mode 100644 go/internal/harnessx/testdata/schemas/RemediationSuggestion.json create mode 100644 go/internal/harnessx/testdata/schemas/ResourceGraph.json create mode 100644 go/internal/harnessx/testdata/schemas/ResourceInventory.json create mode 100644 go/internal/harnessx/testdata/schemas/VerifiedFinding.json create mode 100644 go/internal/prompts/drift_test.go create mode 100644 go/internal/prompts/files/chain/path_constructor.txt create mode 100644 go/internal/prompts/files/hunt/compliance.txt create mode 100644 go/internal/prompts/files/hunt/compute.txt create mode 100644 go/internal/prompts/files/hunt/data.txt create mode 100644 go/internal/prompts/files/hunt/iam.txt create mode 100644 go/internal/prompts/files/hunt/logging.txt create mode 100644 go/internal/prompts/files/hunt/network.txt create mode 100644 go/internal/prompts/files/hunt/secrets.txt create mode 100644 go/internal/prompts/files/prove/live_prover.txt create mode 100644 go/internal/prompts/files/prove/static_prover.txt create mode 100644 go/internal/prompts/files/recon/cloud_connector.txt create mode 100644 go/internal/prompts/files/recon/drift_detector.txt create mode 100644 go/internal/prompts/files/recon/iac_reader.txt create mode 100644 go/internal/prompts/files/recon/resource_graph_builder.txt create mode 100644 go/internal/prompts/files/remediate/fix_generator.txt create mode 100644 go/internal/prompts/prompts.go create mode 100644 go/internal/prompts/prompts_test.go create mode 100644 go/internal/pyfmt/pyfmt.go create mode 100644 go/internal/pyfmt/pyfmt_test.go create mode 100644 go/internal/pyfmt/pyjson.go create mode 100644 go/internal/pyfmt/pyjson_hex.go create mode 100644 go/internal/pyfmt/pyjson_models_test.go create mode 100644 go/internal/pyfmt/pyjson_test.go create mode 100644 go/internal/pyfmt/pyjson_valuemodel_test.go create mode 100644 go/internal/pyfmt/pyload.go create mode 100644 go/internal/pyfmt/pyload_test.go create mode 100644 go/internal/pyfmt/pyset.go create mode 100644 go/internal/pyfmt/pyset_test.go create mode 100644 go/internal/pyfmt/repr_string.go create mode 100644 go/internal/pyfmt/repr_test.go create mode 100644 go/internal/pyfmt/testdata/golden/dumps_AttackPath_compact.txt create mode 100644 go/internal/pyfmt/testdata/golden/dumps_AttackPath_indent2.txt create mode 100644 go/internal/pyfmt/testdata/golden/dumps_ChainResult_compact.txt create mode 100644 go/internal/pyfmt/testdata/golden/dumps_ChainResult_indent2.txt create mode 100644 go/internal/pyfmt/testdata/golden/dumps_ScanMetrics_compact.txt create mode 100644 go/internal/pyfmt/testdata/golden/dumps_ScanMetrics_indent2.txt create mode 100644 go/internal/pyfmt/testdata/golden/dumps_VerifiedFinding_compact.txt create mode 100644 go/internal/pyfmt/testdata/golden/dumps_VerifiedFinding_indent2.txt create mode 100644 go/internal/pyfmt/testdata/golden/dumps_edge_cases_compact.txt create mode 100644 go/internal/pyfmt/testdata/golden/dumps_edge_cases_indent2.txt create mode 100644 go/internal/pyfmt/testdata/models_fixture.json create mode 100644 go/internal/schemas/chain.go create mode 100644 go/internal/schemas/defaults.go create mode 100644 go/internal/schemas/defaults_test.go create mode 100644 go/internal/schemas/doc.go create mode 100644 go/internal/schemas/harness_schema_test.go create mode 100644 go/internal/schemas/hunt.go create mode 100644 go/internal/schemas/input.go create mode 100644 go/internal/schemas/model.go create mode 100644 go/internal/schemas/model_test.go create mode 100644 go/internal/schemas/output.go create mode 100644 go/internal/schemas/parity_test.go create mode 100644 go/internal/schemas/pathplan.go create mode 100644 go/internal/schemas/prove.go create mode 100644 go/internal/schemas/recon.go create mode 100644 go/internal/schemas/required.go create mode 100644 go/internal/schemas/required_test.go create mode 100644 go/internal/schemas/schemas_test.go create mode 100644 go/internal/schemas/testdata/model_keys.json create mode 100644 go/internal/schemas/timestamp.go create mode 100644 go/internal/schemas/timestamp_test.go create mode 100644 go/internal/schemas/uuid.go create mode 100644 go/internal/schemas/uuid_test.go create mode 100644 go/internal/schemas/views.go create mode 100644 go/internal/scoring/scoring.go create mode 100644 go/internal/scoring/scoring_test.go create mode 100644 go/internal/scoring/testdata/risk_score_matrix.json create mode 100644 go/scripts/gen_golden.py create mode 100644 go/scripts/gen_golden_output.py create mode 100755 go/scripts/gen_model_keys.py create mode 100644 go/scripts/gen_schemas.py create mode 100755 go/scripts/gen_scoring_matrix.py create mode 100644 go/scripts/gen_strictify_golden.py diff --git a/go/.gitignore b/go/.gitignore new file mode 100644 index 0000000..079c1cf --- /dev/null +++ b/go/.gitignore @@ -0,0 +1,5 @@ +bin/ +go.work +go.work.sum +coverage.out +*.test diff --git a/go/doc.go b/go/doc.go new file mode 100644 index 0000000..3bb6755 --- /dev/null +++ b/go/doc.go @@ -0,0 +1,4 @@ +// Package gomod is the module root of the cloudsecurity-af Go port. The node lives under +// cmd/cloudsecurity-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/docs/DESIGN.md b/go/docs/DESIGN.md new file mode 100644 index 0000000..91a9fb5 --- /dev/null +++ b/go/docs/DESIGN.md @@ -0,0 +1,280 @@ +# CloudSecurity-AF Go port — design contract + +This document is the design contract the Go port under `go/` is written +against: parity rules, package layout, SDK mapping, DAG shape, packaging and the +testing contract. It mirrors the established Agent-Field porting pattern (pr-af +PRs #53/#54/#64, SWE-AF `go/`), adapted to the fact that this node builds its +reasoner DAG with `app.call(f"{NODE_ID}.")` 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/cloudsecurity_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`. (A system + `python3` older than 3.11, or one without the deps, will not do.) +- Go toolchain: go1.25 or newer on PATH; the module targets the Go 1.21 language + level. `GOFLAGS=-mod=mod` is NOT needed; `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 §6). + +## 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`, `github.com/santhosh-tekuri/jsonschema/v5` + (pulled by SDK), and for cloudsecurity-af only `github.com/hashicorp/hcl/v2` + (Terraform parsing, replaces pyhcl2). Ask before adding anything else. + +## 1. Repository layout (`go/` at the repo root) + +``` +go/ +├── agentfield-package.yaml # name = cloudsecurity-af (the product name), 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/cloudsecurity-af/go ; go 1.21 ; sdk/go v0.1.131 +├── cmd/cloudsecurity-af/main.go +├── 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, Config, 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), top-level reasoner handlers (scan + prove), Serve +└── test/functional/ (build tag `functional`) registration parity against a live control plane +``` + +Root additions: `docker-compose.go.yml` (Go node as add-on to the Python +stack, distinct NODE_ID `cloudsecurity-go` and port), root +`agentfield-package.yaml` gains the +`superseded_by: https://github.com/Agent-Field/cloudsecurity-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 | +|---|---|---|---|---| +| `cloudsecurity` | `cloudsecurity` | 8005 | **8015** | `["cloud","security","infrastructure"]` | + +`NODE_ID` and `PORT` env override both (Python parity). `docker-compose.go.yml` +sets `NODE_ID=-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. cloudsecurity's Python `AIConfig(provider=..., model=...)` has no api_base → Go uses the same OpenRouter BaseURL when OPENROUTER_API_KEY is set (it is the only key the nodes document). | +| `@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": "scan execution failed: ..."})` | `&agent.ExecuteError{StatusCode: 500, Message: "scan execution failed: "+err.Error()}` — app.py raises this one WITHOUT a note; do not add one. | +| `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`. + This port additionally has `afx.UnwrapStrict` (the phases.py/orchestrator.py variant that also fails on `error_message`/`status in (failed,error)`) — EVERY ported `app.Call` site uses `UnwrapStrict` — and `afx.DumpExcludeNone`. +- `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` does NOT exist yet (wave-2 owners S7 / C6 add `pyfmt/pyjson.go`). +- `prompts.Load(rel) (string, error)`, `prompts.MustLoad(rel)`, `prompts.Names()`; files under `internal/prompts/files//prompts>`. +- `config`: `ParseDepth`/`NormalizeDepth`, `DepthHunterMap`/`DepthChainLimits`/`DepthProverCaps`, `BudgetConfig`, `ScanConfigFromInput(scanInput any, repoPath) (ScanConfig, error)` (errors on unknown depth → HTTP 400 path), `NewScanConfigFromView`, `AIConfigFromEnv`, `ProviderEnv` (the node must fail boot on a ProviderEnv error, like Python). +- `schemas`: struct per pydantic class, `New()` constructors (these mint uuid4 ids; `UnmarshalJSON` seeds defaults but never mints ids), `Timestamp` (+ `ISOFormat()`), enums as string types with `Parse*`/`Valid` (STRICT: unknown/null → unmarshal error, matching pydantic). `PathInvestigationPlan`/`ChildInvestigation` live in `schemas` (do not redeclare in agents/chain); `schemas` imports `scoring` (Severity/EvidenceMethod/Exposure live in scoring), never the reverse; `CloudSecurityInput.Tier()` method. Every ported model declares `PydanticModel()` in `schemas/model.go`, which is what makes `afx.Bind` apply pydantic's lax scalar coercion and its rejection of a null for a non-Optional field. +- Stale Python tests discovered (port the CODE behavior, note the stale assertion): `tests/test_config.py::test_prover_caps` (quick cap is 20), `tests/test_schemas.py` (imports removed ResourceNode/ResourceEdge/ResourceCluster), `tests/test_graph_context.py` + `tests/test_utils.py::TestPromptTemplatesExist` (stale paths/signatures — `build_graph_context_for_hunter(graph_path, inventory_path, domain_keywords)` reads JSON files and returns `(node_lines, inventory_stats, edge_lines)`). +- Live verification (see §6) drives the node with a mock `opencode` shim that resolves every prompt role; the DAG this node produces is fully deterministic under it. + +## 3. Port map (Python module → Go package) + +| Python | Go package | Notes | +|---|---|---| +| `app.py` | `internal/node` (+ `cmd/cloudsecurity-af`) | two top-level reasoners `scan` and `prove` building `CloudSecurityInput` (scan: `cloud=None`; prove: `CloudConfig(provider, regions default ["us-east-1"], assume_role_arn)`), defaults exclude_paths `["tests/",".git/","examples/",".terraform/"]`; `_workspaces_root()` (`SEC_AF_WORKSPACES_DIR`, `/workspaces` writability probe, `~/.sec-af/workspaces` fallback), `_resolve_repo` (`CLOUDSECURITY_REPO_PATH` fallback), `_run_pipeline` → `ScanOrchestrator.run()`, error mapping `{"error": ...}` 400/500 (`"scan execution failed: "` prefix, no note in Python here — do not add one). Python callback_url default `http://host.docker.internal:8020` — Go: `AGENT_CALLBACK_URL` → PublicURL, else SDK default; comment the difference. | +| `orchestrator.py` | `internal/orch` | `ScanOrchestrator.run()` is THE DAG driver: 5 sequential `app.call`s (`recon_phase`, `hunt_phase`, `chain_phase`, `prove_phase`, `remediation_phase`) with exactly these kwargs, checkpoints, `_emit_progress` (ScanProgress built but NOT emitted as a note in Python — keep it a no-op builder, comment), `agent_invocations = total_selected + len(strategies_run) + 5`, `_generate_output` (threshold filter, `apply_benchmark_severity_floor(first compliance mapping)`, `compute_risk_score(..., Exposure.VPC_INTERNAL, has_attack_path, has_drift)`, counts, drift/shadow-it counts, `CloudSecurityScanResult`, `generate_sarif`). `_PhaseHarnessProxy` is defined but unused by `run()` — port minimal. | +| `reasoners/*.py` + `phases.py` | `internal/reasoners`, `internal/phases` | Register these router reasoners (tags `cloud, security, infrastructure`): `run_iac_reader, run_resource_graph_builder, run_cloud_connector, run_drift_detector, run_iam_hunter, run_network_hunter, run_data_hunter, run_secrets_hunter, run_compute_hunter, run_logging_hunter, run_compliance_hunter, run_path_constructor, run_static_prover, run_live_prover, run_fix_generator, recon_phase, hunt_phase, chain_phase, prove_phase, remediation_phase` (20) + top-level `scan`, `prove` (22 total). `recon_phase`: iac_reader → graph_builder (sequential), then if `tier>=2 && cloud_config != nil` gather(cloud_connector, drift_detector); providers from inventory.json. `hunt_phase`: `DEPTH_HUNTER_MAP` hunters, semaphore, incremental fingerprint dedup then `_cross_hunter_dedup` (first-seen order of the dict; keep highest severity). `chain_phase`: one call to `run_path_constructor` with `max_paths = DEPTH_CHAIN_LIMITS`. `prove_phase`: prioritize by severity, cap `DEPTH_PROVER_CAPS`, semaphore, `run_static_prover` (tier<2) or `run_live_prover`, `attack_path` kwarg only when the finding is in a path; `_fallback_verified` with `drop_reason="prover_error"`; `exclude_none=True` outputs. `remediation_phase` → `run_fix_generator`. `_unwrap` here ALSO treats `error_message`/`status in (failed,error)` as failures — port this stricter variant. | +| `config.py` | `internal/config` | env names `CLOUDSECURITY_PROVIDER`/`HARNESS_PROVIDER` (aforge), `CLOUDSECURITY_MODEL`/`HARNESS_MODEL` (`openrouter/minimax/minimax-m2.5`), `CLOUDSECURITY_AI_MODEL`/`AI_MODEL`/`CLOUDSECURITY_MODEL`, `CLOUDSECURITY_MAX_TURNS` 50, `CLOUDSECURITY_OPENCODE_BIN`, `CLOUDSECURITY_AFORGE_BIN`/`AFORGE_BIN`; `provider_env()` with the AWS/GCP/Azure keys + `AGENTFIELD_AFORGE_COMMAND` + `XDG_DATA_HOME`; `DEPTH_HUNTER_MAP`, `DEPTH_CHAIN_LIMITS`, `DEPTH_PROVER_CAPS`, `BudgetConfig` pcts, `ScanConfig.from_input` (tier from input). `tests/test_config.py`. | +| `schemas/*.py` | `internal/schemas` | input (CloudSecurityInput incl. `tier` logic — read it), recon (ResourceInventory, ResourceGraph, DriftReport, ReconResult, ...), hunt, chain (AttackPath, ChainResult), prove (Proof, ProofMethod, VerifiedFinding, RemediationSuggestion), output (CloudSecurityScanResult, ScanProgress), views. `tests/test_schemas.py`, `test_graph_context.py`. | +| `scoring.py` | `internal/scoring` | Severity, EvidenceMethod, Exposure, `compute_risk_score`, `apply_benchmark_severity_floor`; `tests/test_scoring.py`. | +| `agents/_utils.py` | `internal/harnessx` (+ `internal/agents/util` for the non-harness helpers in that file — read it, it is 175 lines) | `tests/test_utils.py`. | +| `agents/recon/_terraform_parser.py` | `internal/agents/recon/tfparse.go` | Use `github.com/hashicorp/hcl/v2` + `hclsyntax` to parse every `*.tf` (sorted rglob, relative paths); produce the SAME inventory.json shape (`resources[] {id,type,name,provider,file_path,line_number:0,config,references,referenced_by}`, `variables[]`, `outputs[]`, `providers[]`, `modules[]`), `_provider_from_type` map, `_extract_references` regex + `_NON_REF_PREFIXES`, `_sanitize`, reverse references, `json.dump(indent=2, default=str)` → MarshalIndent. Expression → value: literals evaluate (`expr.Value(nil)` for constant exprs → Go scalars/lists/maps), anything non-constant → its source text (Python stringifies non-literal expressions; match as closely as reasonable and document differences). Nested blocks: labeled → `result[sub_name][label] = dict`, unlabeled repeated → list. Test against `tests/fixtures/vulnerable_infra/main.tf` and assert the counts/ids the Python tests assert. | +| `agents/recon/_graph_builder_fast.py`, `resource_graph_builder.py`, `iac_reader.py`, `cloud_connector.py`, `drift_detector.py` | `internal/agents/recon` | fast deterministic paths first, harness fallback on error (same prompts, `{{REPO_PATH}}`-style template substitution). | +| `agents/hunt/*` (7 hunters) | `internal/agents/hunt` | read each; shared prompt assembly helpers in `_utils.py`. | +| `agents/chain/path_constructor.py`, `agents/prove/{static,live}_prover.py`, `agents/remediate/fix_generator.py` | `internal/agents/chain`, `internal/agents/prove`, `internal/agents/remediate` | | +| `output/{sarif,json_output,report}.py` | `internal/output` | | +| `prompts/**` (repo-root `prompts/` AND check if the package bundles them — PR #5 "bundle prompts in the package": find the runtime PROMPT_PATH) | `internal/prompts/files/**` | same embed + drift test approach. | + +### The DAG the control plane must show + +``` +scan / prove +├── recon_phase +│ ├── run_iac_reader +│ ├── run_resource_graph_builder (after iac_reader) +│ ├── run_cloud_connector ┐ gather (2) — only tier>=2 with cloud_config (prove reasoner) +│ └── run_drift_detector ┘ +├── hunt_phase +│ └── run__hunter × 5 (quick) / 7 (standard, thorough) semaphore max(1,min(3,N)) +├── chain_phase +│ └── run_path_constructor +├── prove_phase +│ └── run_static_prover × K (tier<2) | run_live_prover × K semaphore 3 +└── remediation_phase + └── run_fix_generator × M +``` + +## 4. 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), `scan` / + `prove` input binding defaults, error mapping. +- Functional test (build tag) against a live CP is optional; the manual live + verification (§6) is mandatory and done by the integrator. + +## 5. 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 (`cloudsecurity`), ports, env var names +(`HARNESS_PROVIDER`, `HARNESS_MODEL`, `AI_MODEL`, `CLOUDSECURITY_*`, +`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 +`CLOUDSECURITY_MODEL`, falling back to `HARNESS_MODEL` — the same precedence +chain `config.py` uses). 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 .`. + +## 6. 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 (the installed package, or `pip install -e .` in a venv) and Go + node (`go run ./cmd/cloudsecurity-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` + + `CLOUDSECURITY_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 `scan` 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..b085bc1 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,29 @@ +module github.com/Agent-Field/cloudsecurity-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/hashicorp/hcl/v2 v2.20.1 + github.com/invopop/jsonschema v0.13.0 + github.com/zclconf/go-cty v1.13.0 +) + +require ( + github.com/agext/levenshtein v1.2.1 // indirect + github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + 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/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + golang.org/x/mod v0.8.0 // indirect + golang.org/x/sys v0.5.0 // indirect + golang.org/x/text v0.11.0 // indirect + golang.org/x/tools v0.6.0 // 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..9893bd9 --- /dev/null +++ b/go/go.sum @@ -0,0 +1,53 @@ +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/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= +github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= +github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +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/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= +github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/hcl/v2 v2.20.1 h1:M6hgdyz7HYt1UN9e61j+qKJBqR3orTWbI1HKBJEdxtc= +github.com/hashicorp/hcl/v2 v2.20.1/go.mod h1:TZDqQ4kNKCbh1iJp99FdPiUaVDDUPivbqxZulxDYqL4= +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/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= +github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +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= +github.com/zclconf/go-cty v1.13.0 h1:It5dfKTTZHe9aeppbNOda3mN7Ag7sg6QkBNm6TkyFa0= +github.com/zclconf/go-cty v1.13.0/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0= +github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b h1:FosyBZYxY34Wul7O/MSKey3txpPYyCqVO5ZyceuQJEI= +github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8= +golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +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/afx_test.go b/go/internal/afx/afx_test.go new file mode 100644 index 0000000..9f2d96d --- /dev/null +++ b/go/internal/afx/afx_test.go @@ -0,0 +1,485 @@ +package afx + +import ( + "encoding/json" + "errors" + "reflect" + "testing" +) + +// The expectations in this file were captured by running the EXACT Python +// helper bodies from src/cloudsecurity_af/app.py, reasoners/phases.py and +// orchestrator.py under this repo's interpreter +// (~/.agentfield/packages/cloudsecurity-af/venv/bin/python, CPython 3.11.12). +// The captured transcript is reproduced above each table. + +// --------------------------------------------------------------------------- +// Bind / ToMap +// --------------------------------------------------------------------------- + +type bindTarget struct { + Name string `json:"name"` + Depth string `json:"depth"` + Paths []string `json:"paths"` + Count int `json:"count"` + Ratio float64 `json:"ratio"` + Skipped bool `json:"skipped"` +} + +// UnmarshalJSON seeds the pydantic defaults the way the ported schema structs +// do, so Bind's default-seeding contract is exercised. +func (b *bindTarget) UnmarshalJSON(data []byte) error { + type alias bindTarget + seeded := alias{Depth: "standard", Paths: []string{"tests/"}, Count: 4} + if err := json.Unmarshal(data, &seeded); err != nil { + return err + } + *b = bindTarget(seeded) + return nil +} + +func TestBind_DecodesByJSONTagAndSeedsDefaults(t *testing.T) { + got, err := Bind[bindTarget](map[string]any{"name": "x", "ratio": 0.5}) + if err != nil { + t.Fatalf("Bind: %v", err) + } + want := bindTarget{Name: "x", Depth: "standard", Paths: []string{"tests/"}, Count: 4, Ratio: 0.5} + if !reflect.DeepEqual(got, want) { + t.Fatalf("Bind = %+v, want %+v", got, want) + } +} + +// Python parity: Model(**{}) seeds every default, so Bind(nil) must too. +func TestBind_NilInputSeedsDefaults(t *testing.T) { + got, err := Bind[bindTarget](nil) + if err != nil { + t.Fatalf("Bind: %v", err) + } + if got.Depth != "standard" || got.Count != 4 { + t.Fatalf("Bind(nil) = %+v, want the pydantic defaults", got) + } +} + +// Numbers cross the reasoner boundary as float64; Bind must land them in the +// typed field without precision gymnastics. +func TestBind_Float64InputIntoIntField(t *testing.T) { + got, err := Bind[bindTarget](map[string]any{"count": float64(9)}) + if err != nil { + t.Fatalf("Bind: %v", err) + } + if got.Count != 9 { + t.Fatalf("Count = %d, want 9", got.Count) + } +} + +func TestBind_ReportsUnmarshalError(t *testing.T) { + if _, err := Bind[bindTarget](map[string]any{"count": "not-a-number"}); err == nil { + t.Fatal("expected an error for a type-mismatched key") + } +} + +type nestedValue struct{ N int } + +func (n nestedValue) MarshalJSON() ([]byte, error) { return []byte(`"custom"`), nil } + +type toMapOuter struct { + Embedded + Name string `json:"name"` + Nested nestedValue `json:"nested"` + Hidden string `json:"-"` + unexp string //nolint:unused // exercises the unexported-field skip +} + +type Embedded struct { + Tier int `json:"tier"` +} + +func TestToMap_KeysAreJSONTagsAndValuesStayTyped(t *testing.T) { + got, err := ToMap(toMapOuter{Embedded: Embedded{Tier: 2}, Name: "n", Nested: nestedValue{N: 1}, Hidden: "h"}) + if err != nil { + t.Fatalf("ToMap: %v", err) + } + if len(got) != 3 { + t.Fatalf("ToMap keys = %v, want exactly name/nested/tier", got) + } + if got["name"] != "n" { + t.Fatalf("name = %v", got["name"]) + } + if got["tier"] != 2 { + t.Fatalf("tier = %v (anonymous embedding must flatten)", got["tier"]) + } + if _, ok := got["nested"].(nestedValue); !ok { + t.Fatalf("nested = %T, want the value to stay typed so its MarshalJSON still runs", got["nested"]) + } + if _, ok := got["Hidden"]; ok { + t.Fatal(`json:"-" field leaked into the map`) + } +} + +func TestToMap_RejectsNonStructs(t *testing.T) { + if _, err := ToMap(map[string]any{}); err == nil { + t.Fatal("expected an error for a non-struct") + } + var p *toMapOuter + if _, err := ToMap(p); err == nil { + t.Fatal("expected an error for a nil pointer") + } +} + +func TestToMap_DereferencesPointers(t *testing.T) { + got, err := ToMap(&toMapOuter{Name: "n"}) + if err != nil { + t.Fatalf("ToMap: %v", err) + } + if got["name"] != "n" { + t.Fatalf("name = %v", got["name"]) + } +} + +// --------------------------------------------------------------------------- +// Unwrap (app.py, lenient) / UnwrapStrict (phases.py + orchestrator.py) +// --------------------------------------------------------------------------- +// +// Python transcript (the two helper bodies run verbatim): +// +// lenient {'error': {'message': 'boom'}} -> RuntimeError: run_iac_reader failed: boom +// lenient {'error': {'detail': 'detailed'}} -> RuntimeError: run_iac_reader failed: detailed +// lenient {'error': {'message': '', 'detail': 'fallback'}} -> RuntimeError: run_iac_reader failed: fallback +// lenient {'error': {'code': 7}} -> RuntimeError: run_iac_reader failed: {'code': 7} +// lenient {'error': {}} -> RuntimeError: run_iac_reader failed: {} +// lenient {'error': 'not a dict', 'output': {'a': 1}} -> OK {'a': 1} +// lenient {'error_message': 'bad things', 'status': 'ok'} -> OK (unchanged) +// lenient {'status': 'failed'} -> OK (unchanged) +// lenient {'status': 'completed', 'output': {'x': 1}} -> OK {'x': 1} +// lenient {'result': {'y': 2}} -> OK {'y': 2} +// lenient {'plain': 1} -> OK (unchanged) +// lenient ['a'] -> OK (unchanged) +// +// strict {'error_message': 'bad things', 'status': 'ok'} -> RuntimeError: hunt_phase failed: bad things +// strict {'status': 'failed'} -> RuntimeError: hunt_phase failed: Unknown error +// strict {'status': 'error', 'error_message': None} -> RuntimeError: hunt_phase failed: None +// strict {'status': 'error', 'error_message': ''} -> RuntimeError: hunt_phase failed: +// strict everything else == lenient + +func TestUnwrap_Lenient_PythonGroundTruth(t *testing.T) { + cases := []struct { + name string + raw any + wantVal any + wantErr string + }{ + {"error dict with message", map[string]any{"error": map[string]any{"message": "boom"}}, nil, "run_iac_reader failed: boom"}, + {"error dict falls back to detail", map[string]any{"error": map[string]any{"detail": "detailed"}}, nil, "run_iac_reader failed: detailed"}, + {"empty message is falsy, detail wins", map[string]any{"error": map[string]any{"message": "", "detail": "fallback"}}, nil, "run_iac_reader failed: fallback"}, + // str(dict) fallback. The int stays an int here; a value that really + // crossed the JSON boundary would be a float64 and repr as 7.0. + {"no message or detail falls back to str(dict)", map[string]any{"error": map[string]any{"code": 7}}, nil, "run_iac_reader failed: {'code': 7}"}, + {"empty error dict", map[string]any{"error": map[string]any{}}, nil, "run_iac_reader failed: {}"}, + {"non-dict error falls through", map[string]any{"error": "not a dict", "output": map[string]any{"a": 1}}, map[string]any{"a": 1}, ""}, + {"output wins over result", map[string]any{"output": 1, "result": 2}, 1, ""}, + {"result when there is no output", map[string]any{"result": map[string]any{"y": 2}}, map[string]any{"y": 2}, ""}, + {"plain payload is returned as-is", map[string]any{"plain": 1}, map[string]any{"plain": 1}, ""}, + {"non-dict payload is returned as-is", []any{"a"}, []any{"a"}, ""}, + // The two probes that the STRICT variant adds are inert here. + {"error_message is ignored by the lenient variant", map[string]any{"error_message": "bad things", "status": "ok"}, map[string]any{"error_message": "bad things", "status": "ok"}, ""}, + {"status is ignored by the lenient variant", map[string]any{"status": "failed"}, map[string]any{"status": "failed"}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := Unwrap(tc.raw, "run_iac_reader") + checkUnwrap(t, got, err, tc.wantVal, tc.wantErr) + }) + } +} + +func TestUnwrapStrict_PythonGroundTruth(t *testing.T) { + cases := []struct { + name string + raw any + wantVal any + wantErr string + }{ + {"truthy error_message fails", map[string]any{"error_message": "bad things", "status": "ok"}, nil, "hunt_phase failed: bad things"}, + {"status failed with no error_message key", map[string]any{"status": "failed"}, nil, "hunt_phase failed: Unknown error"}, + // dict.get only substitutes the default when the KEY IS ABSENT, so a + // present-but-None error_message renders as "None". + {"status error with null error_message", map[string]any{"status": "error", "error_message": nil}, nil, "hunt_phase failed: None"}, + {"status error with empty error_message", map[string]any{"status": "error", "error_message": ""}, nil, "hunt_phase failed: "}, + {"falsy error_message does not trip the second probe", map[string]any{"error_message": "", "output": 5}, 5, ""}, + {"a non-failure status unwraps normally", map[string]any{"status": "completed", "output": map[string]any{"x": 1}}, map[string]any{"x": 1}, ""}, + {"error dict is still checked first", map[string]any{"error": map[string]any{"message": "boom"}, "status": "failed"}, nil, "hunt_phase failed: boom"}, + {"result key", map[string]any{"result": map[string]any{"y": 2}}, map[string]any{"y": 2}, ""}, + {"plain payload", map[string]any{"plain": 1}, map[string]any{"plain": 1}, ""}, + {"non-dict payload", []any{"a"}, []any{"a"}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := UnwrapStrict(tc.raw, "hunt_phase") + checkUnwrap(t, got, err, tc.wantVal, tc.wantErr) + }) + } +} + +func checkUnwrap(t *testing.T, got any, err error, wantVal any, wantErr string) { + t.Helper() + if wantErr != "" { + if err == nil { + t.Fatalf("got (%v, nil), want error %q", got, wantErr) + } + if err.Error() != wantErr { + t.Fatalf("error = %q, want %q", err.Error(), wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(got, wantVal) { + t.Fatalf("value = %#v, want %#v", got, wantVal) + } +} + +// The error-dict str() fallback must be stable across runs even though Go map +// iteration is randomized (the port contract forbids non-deterministic output). +func TestUnwrap_ErrorDictFallbackIsDeterministic(t *testing.T) { + raw := map[string]any{"error": map[string]any{"z": 1, "a": 2, "m": 3}} + want := "n failed: {'a': 2, 'm': 3, 'z': 1}" + for i := 0; i < 20; i++ { + _, err := Unwrap(raw, "n") + if err == nil || err.Error() != want { + t.Fatalf("error = %v, want %q", err, want) + } + } +} + +// --------------------------------------------------------------------------- +// AsMap +// --------------------------------------------------------------------------- +// +// Python transcript (_as_dict run verbatim, name="run_x"): +// +// 's' -> run_x returned non-dict payload: str +// 1 -> run_x returned non-dict payload: int +// 1.5 -> run_x returned non-dict payload: float +// True -> run_x returned non-dict payload: bool +// None -> run_x returned non-dict payload: NoneType +// ['a'] -> run_x returned non-dict payload: list +// {'k': 1} -> OK +func TestAsMap_PythonTypeNamesInTheErrorString(t *testing.T) { + cases := []struct { + payload any + want string + }{ + {"s", "run_x returned non-dict payload: str"}, + {1, "run_x returned non-dict payload: int"}, + {1.5, "run_x returned non-dict payload: float"}, + {float32(1.5), "run_x returned non-dict payload: float"}, + {true, "run_x returned non-dict payload: bool"}, + {nil, "run_x returned non-dict payload: NoneType"}, + {[]any{"a"}, "run_x returned non-dict payload: list"}, + {[]string{"a"}, "run_x returned non-dict payload: list"}, + } + for _, tc := range cases { + _, err := AsMap(tc.payload, "run_x") + if err == nil { + t.Fatalf("AsMap(%#v) returned no error", tc.payload) + } + if err.Error() != tc.want { + t.Errorf("AsMap(%#v) error = %q, want %q", tc.payload, err.Error(), tc.want) + } + } +} + +func TestAsMap_PassesDictsThrough(t *testing.T) { + in := map[string]any{"k": 1} + got, err := AsMap(in, "run_x") + if err != nil { + t.Fatalf("AsMap: %v", err) + } + if !reflect.DeepEqual(got, in) { + t.Fatalf("AsMap = %#v", got) + } +} + +// A nil pointer inside an interface is Python's None, not a dict. +func TestAsMap_NilPointerIsNoneType(t *testing.T) { + var p *toMapOuter + _, err := AsMap(p, "run_x") + if err == nil || err.Error() != "run_x returned non-dict payload: NoneType" { + t.Fatalf("error = %v", err) + } +} + +// --------------------------------------------------------------------------- +// DropNulls / DumpExcludeNone +// --------------------------------------------------------------------------- + +func TestDropNulls_RemovesNullObjectEntriesRecursively(t *testing.T) { + in := map[string]any{ + "keep": 1, + "drop": nil, + "inner": map[string]any{"a": nil, "b": "x"}, + "list": []any{ + map[string]any{"c": nil, "d": 2}, + // Python parity: a None ELEMENT of a list survives exclude_none. + nil, + }, + } + want := map[string]any{ + "keep": 1, + "inner": map[string]any{"b": "x"}, + "list": []any{ + map[string]any{"d": 2}, + nil, + }, + } + got := DropNulls(in) + if !reflect.DeepEqual(got, want) { + t.Fatalf("DropNulls = %#v, want %#v", got, want) + } + // The input must not be mutated. + if _, ok := in["drop"]; !ok { + t.Fatal("DropNulls mutated its argument") + } +} + +func TestDropNulls_LeavesEmptyContainersAlone(t *testing.T) { + var nilSlice []string + var nilMap map[string]any + in := map[string]any{"s": nilSlice, "m": nilMap, "e": []any{}} + got, ok := DropNulls(in).(map[string]any) + if !ok { + t.Fatal("not a map") + } + if len(got) != 3 { + t.Fatalf("DropNulls = %#v, want all three empty containers kept", got) + } +} + +func TestDropNulls_DropsTypedNilPointers(t *testing.T) { + var p *toMapOuter + got, _ := DropNulls(map[string]any{"p": p, "k": 1}).(map[string]any) + if _, ok := got["p"]; ok { + t.Fatalf("nil pointer survived: %#v", got) + } +} + +type dumpModel struct { + Name string `json:"name"` + Suggestion *string `json:"suggestion"` + Score *int `json:"score"` + Tags []string `json:"tags"` +} + +func TestDumpExcludeNone_MatchesModelDumpExcludeNone(t *testing.T) { + score := 3 + payload, err := DumpExcludeNone(dumpModel{Name: "n", Score: &score}) + if err != nil { + t.Fatalf("DumpExcludeNone: %v", err) + } + got := payload.Map() + // Python: `model_dump(exclude_none=True)` gives {'name': 'n', 'score': 3} + // — score is an INT, not 3.0. pyfmt.Load's int/float split keeps it an int, + // so it re-renders as `3`; a float64 decode would emit "3" here but + // "5432.0" for the same integer inside a free-form dict. + want := map[string]any{"name": "n", "score": 3, "tags": nil} + // tags is a nil SLICE -> JSON null -> dropped (a nil Go slice marshals to + // null, not []; the ported schema structs seed their list defaults in + // UnmarshalJSON so this only bites on a hand-built zero value). + delete(want, "tags") + if !reflect.DeepEqual(got, want) { + t.Fatalf("DumpExcludeNone = %#v, want %#v", got, want) + } + if _, ok := got["suggestion"]; ok { + t.Fatal("a nil pointer field survived exclude_none") + } +} + +func TestDumpExcludeNone_RejectsNonObjects(t *testing.T) { + if _, err := DumpExcludeNone([]int{1}); err == nil { + t.Fatal("expected an error for a value that does not encode to an object") + } +} + +// VALIDATION CONTRACT — a phase reply of Python None. +// +// The Go SDK returns a NIL map[string]any with a nil error when a succeeded +// execution's stored result is empty or the literal `null` +// (sdk/go/agent/agent.go awaitExecutionResult). Boxed into `any` that is a +// non-nil interface, so both UnwrapStrict's and AsMap's type assertions +// succeed and every key probe misses. +// +// Python's equivalent is a None payload, and the repo venv answers: +// +// _as_dict(_unwrap(None, "hunt_phase"), "hunt_phase") +// -> RuntimeError: hunt_phase returned non-dict payload: NoneType +// _as_dict(_unwrap({"status": "completed", "result": None}, "hunt_phase"), ...) +// -> the same message +// +// A RuntimeError is NOT ValueError-class, so app.py answers it 500 with the +// "scan execution failed: " prefix. Accepting the nil map as `{}` instead binds +// an all-default HuntResult/ChainResult (neither declares a required field) and +// returns a 200 reporting a zero-finding scan. +func TestAsMap_NilMapIsNoneNotAnEmptyDict(t *testing.T) { + var nilMap map[string]any + + payload, err := UnwrapStrict(nilMap, "hunt_phase") + if err != nil { + t.Fatalf("UnwrapStrict: %v", err) + } + _, err = AsMap(payload, "hunt_phase") + if err == nil { + t.Fatal("AsMap accepted a nil map; Python raises RuntimeError for None") + } + if want := "hunt_phase returned non-dict payload: NoneType"; err.Error() != want { + t.Errorf("error = %q, want %q", err, want) + } + + // The same through the lenient variant and through an envelope carrying an + // explicit null result. + payload, err = Unwrap(map[string]any{"status": "completed", "result": nil}, "hunt_phase") + if err != nil { + t.Fatalf("Unwrap: %v", err) + } + if _, err := AsMap(payload, "hunt_phase"); err == nil || err.Error() != "hunt_phase returned non-dict payload: NoneType" { + t.Errorf("an explicit null result gave %v, want the NoneType RuntimeError", err) + } + + // A nil map is not ValueError-class — Python raises RuntimeError, which + // app.py answers 500, not 400. + var validation *ValidationError + if errors.As(err, &validation) { + t.Error("the NoneType payload error must not be ValueError-class") + } + + // An EMPTY (non-nil) dict is a real dict and must still pass — Python + // accepts `{}` and binds every default. + if _, err := AsMap(map[string]any{}, "hunt_phase"); err != nil { + t.Errorf("an empty dict was rejected: %v", err) + } +} + +// PythonTypeName spells a nil map/slice NoneType, because that is how the Go +// SDK and encoding/json both represent a JSON null at those targets. +func TestPythonTypeName_NilContainersAreNoneType(t *testing.T) { + var nilMap map[string]any + var nilSlice []any + for _, v := range []any{nil, nilMap, nilSlice} { + if got := PythonTypeName(v); got != "NoneType" { + t.Errorf("PythonTypeName(%#v) = %q, want NoneType", v, got) + } + } + for v, want := range map[any]string{ + "s": "str", 1.5: "float", true: "bool", + } { + if got := PythonTypeName(v); got != want { + t.Errorf("PythonTypeName(%#v) = %q, want %q", v, got, want) + } + } + if got := PythonTypeName(map[string]any{}); got != "dict" { + t.Errorf("PythonTypeName(empty dict) = %q, want dict", got) + } + if got := PythonTypeName([]any{}); got != "list" { + t.Errorf("PythonTypeName(empty list) = %q, want list", got) + } +} diff --git a/go/internal/afx/bind.go b/go/internal/afx/bind.go new file mode 100644 index 0000000..b248e1a --- /dev/null +++ b/go/internal/afx/bind.go @@ -0,0 +1,185 @@ +// Package afx holds the small ergonomics over the AgentField Go SDK that every +// reasoner handler, phase and orchestrator step in the cloudsecurity-af port +// reuses: +// +// - Bind / ToMap — the pydantic model <-> reasoner-input map boundary +// - Unwrap / UnwrapStrict / AsMap — the exact ports of the Python node's +// _unwrap / _as_dict envelope handling for app.call() results +// - DropNulls / DumpExcludeNone — model_dump(exclude_none=True) parity +// +// Everything here is a 1:1 port of Python behaviour, including the error +// strings, which the phases and the orchestrator surface to callers. +package afx + +import ( + "bytes" + "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 the Python node 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 decode uses json.Decoder.UseNumber. That matters for the `Any`-typed +// leaves the ported models carry — DriftedResource.iac_config / live_config are +// `dict[str, Any]` and ConfigDiff.iac_value / live_value are `Any` — because +// plain encoding/json turns every JSON number landing in such a leaf into a +// float64. Python's json.loads keeps `{"port": 5432}` an INT, so a float64 +// re-renders as `5432.0` in {{DRIFT_REPORT_JSON}} (the CHAIN parent prompt), +// in {{FINDING_JSON}} (the fix-generator prompt), in +// .cloudsecurity/checkpoint-recon.json and in the scan result. json.Number +// keeps the literal and re-marshals verbatim through both encoding/json and +// pyfmt.Dumps. Decoding into TYPED fields is unaffected — UseNumber only +// changes how `any` targets are filled. +// +// The same decoder is used inside every default-seeding UnmarshalJSON (see +// schemas.decodeSeeded), because a custom UnmarshalJSON receives raw bytes and +// would otherwise re-introduce float64 below Bind's own decode. +// +// DIVERGENCE — THE OTHER HALF OF THE SAME TRADE-OFF, and it is not fixable from +// here. UseNumber pins the literal of the map Bind is HANDED, and by then the +// Go SDK has already decoded the request body with a plain encoding/json +// decoder (sdk/go/agent/agent.go handleReasoner / the skill path — there is no +// UseNumber anywhere in sdk/go), collapsing every JSON number onto float64. So +// a wire `7.0` arrives as float64(7), `json.Marshal` writes it back as `7`, and +// UseNumber pins THAT as json.Number("7"). An integral float inside an `Any` +// leaf therefore loses Python's float spelling: +// +// wire {"resource_id":"r","resource_type":"t","iac_config":{"ratio":7.0,"n":5}} +// Python "iac_config": {"ratio": 7.0, "n": 5} (venv, DriftedResource.model_dump) +// Go "iac_config": {"n": 5, "ratio": 7} +// +// visible in {{DRIFT_REPORT_JSON}}, {{FINDING_JSON}}, +// .cloudsecurity/checkpoint-recon.json and the scan reply. Dropping UseNumber +// would trade this rare case for the common one — `{"port": 5432}` would render +// `5432.0` — so UseNumber stays. (The key ORDER in that example is the +// separately documented map-sorting deviation of pyfmt.Dumps: a Go map has no +// insertion order.) internal/afx/bind_numbers_test.go pins both halves. +// +// Python parity: a nil input map is normalized to an empty object, so +// Bind[T](nil) is Model(**{}) — every pydantic default is seeded. (Marshaling +// a nil map would emit "null", which unmarshals into T as a no-op for a plain +// struct and hands "null" to a custom UnmarshalJSON.) +// Bind also enforces pydantic's REQUIRED fields (see required.go): a T whose +// type — or any model nested inside it — implements RequiredFielder is checked +// against the payload before the decode, so `Model.model_validate(d)` raises in +// Go wherever it raises in Python instead of returning a zero-valued model. +// ValidationError is the Go stand-in for pydantic's ValidationError, which is +// a ValueError SUBCLASS — the distinction app.py's `except ValueError` / +// `except Exception` split turns into HTTP 400 versus 500. +// +// It exists so a caller classifies a bind failure by VALUE (errors.As) instead +// of by scanning message text. Text matching cannot tell an in-process bind +// failure from one that happened inside a CHILD reasoner and was relayed back +// through the control plane: the child's `afx.Bind: ...` message is copied +// verbatim into the execution's error_message and surfaces at the parent as an +// *agent.ExecuteError, which Python sees as a transport exception (500), not as +// a ValueError (400). +// +// The rendered text is unchanged — "afx.Bind: " followed by the cause — so the +// 400 body still matches Python's str(exc) shape. +type ValidationError struct{ Err error } + +func (e *ValidationError) Error() string { return "afx.Bind: " + e.Err.Error() } + +func (e *ValidationError) Unwrap() error { return e.Err } + +func Bind[T any](input map[string]any) (T, error) { + var out T + if input == nil { + input = map[string]any{} + } + if err := requireFields(map[string]any(input), reflect.TypeOf(out)); err != nil { + return out, &ValidationError{Err: err} + } + // pydantic's LAX scalar coercion and its rejection of a null for a + // non-Optional field — the two things a JSON round-trip gets wrong on a + // value that IS present. See lax.go. + coerced, err := coerceLax(map[string]any(input), reflect.TypeOf(out)) + if err != nil { + return out, &ValidationError{Err: err} + } + b, err := json.Marshal(coerced) + if err != nil { + return out, &ValidationError{Err: fmt.Errorf("marshal input: %w", err)} + } + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + if err := dec.Decode(&out); err != nil { + return out, &ValidationError{Err: fmt.Errorf("unmarshal into %T: %w", out, err)} + } + return out, nil +} + +// ToMap is Bind's inverse: it renders a typed input 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 isoformat +// rendering, enum normalization). Keeping values typed lets Bind on the handler +// side (and the SDK's 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 +// embedded structs without their own json tag are flattened the way +// encoding/json flattens them. +// +// Use DumpExcludeNone instead when the Python source called +// model_dump(exclude_none=True). +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_numbers_test.go b/go/internal/afx/bind_numbers_test.go new file mode 100644 index 0000000..4156558 --- /dev/null +++ b/go/internal/afx/bind_numbers_test.go @@ -0,0 +1,153 @@ +package afx + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// VALIDATION CONTRACT — integers inside `Any`-typed model fields. +// +// The drift detector's report crosses the control plane (run_drift_detector -> +// recon_phase -> ReconResult -> orchestrator -> chain_phase / prove_phase) and +// is re-materialised with `DriftReport.model_validate(...)`. Its +// `iac_config` / `live_config` are `dict[str, Any]` +// (src/cloudsecurity_af/schemas/recon.py:119-120) and ConfigDiff's +// `iac_value` / `live_value` are `Any`, so an IaC port, retention period, TTL +// or capacity unit lands there as an INTEGER. +// +// Ground truth — the repo venv on the same wire payload: +// +// DriftReport.model_validate(wire).model_dump() -> json.dumps(indent=2) +// "iac_config": {"port": 5432, "backup_retention_period": 7, +// "multi_az": false, "ratio": 0.5} +// "diffs": [{"attribute": "port", "iac_value": 5432, "live_value": 3306, +// "security_impact": null}] +// +// Those exact bytes reach the LLM through {{DRIFT_REPORT_JSON}} in the CHAIN +// parent prompt and through {{FINDING_JSON}} in the fix-generator prompt, and +// they land in .cloudsecurity/checkpoint-recon.json and the scan result. A +// float64 decode renders every one of them as `5432.0` / `7.0`. +const driftWireJSON = `{ + "drifted_resources": [{ + "resource_id": "aws_db_instance.main", + "resource_type": "aws_db_instance", + "iac_config": {"port": 5432, "backup_retention_period": 7, "multi_az": false, "ratio": 0.5}, + "live_config": {"port": 3306}, + "diffs": [{"attribute": "port", "iac_value": 5432, "live_value": 3306, "security_impact": null}], + "security_relevant": true, + "significance": "high" + }], + "iac_only_resources": [], + "cloud_only_resources": [] +}` + +func TestBind_KeepsIntegersInsideAnyTypedFields(t *testing.T) { + var wire map[string]any + if err := json.Unmarshal([]byte(driftWireJSON), &wire); err != nil { + t.Fatalf("decode wire: %v", err) + } + + report, err := Bind[schemas.DriftReport](wire) + if err != nil { + t.Fatalf("Bind: %v", err) + } + rendered := pyfmt.Dumps(report, 2) + + for _, want := range []string{ + `"port": 5432`, + `"backup_retention_period": 7`, + `"ratio": 0.5`, + `"iac_value": 5432`, + `"live_value": 3306`, + } { + if !strings.Contains(rendered, want) { + t.Errorf("rendered drift report is missing %s\n%s", want, rendered) + } + } + for _, unwanted := range []string{"5432.0", "3306.0", `"backup_retention_period": 7.0`} { + if strings.Contains(rendered, unwanted) { + t.Errorf("rendered drift report contains %s; Python renders the integer\n%s", unwanted, rendered) + } + } +} + +// The same guarantee has to survive the custom default-seeding UnmarshalJSON, +// which decodes the raw bytes a second time: json.Unmarshal there would undo +// Bind's UseNumber for every model that has one (DriftedResource and DriftReport +// both do). +func TestUnmarshalJSON_KeepsIntegersInsideAnyTypedFields(t *testing.T) { + var report schemas.DriftReport + if err := json.Unmarshal([]byte(driftWireJSON), &report); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + got := report.DriftedResources[0].IaCConfig["port"] + n, ok := got.(json.Number) + if !ok || n.String() != "5432" { + t.Fatalf("iac_config[port] = %#v, want json.Number(\"5432\")", got) + } + if diff := report.DriftedResources[0].Diffs[0].IaCValue; diff != json.Number("5432") { + t.Fatalf("diffs[0].iac_value = %#v, want json.Number(\"5432\")", diff) + } +} + +// Typed fields must be unaffected by UseNumber. +func TestBind_TypedNumericFieldsAreStillDecodedByType(t *testing.T) { + in, err := Bind[schemas.ResourceInventory](map[string]any{ + "inventory_saved_path": "/tmp/inventory.json", + "total_resources": float64(3), + }) + if err != nil { + t.Fatalf("Bind: %v", err) + } + if in.TotalResources != 3 { + t.Fatalf("TotalResources = %d, want 3", in.TotalResources) + } +} + +// The OTHER half of the UseNumber trade-off: an integral float inside an `Any` +// leaf loses Python's float spelling, and cannot be recovered here. +// +// The Go SDK decodes the reasoner body with a plain encoding/json decoder (no +// UseNumber anywhere in sdk/go), so a wire `7.0` is already float64(7) before +// Bind runs; Bind's own re-marshal writes `7` and UseNumber pins that literal. +// +// Ground truth — the repo venv on the identical wire bytes +// `{"resource_id":"r","resource_type":"t","iac_config":{"ratio":7.0,"n":5}}`: +// +// DriftedResource.model_validate(json.loads(wire)).model_dump() +// -> "iac_config": {"ratio": 7.0, "n": 5} +// +// This test pins the port's actual output so the divergence is visible and a +// future change to the SDK's decoder (or to Bind) shows up here rather than as +// a silent prompt-byte drift. +func TestBind_IntegralFloatInAnyLeafLosesTheFloatSpelling(t *testing.T) { + // What the SDK hands the handler: every number already a float64. + var input map[string]any + if err := json.Unmarshal([]byte(`{"resource_id":"r","resource_type":"t","iac_config":{"ratio":7.0,"n":5}}`), &input); err != nil { + t.Fatalf("decode wire: %v", err) + } + if _, ok := input["iac_config"].(map[string]any)["ratio"].(float64); !ok { + t.Fatalf("premise broken: the SDK decoder no longer collapses numbers to float64") + } + + bound, err := Bind[schemas.DriftedResource](input) + if err != nil { + t.Fatalf("Bind: %v", err) + } + got := pyfmt.DumpsCompact(bound.IaCConfig) + // DIVERGENCE: Python prints {"ratio": 7.0, "n": 5}. The int stays an int + // (the half UseNumber buys); the integral float is indistinguishable from + // it by the time Bind runs. The key order is pyfmt.Dumps' documented + // map-sorting deviation. + if want := `{"n": 5, "ratio": 7}`; got != want { + t.Errorf("iac_config = %s, want %s", got, want) + } + // The integer half must keep working: 5 must never become 5.0. + if strings.Contains(got, "5.0") { + t.Errorf("an integer literal was re-rendered as a float: %s", got) + } +} diff --git a/go/internal/afx/dropnulls.go b/go/internal/afx/dropnulls.go new file mode 100644 index 0000000..fe3986a --- /dev/null +++ b/go/internal/afx/dropnulls.go @@ -0,0 +1,231 @@ +package afx + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" +) + +// DropNulls recursively removes every null-valued entry from the JSON-shaped +// tree v, returning a new tree; v is not mutated. +// +// It is the Go stand-in for pydantic's model_dump(exclude_none=True), which +// reasoners/phases.py uses on every VerifiedFinding it returns: +// +// "verified": [v.model_dump(exclude_none=True) for v in verified] +// +// Shape handled: map[string]any (null entries dropped, values recursed) and +// []any (elements recursed, NOT dropped). Everything else is returned +// unchanged. Null means an untyped nil or a nil pointer/interface; a nil Go +// SLICE is an empty JSON list, not a null, and is left alone. +// +// PYDANTIC SEMANTICS (verified against pydantic 2.13.4 in this repo's venv): +// exclude_none drops MODEL FIELDS whose value is None, but leaves None inside a +// plain dict or list field. Given +// +// class Inner(BaseModel): a: str | None = None ; b: str = "x" +// class Outer(BaseModel): n: int | None = None ; lst: list[Inner|None] = [] +// d: dict[str, Any] = {} ; inner: Inner | None = None +// Outer(n=None, lst=[Inner(), None], d={"k": None, "j": 1}, inner=Inner()) +// .model_dump(exclude_none=True) +// == {'lst': [{'b': 'x'}, None], 'd': {'k': None, 'j': 1}, 'inner': {'b': 'x'}} +// +// i.e. `n` was dropped, the list's None survived, and d's None survived. +// +// DropNulls is the STRUCTURAL half of that rule: it drops nulls at EVERY object +// level, which is only equal to exclude_none for a tree whose objects are all +// model fields. It is NOT the right tool for a model carrying a free-form +// dict — VerifiedFinding.drift.iac_config / live_config are `dict[str, Any]` +// and a null-valued config key (`{"logging": null}` meaning "not configured") +// is ordinary. Use DumpExcludeNone, which walks the Go TYPE alongside the tree +// and therefore knows which objects are models and which are free-form dicts. +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 isNull(val) { + continue + } + out[k] = DropNulls(val) + } + return out + case []any: + out := make([]any, len(x)) + for i, e := range x { + // Python parity: a None ELEMENT of a list survives exclude_none. + out[i] = DropNulls(e) + } + return out + } + return v +} + +// isNull reports whether v is a JSON null: an untyped nil, or a nil pointer or +// interface. A nil map or slice is deliberately NOT null — those marshal to +// {} / [] the way an empty pydantic container does. +func isNull(v any) bool { + if v == nil { + return true + } + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Pointer, reflect.Interface: + return rv.IsNil() + } + return false +} + +// DumpExcludeNone ports `model.model_dump(exclude_none=True)` end to end and +// returns the result as an insertion-ORDERED Payload, because Python's dict +// keeps the model's field order and json.dumps writes it out that way. +// +// The render goes through pyfmt (not encoding/json) for the same reason +// afx.Payload does: pyfmt spells a float field the Python way ("risk_score": +// 0.0, not 0) and pyfmt.Load's int/float split keeps an integer inside a +// free-form dict an integer. Custom marshalers still run — pyfmt.Dumps honours +// json.Marshaler, so schemas.Timestamp keeps its isoformat spelling. +// +// The prune is TYPE-GUIDED: it walks reflect.TypeOf(v) alongside the rendered +// tree, so a null is dropped only where the enclosing object is a Go STRUCT (a +// pydantic model) and the key names one of its fields. Inside a +// `map[string]any` / `any` field — the Go spelling of `dict[str, Any]` / `Any` — +// nulls are kept verbatim, exactly as pydantic keeps them. Without that guide, +// `VerifiedFinding.drift.iac_config = {"logging": null, "acl": "private"}` +// silently lost the `logging` key on its way out of prove_phase / +// remediation_phase and out of the final scan result. +// +// Use this — not ToMap or Dump — wherever the Python source calls +// model_dump(exclude_none=True). +func DumpExcludeNone(v any) (Payload, error) { + rendered := pyfmt.DumpsCompact(v) + tree, err := pyfmt.Load([]byte(rendered)) + if err != nil { + return nil, fmt.Errorf("afx.DumpExcludeNone: decode %T: %w", v, err) + } + cleaned, ok := dropNullsTyped(tree, reflect.TypeOf(v)).(pyfmt.Ordered) + if !ok { + return nil, fmt.Errorf("afx.DumpExcludeNone: %T does not encode to a JSON object", v) + } + return Payload(cleaned), nil +} + +// jsonMarshalerType is the interface whose implementations render themselves — +// their JSON is opaque to the prune, exactly as a pydantic field with a custom +// serializer is opaque to exclude_none. +var jsonMarshalerType = reflect.TypeOf((*json.Marshaler)(nil)).Elem() + +// dropNullsTyped prunes tree using the Go type t as the model/free-form guide. +// +// t is a STRUCT -> a pydantic model: null-valued keys that name one +// of its fields are DROPPED, and each surviving +// value recurses with that field's type. +// t is a MAP -> `dict[str, V]`: nulls are KEPT, values recurse +// with V (so `dict[str, Model]` still gets +// exclude_none, which pydantic also applies). +// t is a SLICE/ARRAY -> elements recurse with the element type; a null +// ELEMENT survives, as it does in pydantic. +// t is an INTERFACE/nil -> unknown shape (`Any`): the subtree is returned +// untouched, nulls included. +// +// tree is pyfmt.Load's value model (nil | bool | string | int | float64 | +// []any | pyfmt.Ordered), so objects keep their document order throughout. +func dropNullsTyped(tree any, t reflect.Type) any { + for t != nil && t.Kind() == reflect.Pointer { + t = t.Elem() + } + // A type that renders itself owns its whole JSON subtree. + if t != nil && t.Kind() != reflect.Interface && + (t.Implements(jsonMarshalerType) || reflect.PointerTo(t).Implements(jsonMarshalerType)) { + return tree + } + + switch x := tree.(type) { + case pyfmt.Ordered: + if t == nil || t.Kind() == reflect.Interface { + return tree + } + switch t.Kind() { + case reflect.Struct: + fields := jsonFieldTypes(t) + out := make(pyfmt.Ordered, 0, len(x)) + for _, kv := range x { + ft, declared := fields[kv.K] + if declared && isNull(kv.V) { + continue + } + if !declared { + // Not a model field (an extra key a custom marshaler + // added): leave it, and its nulls, alone. + out = append(out, kv) + continue + } + out = append(out, pyfmt.KV{K: kv.K, V: dropNullsTyped(kv.V, ft)}) + } + return out + case reflect.Map: + // Python parity: a None VALUE of a dict field survives exclude_none. + elem := t.Elem() + out := make(pyfmt.Ordered, 0, len(x)) + for _, kv := range x { + out = append(out, pyfmt.KV{K: kv.K, V: dropNullsTyped(kv.V, elem)}) + } + return out + default: + return tree + } + case []any: + if t == nil || t.Kind() == reflect.Interface { + return tree + } + if t.Kind() != reflect.Slice && t.Kind() != reflect.Array { + return tree + } + elem := t.Elem() + out := make([]any, len(x)) + for i, e := range x { + // Python parity: a None ELEMENT of a list survives exclude_none. + out[i] = dropNullsTyped(e, elem) + } + return out + } + return tree +} + +// jsonFieldTypes maps a struct's JSON key names to the declared field types, +// flattening untagged anonymous embedded structs the way encoding/json does. +func jsonFieldTypes(t reflect.Type) map[string]reflect.Type { + out := map[string]reflect.Type{} + collectJSONFieldTypes(out, t) + return out +} + +func collectJSONFieldTypes(out map[string]reflect.Type, t reflect.Type) { + 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 == "" { + ft := f.Type + for ft.Kind() == reflect.Pointer { + ft = ft.Elem() + } + if f.Anonymous && ft.Kind() == reflect.Struct { + collectJSONFieldTypes(out, ft) + continue + } + name = f.Name + } + if _, exists := out[name]; !exists { + out[name] = f.Type + } + } +} diff --git a/go/internal/afx/dropnulls_models_test.go b/go/internal/afx/dropnulls_models_test.go new file mode 100644 index 0000000..e220fbc --- /dev/null +++ b/go/internal/afx/dropnulls_models_test.go @@ -0,0 +1,166 @@ +package afx + +import ( + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// VALIDATION CONTRACT for DumpExcludeNone on the models that actually reach it +// (VerifiedFinding, at internal/phases/prove.go and internal/phases/remediate.go, +// porting `[v.model_dump(exclude_none=True) for v in verified]` at +// src/cloudsecurity_af/reasoners/phases.py:318, :367 and :395): +// +// 1. A MODEL FIELD whose value is None is dropped (attack_path, remediation, +// drop_reason on a finding that has none). +// 2. A None INSIDE a free-form dict field is KEPT. drift.iac_config and +// drift.live_config are `dict[str, Any]` (schemas/recon.py:119-120), and +// `{"logging": null}` — "not configured" — is an ordinary value the +// drift detector emits. +// 3. An INTEGER inside such a dict stays an integer (`5432`, not `5432.0`). +// 4. A None ELEMENT of a list field survives. +// +// Ground truth, printed by the repo venv (pydantic 2.13.4) for the finding +// below: +// +// "drift": {..., "iac_config": {"logging": null, "acl": "private", +// "port": 5432}, +// "live_config": {"logging": null}, ...} +// +// with no "attack_path", "remediation" or "drop_reason" key. + +func driftedFinding() schemas.VerifiedFinding { + return schemas.VerifiedFinding{ + ID: "f1", + Title: "t", + Verdict: schemas.VerdictConfirmed, + Severity: scoring.SeverityHigh, + Category: "c", + Drift: &schemas.DriftedResource{ + ResourceID: "aws_s3_bucket.logs", + ResourceType: "aws_s3_bucket", + IaCConfig: map[string]any{ + "logging": nil, + "acl": "private", + "port": 5432, + }, + LiveConfig: map[string]any{"logging": nil}, + Diffs: []schemas.ConfigDiff{}, + Significance: "medium", + }, + Resources: []schemas.AffectedResource{}, + ComplianceMappings: []string{}, + Proof: schemas.Proof{Method: schemas.ProofMethodStaticAnalysis, Evidence: []string{}, ScriptsExecuted: []string{}, VerificationTier: "static"}, + } +} + +// Contract items 2 and 3. +func TestDumpExcludeNone_KeepsNullsInsideFreeFormDictFields(t *testing.T) { + payload, err := DumpExcludeNone(driftedFinding()) + if err != nil { + t.Fatalf("DumpExcludeNone: %v", err) + } + got := payload.Map() + + drift, ok := got["drift"].(pyfmt.Ordered) + if !ok { + t.Fatalf("drift = %#v, want an object", got["drift"]) + } + + iacRaw, _ := drift.Get("iac_config") + iac, ok := iacRaw.(pyfmt.Ordered) + if !ok { + t.Fatalf("iac_config = %#v, want an object", iacRaw) + } + val, present := iac.Get("logging") + if !present { + t.Errorf("iac_config lost the null-valued key %q; pydantic's exclude_none keeps nulls inside dict[str, Any]", "logging") + } + if val != nil { + t.Errorf("iac_config[logging] = %#v, want null", val) + } + if acl, _ := iac.Get("acl"); acl != "private" { + t.Errorf("iac_config[acl] = %#v", acl) + } + if port, _ := iac.Get("port"); port != 5432 { + t.Errorf("iac_config[port] = %#v, want the integer 5432 (Python renders 5432, not 5432.0)", port) + } + + liveRaw, _ := drift.Get("live_config") + live, ok := liveRaw.(pyfmt.Ordered) + if !ok { + t.Fatalf("live_config = %#v, want an object", liveRaw) + } + if _, present := live.Get("logging"); !present { + t.Errorf("live_config lost its null-valued key; got %#v", live) + } +} + +// Contract item 1 — the half that must keep working. +func TestDumpExcludeNone_StillDropsNilModelFields(t *testing.T) { + payload, err := DumpExcludeNone(driftedFinding()) + if err != nil { + t.Fatalf("DumpExcludeNone: %v", err) + } + got := payload.Map() + for _, key := range []string{"attack_path", "remediation", "drop_reason"} { + if _, present := got[key]; present { + t.Errorf("%q survived exclude_none; pydantic drops a None MODEL field", key) + } + } + // A non-nil model field is kept and recursed into: security_impact is a + // *string on ConfigDiff, so a diff with none must lose that key only. + f := driftedFinding() + f.Drift.Diffs = []schemas.ConfigDiff{{Attribute: "acl", IaCValue: "private", LiveValue: nil}} + payload, err = DumpExcludeNone(f) + if err != nil { + t.Fatalf("DumpExcludeNone: %v", err) + } + driftRaw, _ := payload.Get("drift") + diffsRaw, _ := driftRaw.(pyfmt.Ordered).Get("diffs") + diff := diffsRaw.([]any)[0].(pyfmt.Ordered) + if _, present := diff.Get("security_impact"); present { + t.Error("security_impact is a None model field and must be dropped") + } + if _, present := diff.Get("live_value"); present { + t.Error("live_value is `Any = None`, i.e. a None MODEL field, and must be dropped") + } + if v, _ := diff.Get("iac_value"); v != "private" { + t.Errorf("iac_value = %#v", v) + } +} + +// Contract item 4 — a None element of a list field is not a model field. +func TestDumpExcludeNone_KeepsNullListElements(t *testing.T) { + type inner struct { + A *string `json:"a"` + B string `json:"b"` + } + type outer struct { + List []*inner `json:"lst"` + Dict map[string]any `json:"d"` + } + got, err := DumpExcludeNone(outer{ + List: []*inner{{B: "x"}, nil}, + Dict: map[string]any{"k": nil, "j": 1}, + }) + if err != nil { + t.Fatalf("DumpExcludeNone: %v", err) + } + lstRaw, _ := got.Get("lst") + lst, _ := lstRaw.([]any) + if len(lst) != 2 || lst[1] != nil { + t.Errorf("lst = %#v, want the null element kept (pydantic: [{'b':'x'}, None])", lst) + } + first, _ := lst[0].(pyfmt.Ordered) + if _, present := first.Get("a"); present { + t.Error("the nested model's None field must still be dropped") + } + dRaw, _ := got.Get("d") + d, _ := dRaw.(pyfmt.Ordered) + if _, present := d.Get("k"); !present { + t.Errorf("d = %#v, want the null value kept (pydantic: {'k': None, 'j': 1})", d) + } +} diff --git a/go/internal/afx/handlerinput.go b/go/internal/afx/handlerinput.go new file mode 100644 index 0000000..0a681c1 --- /dev/null +++ b/go/internal/afx/handlerinput.go @@ -0,0 +1,397 @@ +package afx + +import ( + "encoding/json" + "fmt" + "math" + "net/http" + "strconv" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" +) + +// handlerinput.go ports the Python SDK's request-body validation, which the Go +// SDK has no counterpart for. +// +// Every Python reasoner endpoint runs its body through +// `Agent._validate_handler_input(body, handler_input_fields)` +// (sdk/python/agentfield/agent.py:1146-1245, called at agent.py:2117 and +// agent.py:3122) BEFORE the coroutine is entered, and renders an +// `_HandlerInputError` as `JSONResponse(status_code=422, {"detail": msg})`. +// That function is the whole contract: +// +// for each DECLARED parameter (name, annotation, default): +// absent + no default -> 422 "Missing required field: {name}" +// absent + default -> the default +// None + Optional annotation -> None +// None + default -> the default +// None + neither -> 422 "Field '{name}' cannot be None" +// present -> int(v) / float(v) / str(v) / the bool whitelist / +// an isinstance check for dict and list +// -> 422 "Invalid value for field '{name}'" +// / "Field '{name}' must be a dict" / "... must be a list" +// UNDECLARED keys are dropped: the handler is called with `result`, not `body`. +// +// The Go SDK reads a reasoner's InputSchema only to build the registration and +// discovery payloads (agent/agent_lifecycle.go, agent/agent.go) and never +// validates against it, so without this layer the port both ACCEPTED bodies +// Python rejects — `cloudsecurity.scan {}` bound repo_url to "" and scanned the +// node's own working directory, returning 200, where Python 422s and runs +// nothing — and REJECTED bodies Python accepts, because plain encoding/json +// refuses `tier: "2"` or `max_concurrent_hunters: "4"` that Python coerces. + +// FieldType is the coercion class of a declared reasoner parameter — the +// `actual_type` branch _validate_handler_input takes after unwrapping Optional. +type FieldType int + +// The coercion classes, in the order agent.py:1204-1236 tests them. +const ( + // TypeInt is `int`: `int(value)`. + TypeInt FieldType = iota + // TypeFloat is `float`: `float(value)`. + TypeFloat + // TypeStr is `str`: `str(value)`, which accepts ANY value. + TypeStr + // TypeBool is `bool`: a bool passes through, a str is tested against the + // ("true", "1", "yes") whitelist, anything else goes through bool(). + TypeBool + // TypeDict is `dict[...]`: an isinstance check, never a conversion. + TypeDict + // TypeList is `list[...]`: an isinstance check, never a conversion. + TypeList + // TypeAny is every annotation the coercion ladder falls through — the + // value is passed to the handler untouched. + TypeAny +) + +// Field transcribes one parameter of a Python reasoner signature. +// +// Required and Optional are independent and both come from the SOURCE, not from +// the Go type: Required is `param.default is inspect.Parameter.empty`, Optional +// is "the annotation admits None" (`X | None`). `drift_report: dict | None = +// None` is Optional and not Required; `finding: dict[str, Any]` is Required and +// not Optional. +type Field struct { + Name string + Type FieldType + Optional bool + Required bool +} + +// HandlerInput is implemented by every reasoner input struct in this port. The +// struct is the transcription of the Python signature, so the field list lives +// next to it rather than in a registry that could drift from it. +type HandlerInput interface { + HandlerInputFields() []Field +} + +// InputError is `_HandlerInputError`. It carries the exact Python message and +// the 422 the Python endpoint answers with. +// +// DIVERGENCE (unavoidable, SDK-level): Python's body is `{"detail": msg}` and +// the Go SDK renders a handler ExecuteError as `{"error": msg}`. The status +// code and the message text match. +type InputError struct{ Message string } + +func (e *InputError) Error() string { return e.Message } + +// ExecuteError renders the error the way the Python endpoint does. +func (e *InputError) ExecuteError() *agent.ExecuteError { + return &agent.ExecuteError{StatusCode: http.StatusUnprocessableEntity, Message: e.Message} +} + +// ValidateHandlerInput ports _validate_handler_input. The returned map contains +// ONLY declared parameters, coerced; a parameter that is absent (or that +// Python would replace with its default) is omitted, so the input struct's own +// default seeding supplies the same value the Python default would. +func ValidateHandlerInput(data map[string]any, fields []Field) (map[string]any, error) { + out := make(map[string]any, len(fields)) + for _, f := range fields { + value, present := data[f.Name] + if !present { + if f.Required { + return nil, &InputError{Message: "Missing required field: " + f.Name} + } + // Python writes the signature default into result; omitting the + // key lets the input struct seed the identical value. + continue + } + + if value == nil { + if f.Optional { + out[f.Name] = nil + continue + } + if !f.Required { + continue // Python: `result[name] = default`. + } + return nil, &InputError{Message: fmt.Sprintf("Field '%s' cannot be None", f.Name)} + } + + coerced, err := coerceField(f, value) + if err != nil { + return nil, err + } + out[f.Name] = coerced + } + return out, nil +} + +// BindHandlerInput is ValidateHandlerInput followed by Bind — the whole Python +// request path for a reasoner: validate the body against the signature, then +// materialise the parameters. A T that does not implement HandlerInput binds +// unvalidated, which is the pre-existing behaviour for any input struct that +// has not been transcribed yet. +func BindHandlerInput[T any](input map[string]any) (T, error) { + var out T + if hi, ok := any(out).(HandlerInput); ok { + validated, err := ValidateHandlerInput(input, hi.HandlerInputFields()) + if err != nil { + return out, err + } + input = validated + } + return Bind[T](input) +} + +// coerceField applies `_validate_handler_input`'s coercion ladder. +// +// PYTHON SDK QUIRK, verified by running the real +// `Agent._validate_handler_input` on cloudsecurity's own `scan` signature: an +// OPTIONAL parameter is NOT coerced. The unwrap is +// +// origin = getattr(expected_type, "__origin__", None) +// if origin is Union: ... +// +// and a PEP 604 annotation (`int | None`, which is what every nullable +// parameter in this node uses) is a types.UnionType with no __origin__, so the +// unwrap never fires and the value falls through the ladder to +// "Pass through for complex/unknown types". Observed: with repo_url set, +// `max_concurrent_hunters: "4"`, `max_cost_usd: "2.5"`, `output_formats: +// "json"` and `max_duration_seconds: "abc"` all leave the validator UNCHANGED. +// +// The rejection happens one layer later, in pydantic's lax validation of the +// model the handler builds — where "4" -> 4 and "2.5" -> 2.5 are accepted while +// `commit_sha: 123`, `output_formats: "json"` and `max_duration_seconds: "abc"` +// raise (all four verified against CloudSecurityInput in the repo venv). So the +// port coerces an optional int/float — that is the only case where pydantic +// ACCEPTS a value encoding/json would refuse — and otherwise passes the value +// through untouched, letting the Bind decode reject exactly what pydantic +// rejects. +func coerceField(f Field, value any) (any, error) { + if f.Optional { + switch f.Type { + case TypeInt: + if n, ok := pyInt(value); ok { + return n, nil + } + case TypeFloat: + if x, ok := pyFloat(value); ok { + return x, nil + } + } + return value, nil + } + + switch f.Type { + case TypeInt: + n, ok := pyInt(value) + if !ok { + return nil, invalidValue(f.Name) + } + return n, nil + case TypeFloat: + x, ok := pyFloat(value) + if !ok { + return nil, invalidValue(f.Name) + } + return x, nil + case TypeStr: + // Python str(value) never fails. + return pyStr(value), nil + case TypeBool: + return pyBool(value), nil + case TypeDict: + if _, ok := value.(map[string]any); !ok { + return nil, &InputError{Message: fmt.Sprintf("Field '%s' must be a dict", f.Name)} + } + return value, nil + case TypeList: + if _, ok := value.([]any); !ok { + return nil, &InputError{Message: fmt.Sprintf("Field '%s' must be a list", f.Name)} + } + return value, nil + } + return value, nil +} + +// pyStr is `str(value)` for a value that came out of `json.loads` — the +// coercion agent.py's str branch (`result[name] = str(value)`) applies to every +// non-Optional `str` parameter (scan/prove's repo_url, depth, branch, +// severity_threshold, cloud_provider, and every router reasoner's path params). +// +// PYTHON PARITY — INTEGER LITERALS. CPython's json.loads makes an `int` out of +// a literal with no "." and no exponent, so `{"depth": 4}` reaches the +// validator as int 4 and `str(4)` is "4". The Go SDK decodes the request body +// with a plain encoding/json decoder and no UseNumber +// (sdk/go/agent/agent.go handleReasoner), which collapses EVERY JSON number to +// float64 before the handler sees it — and pyfmt.Str(float64(4)) is Python's +// str(4.0), i.e. "4.0". Rendering an integral float64 with the integer spelling +// restores the int the literal actually carried. Verified against the repo +// venv's installed validator: `Agent._validate_handler_input` on +// json.loads('{"repo_url":123,"depth":4,"branch":7.0}') with those three +// declared `str` returns {'repo_url': '123', 'depth': '4', 'branch': '7.0'}. +// +// DIVERGENCE (unavoidable here, and it is the RARER half): a literal written +// with an explicit fraction — `{"depth": 4.0}` — is a Python float and +// str()s to "4.0", but by the time this code runs it is indistinguishable from +// the integer literal `4`. The int spelling is chosen because JSON encoders +// emit `4`, not `4.0`, for an integer. Above 2^53 the two spellings stop +// round-tripping through float64 at all, so those keep the float rendering +// (Python str(1e30) == "1e+30", which pyfmt.Repr already produces). +func pyStr(value any) string { + return pyfmt.Str(jsonIntegers(value)) +} + +// jsonIntegers undoes the SDK decoder's collapse of every JSON number onto +// float64, restoring the `int` CPython's json.loads would have produced, so +// pyfmt.Str/Repr spell it the way Python's str() does. It walks containers +// because str() of a list or dict is a repr that recurses (verified: +// str(json.loads('[1, "x"]')) == "[1, 'x']", not "[1.0, 'x']"). +// +// Only float64 is rewritten; strings, bools and nil are Python's own kinds +// already. See pyStr for the one literal this cannot recover (`4.0`). +func jsonIntegers(value any) any { + switch x := value.(type) { + case float64: + if x == math.Trunc(x) && !math.IsInf(x, 0) && math.Abs(x) < 1<<53 { + return int(x) + } + return x + case []any: + out := make([]any, len(x)) + for i := range x { + out[i] = jsonIntegers(x[i]) + } + return out + case map[string]any: + out := make(map[string]any, len(x)) + for k, v := range x { + out[k] = jsonIntegers(v) + } + return out + } + return value +} + +// invalidValue is the message agent.py:1244 raises for a failed int()/float(). +// Python deliberately drops the inner exception's text. +func invalidValue(name string) error { + return &InputError{Message: fmt.Sprintf("Invalid value for field '%s'", name)} +} + +// pyInt is `int(value)` for the value kinds a JSON body can produce. +// +// bool -> 1 / 0 +// int / float -> truncated TOWARD ZERO (int(5.9) == 5, int(-5.9) == -5) +// str -> base-10 parse of the stripped text; int("4.0") is a +// ValueError in Python and is rejected here too +// anything else -> TypeError +func pyInt(v any) (int, bool) { + switch x := v.(type) { + case bool: + if x { + return 1, true + } + return 0, true + case int: + return x, true + case int64: + return int(x), true + case float64: + if math.IsNaN(x) || math.IsInf(x, 0) { + return 0, false + } + return int(math.Trunc(x)), true + case json.Number: + if n, err := strconv.ParseInt(x.String(), 10, 64); err == nil { + return int(n), true + } + if fl, err := x.Float64(); err == nil { + return int(math.Trunc(fl)), true + } + return 0, false + case string: + n, err := strconv.ParseInt(strings.TrimSpace(x), 10, 64) + if err != nil { + return 0, false + } + return int(n), true + } + return 0, false +} + +// pyFloat is `float(value)`. +// +// NON-FINITE RESULTS ARE DECLINED, and that is deliberate. Python's float() +// accepts "NaN", "nan", "Infinity", "inf" and the overflowing "1e999", and +// pydantic's lax mode accepts them too — verified in the repo venv: +// `CloudSecurityInput(repo_url="/tmp", max_cost_usd="NaN")` yields nan, and the +// scan then RUNS, because every budget comparison against nan/inf is False. +// Go cannot carry that value: `Bind` round-trips the validated map through +// encoding/json, which refuses to marshal a non-finite float64 (RFC 8259 has no +// literal for one), so coercing "NaN" here turned a scan into a 400 whose body +// was the Go-internals string "afx.Bind: marshal input: json: unsupported +// value: NaN". Declining instead leaves the raw string in place, so the request +// fails the way every other uncoercible optional parameter does. The residual +// divergence — Python runs the scan, Go rejects the body — is recorded in +// go/README.md's divergence list; it is not fixable without a JSON encoding for +// nan/inf on both sides. +// +// pyInt has the same guard (`math.IsNaN(x) || math.IsInf(x, 0)`), for the same +// reason. +func pyFloat(v any) (float64, bool) { + switch x := v.(type) { + case bool: + if x { + return 1, true + } + return 0, true + case int: + return float64(x), true + case int64: + return float64(x), true + case float64: + return x, finite(x) + case json.Number: + f, err := x.Float64() + return f, err == nil && finite(f) + case string: + f, err := strconv.ParseFloat(strings.TrimSpace(x), 64) + return f, err == nil && finite(f) + } + return 0, false +} + +// finite reports whether f survives a JSON round trip. +func finite(f float64) bool { return !math.IsNaN(f) && !math.IsInf(f, 0) } + +// pyBool is agent.py:1211-1216: a bool passes through, a str is matched against +// the ("true", "1", "yes") whitelist (case-insensitively), and everything else +// goes through Python's bool() truthiness. +func pyBool(v any) bool { + switch x := v.(type) { + case bool: + return x + case string: + switch strings.ToLower(x) { + case "true", "1", "yes": + return true + } + return false + } + return truthy(v) +} diff --git a/go/internal/afx/handlerinput_str_test.go b/go/internal/afx/handlerinput_str_test.go new file mode 100644 index 0000000..44c132e --- /dev/null +++ b/go/internal/afx/handlerinput_str_test.go @@ -0,0 +1,84 @@ +package afx + +import ( + "encoding/json" + "testing" +) + +// VALIDATION CONTRACT — `str(value)` on a JSON number. +// +// Python's reasoner endpoint runs the body through +// `Agent._validate_handler_input`, whose `str` branch is `result[name] = str(value)`. +// CPython's json.loads makes an INT out of a literal with no "." and no +// exponent, so an integer literal str()s without a fraction. Ground truth from +// the repo venv, calling the INSTALLED validator on +// +// json.loads('{"repo_url":123,"depth":4,"branch":7.0,"severity_threshold":2.5,"cloud_provider":1e30}') +// +// with all five declared `str`: +// +// {'repo_url': '123', 'depth': '4', 'branch': '7.0', +// 'severity_threshold': '2.5', 'cloud_provider': '1e+30'} +// +// The Go SDK's body decoder has no UseNumber, so every number reaches the +// handler as float64 and the integer literals are indistinguishable from +// `123.0` / `4.0` by the time this runs. The port renders an integral float64 +// with the integer spelling — the reading that matches what JSON encoders +// actually emit for an integer — which is the divergence noted on pyStr. +func TestValidateHandlerInput_StrCoercionOfJSONNumbers(t *testing.T) { + var body map[string]any + if err := json.Unmarshal([]byte(`{"repo_url":123,"depth":4,"branch":7.0,"severity_threshold":2.5,"cloud_provider":1e30}`), &body); err != nil { + t.Fatalf("decode body: %v", err) + } + + fields := []Field{ + {Name: "repo_url", Type: TypeStr, Required: true}, + {Name: "depth", Type: TypeStr}, + {Name: "branch", Type: TypeStr}, + {Name: "severity_threshold", Type: TypeStr}, + {Name: "cloud_provider", Type: TypeStr}, + } + got, err := ValidateHandlerInput(body, fields) + if err != nil { + t.Fatalf("ValidateHandlerInput: %v", err) + } + + want := map[string]any{ + "repo_url": "123", + "depth": "4", + // `7.0` is written with an explicit fraction, so Python str()s it + // "7.0" — but the SDK's decoder erased the literal, and this port + // resolves the ambiguity toward the integer spelling. + "branch": "7", + "severity_threshold": "2.5", + "cloud_provider": "1e+30", + } + for name, wantValue := range want { + if got[name] != wantValue { + t.Errorf("%s = %#v, want %#v", name, got[name], wantValue) + } + } +} + +// A string parameter that already carries a string is untouched (str(s) is s), +// and every other scalar keeps Python's repr spelling. +func TestValidateHandlerInput_StrCoercionOfNonNumbers(t *testing.T) { + got, err := ValidateHandlerInput( + map[string]any{"a": "already", "b": true, "c": []any{1.0, "x"}}, + []Field{ + {Name: "a", Type: TypeStr}, + {Name: "b", Type: TypeStr}, + {Name: "c", Type: TypeStr}, + }, + ) + if err != nil { + t.Fatalf("ValidateHandlerInput: %v", err) + } + // Verified against the venv: str(True) == 'True' and + // str(json.loads('[1, "x"]')) == "[1, 'x']". + for name, want := range map[string]any{"a": "already", "b": "True", "c": `[1, 'x']`} { + if got[name] != want { + t.Errorf("%s = %#v, want %#v", name, got[name], want) + } + } +} diff --git a/go/internal/afx/lax.go b/go/internal/afx/lax.go new file mode 100644 index 0000000..6c9d036 --- /dev/null +++ b/go/internal/afx/lax.go @@ -0,0 +1,445 @@ +package afx + +import ( + "encoding/json" + "fmt" + "math" + "reflect" + "sort" + "strconv" + "strings" +) + +// lax.go restores the OTHER half of `Model.model_validate(payload)` that a JSON +// round-trip gets wrong — the scalar rules. +// +// required.go covers the fields pydantic raises `missing` for. This file covers +// the two ways `encoding/json` and pydantic v2's LAX mode disagree about a +// value that IS present: +// +// 1. pydantic COERCES scalars encoding/json refuses. `"12"` is a valid int, +// `"5.5"` a valid float, `1` / `"true"` a valid bool. Without this the Go +// node rejected bodies the Python node proves, and — worse — rejected +// harness output the Python node accepts, which costs a whole hunter's +// findings (hunt_phase swallows a failed hunter into an empty batch). +// 2. pydantic REJECTS an explicit `null` for a field that is not `X | None`, +// while encoding/json treats `null` as a no-op for a scalar/struct and as +// "set to nil" for a slice/map — so the seeded pydantic default survived a +// `null` the Python node answers 422 for, and a seeded `[]` was silently +// wiped back to `null` in the re-dump. +// +// Both were measured against the repo venv (pydantic 2.13.4) rather than read +// off the docs; the ladders below cite the observed results. +// +// SCOPE. These rules apply to the ported PYDANTIC MODELS only — the types in +// internal/schemas that declare PydanticModel() — and to everything reachable +// from one. They must NOT apply to the reasoner INPUT structs +// (node.ScanInput, phases.ChainPhaseInput, …): those stand for a Python +// FUNCTION SIGNATURE, whose validation is `Agent._validate_handler_input` and +// is ported in handlerinput.go with its own, different ladder (an optional +// parameter is not coerced at all there, and `repo_url: null` becomes the +// string "None" rather than an error). + +// PydanticModel marks a Go struct as a port of a pydantic BaseModel, i.e. a +// type whose Go-side bind stands in for a `Model.model_validate(...)` call. +// internal/schemas declares it once per model in model.go. +type PydanticModel interface{ PydanticModel() } + +// NullableFielder is implemented by a PydanticModel that has a field which +// accepts `None` in Python but is NOT a Go pointer. +// +// Every `X | None` field in this port maps to a Go pointer EXCEPT +// CloudSecurityInput.include_paths (`list[str] | None` -> `[]string`, because +// Python's own code treats None and [] identically there). Declaring the +// exception is cheaper, and far more legible, than pointerizing the slice. +type NullableFielder interface{ NullableFields() []string } + +// InvalidTypeError is the Go stand-in for the pydantic ValidationError raised +// when a value has the wrong TYPE — in this port, always an explicit null for a +// field that is not `X | None`. +// +// DIVERGENCE (message text only, same trade-off as MissingFieldError): +// pydantic renders "1 validation error for RawFinding\n resources\n Input +// should be a valid list [type=list_type, input_value=None, ...]". The Go text +// keeps the model, the field and the expectation, which is what the phases +// surface (prove_phase embeds it in the fallback finding's evidence string). +type InvalidTypeError struct { + Model string + Field string + Want string +} + +func (e *InvalidTypeError) Error() string { + return fmt.Sprintf("1 validation error for %s: %s: Input should be a valid %s", + e.Model, e.Field, e.Want) +} + +// coerceLax rewrites payload so that every value pydantic's lax mode would +// coerce is coerced before the decode sees it, and reports the nulls pydantic +// would reject. Values it has no rule for are returned untouched, so the decode +// stays the thing that rejects what pydantic also rejects (`iac_line: 12.5`, +// `title: 5`, `confidence: "HIGH"`). +func coerceLax(payload any, t reflect.Type) (any, error) { + return laxWalk(payload, t, false) +} + +// laxWalk is the recursive worker. `strict` reports whether this position is +// inside a PydanticModel tree; it turns on at the first model struct and stays +// on for everything below. +func laxWalk(payload any, t reflect.Type, strict bool) (any, error) { + t = derefType(t) + if t == nil || t.Kind() == reflect.Interface { + // `Any` in Python: pydantic stores whatever it is handed. + return payload, nil + } + + switch value := payload.(type) { + case map[string]any: + if t.Kind() == reflect.Map { + return laxMap(value, t, strict) + } + if t.Kind() != reflect.Struct { + return payload, nil + } + return laxStruct(value, t, strict) + case []any: + if t.Kind() != reflect.Slice && t.Kind() != reflect.Array { + return payload, nil + } + return laxSlice(value, t, strict) + } + + if !strict { + return payload, nil + } + return laxScalar(payload, t), nil +} + +// laxMap walks a `dict[str, V]`: the KEYS are data, the values may be models. +func laxMap(value map[string]any, t reflect.Type, strict bool) (any, error) { + out := make(map[string]any, len(value)) + for k, v := range value { + nv, err := laxWalk(v, t.Elem(), strict) + if err != nil { + return nil, err + } + out[k] = nv + } + return out, nil +} + +// laxSlice walks a `list[V]`. +func laxSlice(value []any, t reflect.Type, strict bool) (any, error) { + elem := t.Elem() + out := make([]any, len(value)) + for i, v := range value { + if v == nil && strict && !acceptsNull(elem, false) { + return nil, &InvalidTypeError{Model: modelName(elem), Field: strconv.Itoa(i), Want: pyTypeWord(elem)} + } + nv, err := laxWalk(v, elem, strict) + if err != nil { + return nil, err + } + out[i] = nv + } + return out, nil +} + +// laxStruct walks one object against a Go struct. Undeclared keys are left +// alone — pydantic's default `extra="ignore"` drops them, and so does the +// decode. +func laxStruct(value map[string]any, t reflect.Type, strict bool) (any, error) { + fieldStrict := strict || isPydanticModel(t) + fields := jsonFieldTypes(t) + nullable := nullableFields(t) + + out := make(map[string]any, len(value)) + for k, v := range value { + out[k] = v + } + // Deterministic order so the reported field is stable run to run. + keys := make([]string, 0, len(value)) + for k := range value { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + ft, declared := fields[k] + if !declared { + continue + } + if value[k] == nil { + if fieldStrict && !acceptsNull(ft, nullable[k]) { + return nil, &InvalidTypeError{Model: t.Name(), Field: k, Want: pyTypeWord(ft)} + } + continue + } + nv, err := laxWalk(value[k], ft, fieldStrict) + if err != nil { + return nil, err + } + out[k] = nv + } + return out, nil +} + +// acceptsNull reports whether a JSON null is a valid value for a field of type +// ft. Every `X | None` field in the port is a Go pointer, `Any` is an +// interface, and the handful of declared exceptions come in via nullable. +func acceptsNull(ft reflect.Type, declaredNullable bool) bool { + if declaredNullable { + return true + } + switch ft.Kind() { + case reflect.Pointer, reflect.Interface: + return true + } + return false +} + +// pyTypeWord is the noun pydantic uses in "Input should be a valid ...". +func pyTypeWord(ft reflect.Type) string { + ft = derefType(ft) + switch ft.Kind() { + case reflect.Slice, reflect.Array: + return "list" + case reflect.Map: + return "dictionary" + case reflect.Bool: + return "boolean" + case reflect.String: + return "string" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return "integer" + case reflect.Float32, reflect.Float64: + return "number" + case reflect.Struct: + return "dictionary or instance of " + ft.Name() + } + return ft.Name() +} + +// modelName is the type name to report for a bare element position. +func modelName(ft reflect.Type) string { + ft = derefType(ft) + if ft == nil || ft.Name() == "" { + return "list item" + } + return ft.Name() +} + +func derefType(t reflect.Type) reflect.Type { + for t != nil && t.Kind() == reflect.Pointer { + t = t.Elem() + } + return t +} + +func isPydanticModel(t reflect.Type) bool { + if t.Kind() != reflect.Struct { + return false + } + _, ok := reflect.New(t).Interface().(PydanticModel) + return ok +} + +func nullableFields(t reflect.Type) map[string]bool { + nf, ok := reflect.New(t).Interface().(NullableFielder) + if !ok { + return nil + } + out := map[string]bool{} + for _, name := range nf.NullableFields() { + out[name] = true + } + return out +} + +// --------------------------------------------------------------------------- +// the scalar ladders +// --------------------------------------------------------------------------- + +// laxScalar applies pydantic v2's LAX scalar coercion for the target kind. +// +// It only ever REWRITES a value the decode would refuse but pydantic accepts. +// Anything else is returned untouched so the decode reports it, which keeps the +// two implementations rejecting the same set (measured in the repo venv: +// `iac_line: 12.5` -> int_from_float and `title: 5` -> string_type are errors +// on BOTH sides, and pydantic does NOT coerce a number or a bool to a str). +func laxScalar(v any, t reflect.Type) any { + switch t.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if n, ok := laxInt(v); ok { + return n + } + case reflect.Float32, reflect.Float64: + if f, ok := laxFloat(v); ok { + return f + } + case reflect.Bool: + if b, ok := laxBool(v); ok { + return b + } + } + return v +} + +// laxInt is pydantic's lax `int` ladder, verified field by field against +// RawFinding.iac_line in the repo venv: +// +// True/False -> 1/0 12.0 -> 12 12.5 -> int_from_float (ERR) +// "12" -> 12 " 12 " -> 12 "+12"/"-12" -> 12/-12 +// "012" -> 12 "1_000" -> 1000 "12.0" -> 12 +// "12." / "12.5" / "2e2" / "0x10" / "abc" / "" -> int_parsing (ERR) +// None -> int_type (ERR, handled one level up) +func laxInt(v any) (any, bool) { + switch x := v.(type) { + case bool: + if x { + return 1, true + } + return 0, true + case float64: + if x == math.Trunc(x) && !math.IsInf(x, 0) && !math.IsNaN(x) { + return x, true // already an integral JSON number; the decode takes it + } + case json.Number: + if n, err := strconv.ParseInt(x.String(), 10, 64); err == nil { + return n, true + } + if f, err := x.Float64(); err == nil && f == math.Trunc(f) && !math.IsInf(f, 0) { + return int64(f), true + } + case string: + if n, ok := pydanticIntFromString(x); ok { + return n, true + } + } + return nil, false +} + +// pydanticIntFromString implements pydantic-core's str -> int parser: ASCII +// whitespace is stripped, `_` may separate digits, an optional sign is allowed, +// and a fractional part is accepted ONLY when it is all zeros ("12.0" is 12, +// "12." and "12.5" are errors). Exponents and non-decimal bases are rejected. +func pydanticIntFromString(s string) (int64, bool) { + text, ok := stripDigitSeparators(strings.TrimSpace(s)) + if !ok { + return 0, false + } + if n, err := strconv.ParseInt(text, 10, 64); err == nil { + return n, true + } + mantissa, fraction, hasDot := strings.Cut(text, ".") + if !hasDot || fraction == "" || strings.Trim(fraction, "0") != "" { + return 0, false + } + n, err := strconv.ParseInt(mantissa, 10, 64) + if err != nil { + return 0, false + } + return n, true +} + +// laxFloat is pydantic's lax `float` ladder, verified against +// VerifiedFinding.risk_score in the repo venv: +// +// True/False -> 1.0/0.0 5 -> 5.0 "5" -> 5.0 +// "5.5" / " 5.5 " / "+5.5" / ".5" / "5." / "1e3" / "1_0.5" -> the number +// "" / "abc" -> float_parsing (ERR) None -> float_type (ERR) +// +// DIVERGENCE (bounded, and unrepresentable in JSON): pydantic also accepts +// "NaN", "inf", "Infinity" and the overflowing "1e999", yielding nan/±inf. +// encoding/json cannot marshal a non-finite float64 — RFC 8259 has no literal +// for one — so a non-finite result is declined here and the raw string reaches +// the decode, which rejects it with the ordinary +// "cannot unmarshal string into ... of type float64". Go therefore rejects four +// spellings Python accepts; see go/README.md's divergence list. +func laxFloat(v any) (any, bool) { + switch x := v.(type) { + case bool: + if x { + return 1.0, true + } + return 0.0, true + case string: + text, ok := stripDigitSeparators(strings.TrimSpace(x)) + if !ok { + return nil, false + } + f, err := strconv.ParseFloat(text, 64) + if err != nil || math.IsNaN(f) || math.IsInf(f, 0) { + return nil, false + } + return f, true + } + return nil, false +} + +// laxBool is pydantic's lax `bool` ladder, verified against +// DriftedResource.security_relevant in the repo venv: +// +// 1 / 1.0 -> True 0 / 0.0 -> False +// 2 / -1 -> bool_parsing (ERR) 1.5 -> bool_type (ERR) +// "true","True","TRUE","yes","on","1","t","y" -> True (case-insensitive) +// "false","no","off","0","f","n" -> False +// "" / "abc" -> bool_parsing (ERR) None -> bool_type (ERR) +func laxBool(v any) (any, bool) { + switch x := v.(type) { + case int: + return laxBoolFromFloat(float64(x)) + case int64: + return laxBoolFromFloat(float64(x)) + case float64: + return laxBoolFromFloat(x) + case json.Number: + if f, err := x.Float64(); err == nil { + return laxBoolFromFloat(f) + } + case string: + switch strings.ToLower(strings.TrimSpace(x)) { + case "true", "t", "yes", "y", "on", "1": + return true, true + case "false", "f", "no", "n", "off", "0": + return false, true + } + } + return nil, false +} + +// stripDigitSeparators removes the `_` separators pydantic allows inside a +// numeric string ("1_000" is 1000, "1_0.5" is 10.5). A `_` that is not between +// two digits is a parse error, which is reported by returning false. +func stripDigitSeparators(s string) (string, bool) { + if !strings.Contains(s, "_") { + return s, true + } + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] != '_' { + b.WriteByte(s[i]) + continue + } + if i == 0 || i == len(s)-1 || !isASCIIDigit(s[i-1]) || !isASCIIDigit(s[i+1]) { + return "", false + } + } + return b.String(), true +} + +func isASCIIDigit(c byte) bool { return c >= '0' && c <= '9' } + +// laxBoolFromFloat is the numeric half of the bool ladder: pydantic accepts +// only 0 and 1 (int or float); 2, -1 and 1.5 are all errors. +func laxBoolFromFloat(f float64) (any, bool) { + switch f { + case 0: + return false, true + case 1: + return true, true + } + return nil, false +} diff --git a/go/internal/afx/lax_test.go b/go/internal/afx/lax_test.go new file mode 100644 index 0000000..2b4be38 --- /dev/null +++ b/go/internal/afx/lax_test.go @@ -0,0 +1,250 @@ +package afx + +import ( + "strings" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// VALIDATION CONTRACT for the pydantic-lax half of afx.Bind (lax.go). +// +// Every expectation below was measured against the repo venv (pydantic 2.13.4, +// ~/.agentfield/packages/cloudsecurity-af/venv/bin/python, PYTHONPATH=src) by +// calling the REAL models. Transcript, abridged to the rows the tables use: +// +// RawFinding.iac_line (int): +// "12" -> 12 " 12 " -> 12 "+12"/"-12" -> 12/-12 "012" -> 12 +// "1_000" -> 1000 "12.0" -> 12 12.0 -> 12 True/False -> 1/0 +// "12." / "12.5" / "2e2" / "0x10" / "abc" / "" -> ERR int_parsing +// 12.5 -> ERR int_from_float None -> ERR int_type +// VerifiedFinding.risk_score (float): +// "5.5" -> 5.5 " 5.5 " -> 5.5 "5" -> 5.0 "+5.5" -> 5.5 +// ".5" -> 0.5 "5." -> 5.0 "1e3" -> 1000.0 "1_0.5" -> 10.5 +// 5 -> 5.0 True -> 1.0 +// "" / "abc" -> ERR float_parsing None -> ERR float_type +// DriftedResource.security_relevant (bool): +// 1/0 -> True/False 1.0/0.0 -> True/False +// "true","True","TRUE","yes","on","1","t","y" -> True +// "false","no","off","0","f","n" -> False +// 2 / -1 -> ERR bool_parsing 1.5 -> ERR bool_type None -> ERR bool_type +// RawFinding.title (str): +// 5 / 5.5 / True / None -> ERR string_type (pydantic does NOT stringify) +// Nulls: +// RawFinding{"resources": null} -> ERR +// RawFinding{"iac_line": null} -> ERR +// Proof{"evidence": null} -> ERR +// VerifiedFinding{"compliance_mappings": null} -> ERR +// RawFinding{"benchmark_id": null} -> OK (`str | None`) +// CloudSecurityInput{"include_paths": null} -> OK (`list[str] | None`) +// RawFinding{"confidence": null} -> ERR (already: strict enum) + +func rawFindingBody(extra map[string]any) map[string]any { + body := map[string]any{ + "hunter_strategy": "iam", + "title": "t", + "description": "d", + "category": "c", + } + for k, v := range extra { + body[k] = v + } + return body +} + +func verifiedFindingBody(extra map[string]any) map[string]any { + body := map[string]any{ + "title": "t", + "verdict": "confirmed", + "severity": "medium", + "category": "c", + } + for k, v := range extra { + body[k] = v + } + return body +} + +// CONTRACT 1 — a value pydantic's lax mode coerces must bind, not 422. +// +// This is the finding-losing case: the Go SDK's harness validates a model reply +// with a plain json.Unmarshal, so a hunter that writes `"iac_line": "12"` used +// to burn the schema-retry budget and end as `iam_hunter harness error: ...`, +// which hunt_phase swallows into an EMPTY batch — that hunter contributes zero +// findings where the Python node contributes all of them. +func TestBind_CoercesTheScalarsPydanticCoerces(t *testing.T) { + t.Run("int", func(t *testing.T) { + cases := []struct { + in any + want int + }{ + {"12", 12}, {" 12 ", 12}, {"+12", 12}, {"-12", -12}, {"012", 12}, + {"1_000", 1000}, {"12.0", 12}, {12.0, 12}, {true, 1}, {false, 0}, + } + for _, tc := range cases { + got, err := Bind[schemas.RawFinding](rawFindingBody(map[string]any{"iac_line": tc.in})) + if err != nil { + t.Fatalf("iac_line=%#v: %v", tc.in, err) + } + if got.IaCLine != tc.want { + t.Errorf("iac_line=%#v -> %d, want %d", tc.in, got.IaCLine, tc.want) + } + } + }) + + t.Run("float", func(t *testing.T) { + cases := []struct { + in any + want float64 + }{ + {"5.5", 5.5}, {" 5.5 ", 5.5}, {"5", 5}, {"+5.5", 5.5}, {".5", 0.5}, + {"5.", 5}, {"1e3", 1000}, {"1_0.5", 10.5}, {5, 5}, {true, 1}, + } + for _, tc := range cases { + got, err := Bind[schemas.VerifiedFinding](verifiedFindingBody(map[string]any{"risk_score": tc.in})) + if err != nil { + t.Fatalf("risk_score=%#v: %v", tc.in, err) + } + if got.RiskScore != tc.want { + t.Errorf("risk_score=%#v -> %v, want %v", tc.in, got.RiskScore, tc.want) + } + } + }) + + t.Run("bool", func(t *testing.T) { + cases := []struct { + in any + want bool + }{ + {1, true}, {0, false}, {1.0, true}, {0.0, false}, + {"true", true}, {"True", true}, {"TRUE", true}, {"yes", true}, + {"on", true}, {"1", true}, {"t", true}, {"y", true}, + {"false", false}, {"no", false}, {"off", false}, {"0", false}, + {"f", false}, {"n", false}, + } + for _, tc := range cases { + got, err := Bind[schemas.DriftedResource](map[string]any{ + "resource_id": "r", "resource_type": "t", "security_relevant": tc.in, + }) + if err != nil { + t.Fatalf("security_relevant=%#v: %v", tc.in, err) + } + if got.SecurityRelevant != tc.want { + t.Errorf("security_relevant=%#v -> %v, want %v", tc.in, got.SecurityRelevant, tc.want) + } + } + }) +} + +// CONTRACT 2 — the ladder must not become a general "accept anything". Every +// row here is a pydantic ValidationError, so the bind must fail too. +func TestBind_RejectsTheScalarsPydanticRejects(t *testing.T) { + intCases := []any{12.5, "12.", "12.5", "2e2", "0x10", "abc", "", []any{1}} + for _, in := range intCases { + if _, err := Bind[schemas.RawFinding](rawFindingBody(map[string]any{"iac_line": in})); err == nil { + t.Errorf("iac_line=%#v bound; pydantic raises", in) + } + } + floatCases := []any{"", "abc"} + for _, in := range floatCases { + if _, err := Bind[schemas.VerifiedFinding](verifiedFindingBody(map[string]any{"risk_score": in})); err == nil { + t.Errorf("risk_score=%#v bound; pydantic raises", in) + } + } + boolCases := []any{2, -1, 1.5, "", "abc"} + for _, in := range boolCases { + if _, err := Bind[schemas.DriftedResource](map[string]any{ + "resource_id": "r", "resource_type": "t", "security_relevant": in, + }); err == nil { + t.Errorf("security_relevant=%#v bound; pydantic raises", in) + } + } + // pydantic v2 does NOT stringify a number/bool for a `str` field. + for _, in := range []any{5, 5.5, true} { + if _, err := Bind[schemas.RawFinding](rawFindingBody(map[string]any{"title": in})); err == nil { + t.Errorf("title=%#v bound; pydantic raises string_type", in) + } + } +} + +// CONTRACT 3 — an explicit null for a field that is not `X | None` is a +// pydantic ValidationError. encoding/json treats null as a no-op for a scalar +// and as "set to nil" for a slice, so without lax.go the seeded default +// survived (iac_line stayed 0) or the seeded `[]` was wiped back to null. +func TestBind_RejectsNullForANonOptionalField(t *testing.T) { + if _, err := Bind[schemas.RawFinding](rawFindingBody(map[string]any{"resources": nil})); err == nil { + t.Error("resources=null bound; pydantic raises list_type") + } else if got := err.Error(); !strings.Contains(got, "RawFinding: resources: Input should be a valid list") { + t.Errorf("err = %q", got) + } + if _, err := Bind[schemas.RawFinding](rawFindingBody(map[string]any{"iac_line": nil})); err == nil { + t.Error("iac_line=null bound; pydantic raises int_type") + } + if _, err := Bind[schemas.Proof](map[string]any{"evidence": nil}); err == nil { + t.Error("evidence=null bound; pydantic raises list_type") + } + if _, err := Bind[schemas.VerifiedFinding](verifiedFindingBody(map[string]any{"compliance_mappings": nil})); err == nil { + t.Error("compliance_mappings=null bound; pydantic raises list_type") + } + // Nested: the rule follows a model into a list of models. + if _, err := Bind[schemas.HuntResult](map[string]any{ + "findings": []any{rawFindingBody(map[string]any{"resources": nil})}, + }); err == nil { + t.Error("findings[0].resources=null bound; pydantic raises") + } +} + +// CONTRACT 4 — the mirror image: a null the Python node ACCEPTS must still +// bind, or the rule would break every `X | None` field in the port. +func TestBind_AcceptsNullForAnOptionalField(t *testing.T) { + got, err := Bind[schemas.RawFinding](rawFindingBody(map[string]any{"benchmark_id": nil})) + if err != nil { + t.Fatalf("benchmark_id=null: %v", err) + } + if got.BenchmarkID != nil { + t.Errorf("benchmark_id = %v, want nil", *got.BenchmarkID) + } + // include_paths is the ONE `X | None` field that is not a Go pointer; the + // exception is declared by CloudSecurityInput.NullableFields. + in, err := Bind[schemas.CloudSecurityInput](map[string]any{"repo_url": "/r", "include_paths": nil}) + if err != nil { + t.Fatalf("include_paths=null: %v", err) + } + if in.IncludePaths != nil { + t.Errorf("include_paths = %v, want nil", in.IncludePaths) + } + // A null inside a free-form `dict[str, Any]` is an ordinary value. + drift, err := Bind[schemas.DriftedResource](map[string]any{ + "resource_id": "r", "resource_type": "t", + "iac_config": map[string]any{"logging": nil}, + }) + if err != nil { + t.Fatalf("iac_config={logging: null}: %v", err) + } + if v, ok := drift.IaCConfig["logging"]; !ok || v != nil { + t.Errorf("iac_config = %#v, want the null preserved", drift.IaCConfig) + } +} + +// CONTRACT 5 — the rules are for MODELS only. A reasoner input struct stands +// for a Python function signature, whose validation is +// Agent._validate_handler_input (handlerinput.go), and there `drift_report: +// null` is simply the parameter's default None, not an error. +func TestBind_LeavesReasonerInputStructsAlone(t *testing.T) { + type chainPhaseInputLike struct { + Findings []any `json:"findings"` + ResourceGraphPath string `json:"resource_graph_path"` + DriftReport map[string]any `json:"drift_report"` + } + got, err := Bind[chainPhaseInputLike](map[string]any{ + "findings": []any{1, 2, "x"}, + "resource_graph_path": "/g", + "drift_report": nil, + }) + if err != nil { + t.Fatalf("Bind: %v", err) + } + if len(got.Findings) != 3 || got.DriftReport != nil { + t.Fatalf("got = %#v", got) + } +} diff --git a/go/internal/afx/payload.go b/go/internal/afx/payload.go new file mode 100644 index 0000000..5341aa5 --- /dev/null +++ b/go/internal/afx/payload.go @@ -0,0 +1,257 @@ +package afx + +import ( + "encoding/json" + "fmt" + "reflect" + "sort" + "strings" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" +) + +// payload.go is the reasoner RETURN boundary — the Go stand-in for the dict a +// Python reasoner hands back. +// +// Python returns `result.model_dump()` (or a dict literal), FastAPI serialises +// it with json.dumps, and json.dumps preserves a dict's insertion order. So the +// wire body starts +// +// {"repository": ..., "commit_sha": ..., "branch": ..., "timestamp": ... +// +// in pydantic FIELD-DECLARATION order, and every float field is spelled the +// Python way: `"risk_score": 0.0`, not `0`. +// +// A Go `map[string]any` return loses both: encoding/json SORTS map keys +// (agent_invocations, attack_paths, branch, by_severity, commit_sha, …) and +// renders float64(0) as `0`. Payload keeps the order the model declares and +// delegates the value spelling to pyfmt.Dumps, which is the same renderer the +// port already uses for every on-disk artifact. +// +// SDK CAVEAT (unavoidable from here): when an execution recorded LLM usage, the +// Go SDK re-encodes the handler's result through a map[string]any to merge its +// usage envelope (sdk/go/agent/usage.go wrapSyncResultWithUsage), which sorts +// the keys again. Payload therefore restores Python's byte order on the +// no-usage path and the VALUE spelling everywhere. + +// Payload is an insertion-ordered JSON object: the model_dump() of a pydantic +// model, or a reasoner's literal dict, with its key order intact. +// +// It is deliberately the same element type as pyfmt.Ordered so the two convert +// for free. It is a separate type because pyfmt.Ordered must NOT grow a +// MarshalJSON — pyfmt.Dumps checks json.Marshaler before its own Ordered +// branch, so that would make Dumps recurse into itself. +type Payload []pyfmt.KV + +// DictFieldOrder is implemented by a model that has map-typed fields whose +// Python dict INSERTION order is fixed and knowable, and returns that order +// keyed by json field name. +// +// A Python dict keeps insertion order and json.dumps honors it, so +// `model_dump()["by_severity"]` goes over the wire as +// {"critical": …, "high": …, "medium": …, "low": …, "info": …} — the Severity +// enum's declaration order, seeded at orchestrator.py:165. A Go map has no +// order, and every renderer in this port therefore SORTS its keys, which would +// put "info" second. Implementing this interface is how a model opts a field +// out of that sort; schemas.CloudSecurityScanResult does it for `by_severity` +// and `cost_breakdown`, the two dicts whose seeding is deterministic. +// +// Fields not named here keep the sorted rendering (`metadata` is the one that +// still does — its keys are assembled ad hoc and Python's order is not +// reproducible from the Go side). +type DictFieldOrder interface { + DictFieldOrder() map[string][]string +} + +// Dump renders a struct as a Payload in FIELD-DECLARATION order. +// +// Like ToMap it keeps the field VALUES typed rather than round-tripping them +// through JSON, so custom marshalers (schemas.Timestamp's isoformat spelling, +// the strict enums) still run at encode time. Untagged anonymous embedded +// structs are flattened the way encoding/json flattens them. +func Dump(v any) (Payload, error) { + rv := reflect.ValueOf(v) + for rv.Kind() == reflect.Pointer { + if rv.IsNil() { + return nil, fmt.Errorf("afx.Dump: nil %T", v) + } + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + return nil, fmt.Errorf("afx.Dump: %T is not a struct", v) + } + out := make(Payload, 0, rv.NumField()) + out = appendFields(out, rv) + if orderer, ok := rv.Interface().(DictFieldOrder); ok { + applyDictFieldOrder(out, orderer.DictFieldOrder()) + } + return out, nil +} + +// applyDictFieldOrder replaces every named map-typed field with an +// insertion-ordered pyfmt.Ordered, in place. +func applyDictFieldOrder(p Payload, orders map[string][]string) { + if len(orders) == 0 { + return + } + for i, kv := range p { + order, ok := orders[kv.K] + if !ok { + continue + } + p[i].V = orderedMap(kv.V, order) + } +} + +// orderedMap renders a Go map as a pyfmt.Ordered whose keys start with `order` +// (skipping any the map does not hold) and end with whatever is left, SORTED — +// a defensive tail Python cannot reach, kept deterministic. A non-map value is +// returned untouched. +func orderedMap(v any, order []string) any { + rv := reflect.ValueOf(v) + for rv.Kind() == reflect.Pointer || rv.Kind() == reflect.Interface { + if rv.IsNil() { + return v + } + rv = rv.Elem() + } + if rv.Kind() != reflect.Map || rv.IsNil() { + return v + } + byName := make(map[string]any, rv.Len()) + for _, k := range rv.MapKeys() { + byName[pyfmt.JSONMapKey(k)] = rv.MapIndex(k).Interface() + } + out := make(pyfmt.Ordered, 0, len(byName)) + seen := make(map[string]bool, len(order)) + for _, name := range order { + seen[name] = true + if val, present := byName[name]; present { + out = append(out, pyfmt.KV{K: name, V: val}) + } + } + rest := make([]string, 0, len(byName)) + for name := range byName { + if !seen[name] { + rest = append(rest, name) + } + } + sort.Strings(rest) + for _, name := range rest { + out = append(out, pyfmt.KV{K: name, V: byName[name]}) + } + return out +} + +// appendFields walks rv's fields in declaration order. +func appendFields(out Payload, rv reflect.Value) Payload { + 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 { + out = appendFields(out, fv) + continue + } + } + name = f.Name + } + out = append(out, pyfmt.KV{K: name, V: rv.Field(i).Interface()}) + } + return out +} + +// Get returns the value for key and whether it was present — Python's `d[k]` / +// `k in d`. +func (p Payload) Get(key string) (any, bool) { + return pyfmt.Ordered(p).Get(key) +} + +// Map returns the payload as a plain map, for callers (and tests) that only +// look values up by key. The order is lost, which is the whole reason Payload +// exists — do not feed the result back to a serializer. +func (p Payload) Map() map[string]any { + out := make(map[string]any, len(p)) + for _, kv := range p { + if _, seen := out[kv.K]; !seen { + out[kv.K] = kv.V + } + } + return out +} + +// MarshalJSON renders the payload exactly as `json.dumps(model_dump())` would: +// keys in insertion order, floats in Python's repr (`0.0`, not `0`), integers +// verbatim, non-ASCII escaped as \uXXXX. +// +// DIVERGENCE (documented, and the same thing FastAPI does): Python's json.dumps +// would emit NaN and Infinity, which are not valid JSON — but FastAPI's +// JSONResponse passes allow_nan=False and RAISES instead. pyfmt.Dumps +// reproduces the json.dumps spelling, so this method reports an error rather +// than putting an unparsable body on the wire. No arithmetic in the port can +// produce a non-finite float. +// +// Note that encoding/json COMPACTS a Marshaler's output, dropping pyfmt's +// ", " / ": " separators — which is also what FastAPI does +// (separators=(",", ":")), so the wire bytes agree. +func (p Payload) MarshalJSON() ([]byte, error) { + body := []byte(pyfmt.DumpsCompact(p.renderable())) + if !json.Valid(body) { + return nil, fmt.Errorf("afx.Payload: rendered body is not valid JSON (a non-finite float?): %s", body) + } + return body, nil +} + +// renderable converts the payload into the value model pyfmt.Dumps renders +// NATIVELY, replacing every nested Payload with a pyfmt.Ordered. +// +// This is load-bearing, not cosmetic. pyfmt.Dumps checks json.Marshaler before +// its own Ordered branch, and its Marshaler path decodes the produced bytes +// into a plain map before re-rendering — which SORTS the keys. So a Payload +// nested inside another Payload (prove_phase's `verified` list of +// model_dump(exclude_none=True) results) would come out alphabetised, undoing +// the very ordering this type exists to preserve. +func (p Payload) renderable() pyfmt.Ordered { + out := make(pyfmt.Ordered, len(p)) + for i, kv := range p { + out[i] = pyfmt.KV{K: kv.K, V: renderableValue(kv.V)} + } + return out +} + +// renderableValue converts the containers that can hold a Payload. Anything +// else — structs, typed slices, maps, scalars — is left alone for pyfmt.Dumps +// to walk natively. +func renderableValue(v any) any { + switch x := v.(type) { + case Payload: + return x.renderable() + case []Payload: + out := make([]any, len(x)) + for i := range x { + out[i] = x[i].renderable() + } + return out + case pyfmt.Ordered: + return Payload(x).renderable() + case []any: + out := make([]any, len(x)) + for i := range x { + out[i] = renderableValue(x[i]) + } + return out + } + return v +} diff --git a/go/internal/afx/payload_dictorder_test.go b/go/internal/afx/payload_dictorder_test.go new file mode 100644 index 0000000..190f5a1 --- /dev/null +++ b/go/internal/afx/payload_dictorder_test.go @@ -0,0 +1,64 @@ +package afx + +import ( + "encoding/json" + "testing" +) + +type orderedDictModel struct { + Name string `json:"name"` + Counts map[string]int `json:"counts"` + Costs map[string]float64 `json:"costs"` + Untamed map[string]int `json:"untamed"` +} + +func (orderedDictModel) DictFieldOrder() map[string][]string { + return map[string][]string{ + "counts": {"critical", "high", "medium", "low", "info"}, + "costs": {"recon", "hunt"}, + } +} + +// VALIDATION CONTRACT — Dump renders a declared dict field in the model's +// stated INSERTION order, not alphabetically. +// +// Python dicts keep insertion order and json.dumps honors it, so +// `{s.value: 0 for s in Severity}` reaches the wire as +// critical/high/medium/low/info. Every field NOT named by DictFieldOrder keeps +// the sorted rendering, which is pyfmt.Dumps' documented map deviation. +func TestDump_DictFieldOrderBeatsTheMapSort(t *testing.T) { + p, err := Dump(orderedDictModel{ + Name: "n", + Counts: map[string]int{"info": 5, "critical": 1, "low": 4, "high": 2, "medium": 3}, + Costs: map[string]float64{"hunt": 2, "recon": 1}, + Untamed: map[string]int{"b": 2, "a": 1}, + }) + if err != nil { + t.Fatalf("Dump: %v", err) + } + body, err := json.Marshal(p) + if err != nil { + t.Fatalf("marshal: %v", err) + } + const want = `{"name":"n","counts":{"critical":1,"high":2,"medium":3,"low":4,"info":5},"costs":{"recon":1.0,"hunt":2.0},"untamed":{"a":1,"b":2}}` + if string(body) != want { + t.Errorf("body = %s\nwant %s", body, want) + } +} + +// A key the declared order does not name is kept, sorted, after the known ones +// — never dropped. A nil map still renders null, as pydantic would. +func TestDump_DictFieldOrderKeepsUnknownKeysAndNilMaps(t *testing.T) { + p, err := Dump(orderedDictModel{Counts: map[string]int{"zzz": 9, "high": 2, "aaa": 1}}) + if err != nil { + t.Fatalf("Dump: %v", err) + } + body, err := json.Marshal(p) + if err != nil { + t.Fatalf("marshal: %v", err) + } + const want = `{"name":"","counts":{"high":2,"aaa":1,"zzz":9},"costs":null,"untamed":null}` + if string(body) != want { + t.Errorf("body = %s\nwant %s", body, want) + } +} diff --git a/go/internal/afx/required.go b/go/internal/afx/required.go new file mode 100644 index 0000000..7df5e25 --- /dev/null +++ b/go/internal/afx/required.go @@ -0,0 +1,139 @@ +package afx + +import ( + "fmt" + "reflect" + "sort" + "strings" +) + +// required.go restores the half of `Model.model_validate(payload)` that a JSON +// round-trip silently drops: pydantic raises `ValidationError` when a field +// WITHOUT a default is absent, while encoding/json leaves it at the Go zero +// value and reports success. +// +// That gap is reachable on every phase boundary. recon_phase does +// +// inventory = ResourceInventory.model_validate(_as_dict(_unwrap(iac_raw, ...))) +// +// and `inventory_saved_path` is required, so a malformed iac-reader reply +// aborts the phase in Python; binding it to "" instead carries an empty path +// into run_resource_graph_builder. prove_phase relies on the same raise to take +// its `_fallback_verified(finding, "Schema parse failed: ...")` branch — a +// prover reply missing `verdict`/`severity` must become an INCONCLUSIVE +// finding with drop_reason "prover_error", not a finding whose verdict is "" +// (uncounted in the verdict tallies, a bogus "" key in by_severity, and dropped +// outright by the default severity_threshold because "" has no rank). +// +// A model declares its required fields with RequiredFields; the list is the +// transcription of `[n for n, f in Model.model_fields.items() if f.is_required()]` +// and internal/schemas' test cross-checks every declared list against the +// committed pydantic schema fixtures' `required` arrays. + +// RequiredFielder is implemented by the ported pydantic models that have at +// least one field without a default. +type RequiredFielder interface { + RequiredFields() []string +} + +// MissingFieldError is the Go stand-in for pydantic's ValidationError with +// `type=missing`. +// +// DIVERGENCE (message text only): pydantic's rendering carries the offending +// input value and a docs URL — "1 validation error for ResourceInventory\n +// inventory_saved_path\n Field required [type=missing, input_value={}, ...]". +// The Go text keeps the model name, the count and the field list, which is what +// the phases surface (prove_phase embeds it in the fallback's evidence string). +type MissingFieldError struct { + Model string + Fields []string +} + +func (e *MissingFieldError) Error() string { + plural := "s" + if len(e.Fields) == 1 { + plural = "" + } + return fmt.Sprintf("%d validation error%s for %s: %s: Field required", + len(e.Fields), plural, e.Model, strings.Join(e.Fields, ", ")) +} + +// requireFields walks the untyped payload alongside the Go type t and reports +// the first model whose required fields are not all present, the way pydantic +// validates a nested model tree rather than only the outer object. +func requireFields(payload any, t reflect.Type) error { + for t != nil && t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t == nil { + return nil + } + + switch value := payload.(type) { + case map[string]any: + if t.Kind() == reflect.Map { + // dict[str, V]: the KEYS are data, the values may be models. + for _, v := range value { + if err := requireFields(v, t.Elem()); err != nil { + return err + } + } + return nil + } + if t.Kind() != reflect.Struct { + return nil + } + if err := checkModelRequired(value, t); err != nil { + return err + } + fields := jsonFieldTypes(t) + // Deterministic order so the reported field is stable run to run. + keys := make([]string, 0, len(value)) + for k := range value { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + ft, declared := fields[k] + if !declared { + continue + } + if err := requireFields(value[k], ft); err != nil { + return err + } + } + return nil + case []any: + if t.Kind() != reflect.Slice && t.Kind() != reflect.Array { + return nil + } + for _, e := range value { + if err := requireFields(e, t.Elem()); err != nil { + return err + } + } + return nil + } + return nil +} + +// checkModelRequired reports the required fields of t that payload does not +// carry. Python parity: pydantic's `missing` fires on an ABSENT key; a key +// present with an explicit null is a different (type) error, and this port +// leaves that one to the decode. +func checkModelRequired(payload map[string]any, t reflect.Type) error { + rf, ok := reflect.New(t).Interface().(RequiredFielder) + if !ok { + return nil + } + var missing []string + for _, name := range rf.RequiredFields() { + if _, present := payload[name]; !present { + missing = append(missing, name) + } + } + if len(missing) == 0 { + return nil + } + return &MissingFieldError{Model: t.Name(), Fields: missing} +} diff --git a/go/internal/afx/unwrap.go b/go/internal/afx/unwrap.go new file mode 100644 index 0000000..a845ea1 --- /dev/null +++ b/go/internal/afx/unwrap.go @@ -0,0 +1,240 @@ +package afx + +import ( + "fmt" + "reflect" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" +) + +// Unwrap ports the _unwrap helper defined in src/cloudsecurity_af/app.py +// (the LENIENT variant). +// +// Python: +// +// 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 +// +// Python parity: an "error" key whose value is NOT a dict falls straight +// through to the "output"/"result" probes — it is not an error. +// +// Python parity: the raised message is `message or detail or str(error_dict)`, +// using PYTHON truthiness — an empty string or a 0 for "message" falls through +// to "detail". +// +// NOTE: app.py's copy of _unwrap is dead code on the live path (app.py calls +// the orchestrator directly, never app.call), but it is ported for +// completeness and because it documents the lenient contract. The phases and +// the orchestrator use UnwrapStrict; use that one for any ported .call() site. +func Unwrap(raw any, name string) (any, error) { + m, ok := raw.(map[string]any) + if !ok { + return raw, nil + } + if err := checkErrorDict(m, name); err != nil { + return nil, err + } + if v, present := m["output"]; present { + return v, nil + } + if v, present := m["result"]; present { + return v, nil + } + return raw, nil +} + +// UnwrapStrict ports the _unwrap helper that src/cloudsecurity_af/reasoners/ +// phases.py and src/cloudsecurity_af/orchestrator.py each define (byte-identical +// copies of each other). It is the LENIENT variant plus two extra failure +// probes that run BEFORE the "output"/"result" unwrapping: +// +// if "error_message" in result and result["error_message"]: +// raise RuntimeError(f"{name} failed: {result['error_message']}") +// if result.get("status") in ("failed", "error"): +// raise RuntimeError(f"{name} failed: {result.get('error_message', 'Unknown error')}") +// +// This is the variant every ported app.Call() site in this repo must use — both +// DAG drivers (phases.py, orchestrator.py) use it, and it is what turns a +// control-plane execution envelope that reports a failed child into a Go error +// instead of silently validating an error envelope as a result model. +// +// Python parity: the status probe's fallback message is +// `result.get("error_message", "Unknown error")` — a PRESENT-but-None +// error_message renders as the string "None", not as "Unknown error", because +// dict.get only substitutes the default when the key is absent. +func UnwrapStrict(raw any, name string) (any, error) { + m, ok := raw.(map[string]any) + if !ok { + return raw, nil + } + if err := checkErrorDict(m, name); err != nil { + return nil, err + } + if v, present := m["error_message"]; present && truthy(v) { + return nil, fmt.Errorf("%s failed: %s", name, pyfmt.Str(v)) + } + if s, present := m["status"]; present { + if sv, isStr := s.(string); isStr && (sv == "failed" || sv == "error") { + message := "Unknown error" + if em, hasKey := m["error_message"]; hasKey { + message = pyfmt.Str(em) + } + return nil, fmt.Errorf("%s failed: %s", name, message) + } + } + if v, present := m["output"]; present { + return v, nil + } + if v, present := m["result"]; present { + return v, nil + } + return raw, nil +} + +// checkErrorDict is the shared first clause of both _unwrap variants. +func checkErrorDict(m map[string]any, name string) error { + raw, present := m["error"] + if !present { + return nil + } + errMap, isMap := raw.(map[string]any) + if !isMap { + return nil + } + return fmt.Errorf("%s failed: %s", name, errorDictMessage(errMap)) +} + +// errorDictMessage ports +// `result["error"].get("message") or result["error"].get("detail") or str(result["error"])`. +// +// Python parity / DETERMINISM: the final fallback is Python's str(dict), which +// prints the dict in INSERTION order. A Go map has no order at all, so +// pyfmt.Repr sorts the keys — a deliberate determinism fix (the port contract +// forbids non-deterministic output) that only shows up on the +// no-message/no-detail path. +func errorDictMessage(errMap map[string]any) string { + if v, present := errMap["message"]; present && truthy(v) { + return pyfmt.Str(v) + } + if v, present := errMap["detail"]; present && truthy(v) { + return pyfmt.Str(v) + } + return pyfmt.Repr(errMap) +} + +// truthy reproduces Python's bool(v) for the JSON value kinds that reach it: +// None, False, 0, 0.0, "", [] and {} are falsy; everything else is truthy. +func truthy(v any) bool { + if v == nil { + return false + } + rv := reflect.ValueOf(v) + 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: + return !rv.IsNil() + } + return true +} + +// AsMap ports the _as_dict helper that app.py, reasoners/phases.py and +// orchestrator.py each define (all three copies are identical): +// +// 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 string embeds the PYTHON type name of the payload, so PythonTypeName +// maps the Go kinds back onto their Python counterparts (nil -> NoneType, +// string -> str, float64 -> float, []any -> list, ...). +// +// PYTHON PARITY — A NIL MAP IS None, NOT AN EMPTY DICT. The Go SDK returns a +// NIL map[string]any with a NIL error when a succeeded execution's stored +// result is empty or the literal `null` (sdk/go/agent/agent.go +// awaitExecutionResult: `if len(statusResp.Result) > 0 && string(...) != "null"`). +// Boxed into `any` that is a NON-nil interface holding a nil map, so a plain +// type assertion succeeds and every key probe silently misses. Python cannot +// reach that state: the phase reply is None, `isinstance(None, dict)` is False +// and _as_dict raises — verified against the repo venv: +// +// _as_dict(_unwrap(None, "hunt_phase"), "hunt_phase") +// -> RuntimeError: hunt_phase returned non-dict payload: NoneType +// +// Accepting it as `{}` instead lets the orchestrator bind an all-default +// HuntResult/ChainResult (neither declares a required field) and return a 200 +// scan reporting total_raw_findings 0 and confirmed 0 — a clean-looking +// security scan — where Python aborts the run with a 500. +func AsMap(payload any, name string) (map[string]any, error) { + m, ok := payload.(map[string]any) + if !ok || m == nil { + return nil, fmt.Errorf("%s returned non-dict payload: %s", name, PythonTypeName(payload)) + } + return m, nil +} + +// PythonTypeName renders type(v).__name__ for the value kinds that cross a +// reasoner boundary as JSON. Anything outside that set (a Go struct that never +// went through JSON) falls back to the Go type's own name, which is the closest +// honest analogue of a Python class name. +func PythonTypeName(v any) string { + if v == nil { + return "NoneType" + } + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Bool: + // bool must precede the integer cases: Python's bool is a subclass of + // int but type(True).__name__ is "bool". + return "bool" + case reflect.String: + return "str" + 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.Float32, reflect.Float64: + return "float" + case reflect.Map: + // A nil map is how the Go SDK spells "the execution stored no result", + // i.e. Python's None — see the parity note on AsMap. + if rv.IsNil() { + return "NoneType" + } + return "dict" + case reflect.Slice: + if rv.IsNil() { + return "NoneType" + } + return "list" + case reflect.Array: + return "list" + case reflect.Pointer, reflect.Interface: + if rv.IsNil() { + return "NoneType" + } + return PythonTypeName(rv.Elem().Interface()) + } + if n := rv.Type().Name(); n != "" { + return n + } + return rv.Type().String() +} diff --git a/go/internal/aix/aix.go b/go/internal/aix/aix.go new file mode 100644 index 0000000..49422d6 --- /dev/null +++ b/go/internal/aix/aix.go @@ -0,0 +1,234 @@ +// Package aix is the structured `.ai(...)` seam: the Go port of +// +// await app.ai(system=..., user=..., schema=Model) # agentfield Python SDK +// +// which returns a validated pydantic model. Structured[T] resolves the same +// committed pydantic schema harnessx uses, strictifies it exactly the way the +// Python SDK does before sending it to an OpenAI-compatible endpoint, makes the +// call, and decodes the response into T. +// +// NOTE for this repo: cloudsecurity-af currently has NO `.ai(...)` call sites — +// `grep -rn '\.ai(' src/` is empty; every LLM interaction goes through the +// harness. This package exists because the port contract lists it as shared +// foundation (sec-af's gates DO use `.ai(schema=...)`, and cloudsecurity's own +// gates are the obvious next feature), and because Strictify is the only +// faithful copy of the SDK's schema transformation. It is fully tested against +// the Python function's real output; it is simply not yet on a live path here. +package aix + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "sort" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/harnessx" +) + +// maxParseRetries mirrors the Python SDK's `max_parse_retries = 2` +// (sdk/python/agentfield/agent_ai.py): the WHOLE call — request plus parse — is +// re-issued up to two more times when the model's body cannot be parsed into +// the schema, for three attempts in total. +const maxParseRetries = 2 + +// jsonObjectPattern ports the SDK's `re.search(r"\{.*\}", text, re.DOTALL)` +// salvage step. Go's regexp is leftmost-first with a greedy `.*`, so — exactly +// like CPython's `re` — it spans from the FIRST `{` to the LAST `}` in the +// body. That is what lets it strip a markdown ```json fence, a "Sure, here you +// go:" preamble, or a trailing apology off an otherwise valid object. +var jsonObjectPattern = regexp.MustCompile(`(?s)\{.*\}`) + +// Structured ports `await app.ai(system=system, user=user, schema=T)`. +// +// The Python SDK turns `schema=Model` into an OpenAI `response_format` of type +// json_schema with strict:true, whose schema is +// `_strictify_openai_schema(Model.model_json_schema())`. The Go SDK's +// ai.WithSchema(json.RawMessage) produces the identical request body +// (ResponseFormat{Type: "json_schema", JSONSchema: {Name: "response", Strict: +// true, Schema: raw}}), so the only thing the port has to reproduce is the +// strictification — which Strictify does. +// +// An empty system prompt adds no system message, matching Python's +// `system=None` default (the SDK only prepends one when it is truthy). +// +// TOLERANT PARSING + RETRY (agent_ai.py, the `if schema:` branch of +// `_execute_and_parse` plus the `for attempt in range(max_parse_retries + 1)` +// loop around it). Reproduced here exactly, because a real OpenRouter run of +// the sec-af sibling failed two `run_verifier` executions when kimi-k2.5 +// wrapped its json_schema reply in a ```json fence — a body the Python node +// absorbs without a blink: +// +// 1. parse the body directly; +// 2. on failure, salvage the first-`{`-to-last-`}` substring and parse that; +// 3. on failure, RE-ISSUE the AI request and start over, up to +// maxParseRetries more times; +// 4. after the last attempt, return `Could not parse structured response: +// ` — the SDK's final ValueError text, under the aix prefix. +// +// A transport error (app.AI returning an error) is NOT retried and is returned +// immediately: Python's retry loop only catches the parse `ValueError`, so an +// HTTP/network failure propagates on the first attempt there too. +// +// Python parity, deliberate divergences (both make Go strictly safer, neither +// changes any of the four outcomes above): +// +// - Python calls `schema(**json_data)`, which requires a MAPPING; a body that +// parses as `null`, a list or a scalar raises TypeError, which the SDK's +// `except (JSONDecodeError, ValueError, ValidationError)` does not catch and +// which therefore escapes uncaught. Go's json.Unmarshal would happily decode +// `null` into a zero-valued T and report success, so Structured requires the +// candidate to be a JSON object and otherwise treats it as a parse failure. +// The caller sees an error either way; Go's is the retried, described one. +// - `schema(**json_data)` also VALIDATES (missing required field, wrong type), +// which is the ValidationError arm of the same except-clause; encoding/json +// is lenient about both. A body that pydantic would reject can therefore +// decode here into a partially-zero T. That is the port-wide pydantic gap +// documented in DESIGN §2, not something this function can close. +func Structured[T any](ctx context.Context, app appx.AIer, system, user string) (T, error) { + var out T + + raw, err := json.Marshal(Strictify(harnessx.SchemaFor[T]())) + if err != nil { + return out, fmt.Errorf("aix.Structured[%s]: marshal schema: %w", harnessx.TypeName[T](), err) + } + + opts := make([]ai.Option, 0, 2) + if system != "" { + opts = append(opts, ai.WithSystem(system)) + } + opts = append(opts, ai.WithSchema(json.RawMessage(raw))) + + var lastErr error + for attempt := 0; attempt <= maxParseRetries; attempt++ { + resp, err := app.AI(ctx, user, opts...) + if err != nil { + return out, fmt.Errorf("aix.Structured[%s]: %w", harnessx.TypeName[T](), err) + } + + // A nil response is a Go-only shape (Python always has a response + // object here); treating it as an empty body routes it down the same + // path an empty completion takes in Python — parse failure, retry. + var text string + if resp != nil { + text = resp.Text() + } + + if parsed, ok := parseStructured[T](text); ok { + return parsed, nil + } + lastErr = fmt.Errorf("aix.Structured[%s]: Could not parse structured response: %s", harnessx.TypeName[T](), text) + } + return out, lastErr +} + +// parseStructured ports the two-step body of agent_ai.py's `if schema:` branch: +// a direct `json.loads(text)` + `schema(**data)`, then the `\{.*\}` salvage of +// the same. It reports whether either step produced a value. +func parseStructured[T any](text string) (T, bool) { + if v, ok := decodeObject[T](text); ok { + return v, true + } + if m := jsonObjectPattern.FindString(text); m != "" { + if v, ok := decodeObject[T](m); ok { + return v, true + } + } + var zero T + return zero, false +} + +// decodeObject parses s into a fresh T, requiring the top-level JSON value to +// be an object — see the mapping-vs-`**kwargs` note on Structured. A fresh T is +// used per attempt because encoding/json can populate fields before it fails, +// and a half-filled value must never leak into the next step or the result. +func decodeObject[T any](s string) (T, bool) { + // json.loads skips the four JSON whitespace bytes before the value; so + // does encoding/json, so the object check has to skip them too. + if trimmed := strings.TrimLeft(s, " \t\n\r"); trimmed == "" || trimmed[0] != '{' { + var zero T + return zero, false + } + var v T + if err := json.Unmarshal([]byte(s), &v); err != nil { + var zero T + return zero, false + } + return v, true +} + +// Strictify ports _strictify_openai_schema from the AgentField Python SDK +// (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 +// +// OpenAI's strict structured-output mode requires every object to set +// additionalProperties:false and to list ALL of its properties in required; +// pydantic's model_json_schema() emits neither. The walk covers $defs, +// properties, items and anyOf alike because it recurses into every dict value +// and every list element, not into a fixed keyword whitelist. +// +// Strictify returns a DEEP COPY and never mutates its argument — which matters +// here because harnessx.SchemaFor hands out a cached, shared map. +// +// DIVERGENCE (documented, semantically irrelevant): `list(props.keys())` yields +// Python's dict insertion order, i.e. the order pydantic emitted the fields in. +// A Go map has no order, so Strictify SORTS the required names. JSON Schema +// treats `required` as a set, and OpenAI does too, so nothing observable +// changes — and the committed fixtures are themselves written with sorted keys +// (gen_schemas.py uses sort_keys=True), so for every schema this port actually +// sends the two orders are in fact identical. The golden test compares against +// the real Python function run over the real fixture, which is what pins this. +func Strictify(schema map[string]any) map[string]any { + walked, _ := walk(schema).(map[string]any) + return walked +} + +func walk(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] = walk(v) + } + props, isObj := out["properties"].(map[string]any) + nodeType, hasType := out["type"] + if isObj && (nodeType == "object" || !hasType) { + out["additionalProperties"] = false + keys := make([]string, 0, len(props)) + for k := range props { + keys = append(keys, k) + } + sort.Strings(keys) + // required is []any, not []string, so the result marshals and + // compares identically to a JSON-decoded schema. + 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] = walk(e) + } + return out + } + return node +} diff --git a/go/internal/aix/aix_test.go b/go/internal/aix/aix_test.go new file mode 100644 index 0000000..2d43e42 --- /dev/null +++ b/go/internal/aix/aix_test.go @@ -0,0 +1,523 @@ +package aix + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/harnessx" +) + +// HuntResult mirrors the schemas package's type of the same name; the NAME is +// what SchemaFor uses to find the committed pydantic fixture. +type HuntResult struct { + Findings []map[string]any `json:"findings"` + TotalRaw int `json:"total_raw"` +} + +// --------------------------------------------------------------------------- +// Strictify — golden comparison against the real Python SDK function +// --------------------------------------------------------------------------- + +// The goldens are produced by go/scripts/gen_strictify_golden.py, which runs +// agentfield.agent_ai._strictify_openai_schema (the exact function +// app.ai(schema=Model) uses) over the committed pydantic fixtures. If this test +// fails, either Strictify drifted or the SDK's transformation changed — +// regenerate and read the diff, do not "fix" the expectation. +func TestStrictify_MatchesThePythonSDKFunction(t *testing.T) { + for _, name := range []string{"PathInvestigationPlan", "HuntResult", "VerifiedFinding"} { + t.Run(name, func(t *testing.T) { + input, err := harnessx.LoadEmbeddedSchema(name) + if err != nil { + t.Fatalf("LoadEmbeddedSchema(%s): %v", name, err) + } + + want := readGolden(t, "strict_"+name+".json") + got := Strictify(input) + + // reflect.DeepEqual on the decoded trees compares the `required` + // SLICES element-wise, so this pins their order too. + if !reflect.DeepEqual(got, want) { + gotJSON, _ := json.MarshalIndent(got, "", " ") + wantJSON, _ := json.MarshalIndent(want, "", " ") + t.Fatalf("Strictify(%s) diverged from the Python SDK.\n--- got ---\n%s\n--- want ---\n%s", name, gotJSON, wantJSON) + } + }) + } +} + +// readGolden decodes a golden with UseNumber, the same way +// harnessx.LoadEmbeddedSchema decodes the fixture Strictify is handed — so the +// DeepEqual below compares json.Number to json.Number and pins the numeric +// LITERALS (pydantic's `"default": 0.0`) as well as the tree shape. +func readGolden(t *testing.T, name string) map[string]any { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("reading golden %s: %v", name, err) + } + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + var m map[string]any + if err := dec.Decode(&m); err != nil { + t.Fatalf("decoding golden %s: %v", name, err) + } + return m +} + +// The transformation itself, on a hand-built schema that exercises every branch +// the Python walk has: $defs, nested properties, items, anyOf, a node with +// properties but no "type", and a node that is NOT an object. +func TestStrictify_TransformationRules(t *testing.T) { + in := map[string]any{ + "type": "object", + "title": "Root", + "properties": map[string]any{ + "b": map[string]any{"type": "string"}, + "a": map[string]any{ + "type": "array", + "items": map[string]any{"$ref": "#/$defs/Nested"}, + }, + "c": map[string]any{ + "anyOf": []any{ + map[string]any{"type": "object", "properties": map[string]any{"z": map[string]any{"type": "integer"}}}, + map[string]any{"type": "null"}, + }, + }, + }, + "$defs": map[string]any{ + // No "type" key, but it has properties -> still strictified. + "Nested": map[string]any{ + "properties": map[string]any{"n": map[string]any{"type": "string"}}, + }, + // An enum has no properties -> untouched. + "Sev": map[string]any{"type": "string", "enum": []any{"low", "high"}}, + }, + } + + got := Strictify(in) + + assertStrict(t, got, []any{"a", "b", "c"}) + + defs := got["$defs"].(map[string]any) + assertStrict(t, defs["Nested"].(map[string]any), []any{"n"}) + + sev := defs["Sev"].(map[string]any) + if _, ok := sev["additionalProperties"]; ok { + t.Error("an enum node without properties must not be strictified") + } + if _, ok := sev["required"]; ok { + t.Error("an enum node without properties must not gain required") + } + + props := got["properties"].(map[string]any) + // Recursion reaches inside anyOf list elements. + anyOf := props["c"].(map[string]any)["anyOf"].([]any) + assertStrict(t, anyOf[0].(map[string]any), []any{"z"}) + if _, ok := anyOf[1].(map[string]any)["required"]; ok { + t.Error("the null branch has no properties and must be untouched") + } +} + +func assertStrict(t *testing.T, node map[string]any, wantRequired []any) { + t.Helper() + if node["additionalProperties"] != false { + t.Errorf("additionalProperties = %v, want false", node["additionalProperties"]) + } + if !reflect.DeepEqual(node["required"], wantRequired) { + t.Errorf("required = %v, want %v", node["required"], wantRequired) + } +} + +// Strictify must never mutate its argument: harnessx.SchemaFor hands out a +// CACHED, SHARED map, so an in-place edit would poison every later harness call. +func TestStrictify_DoesNotMutateItsArgument(t *testing.T) { + shared := harnessx.SchemaFor[HuntResult]() + before, err := json.Marshal(shared) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + _ = Strictify(shared) + + after, err := json.Marshal(shared) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if string(before) != string(after) { + t.Fatal("Strictify mutated the cached schema map") + } + if _, ok := shared["additionalProperties"]; ok { + t.Fatal("Strictify wrote additionalProperties into the shared map") + } +} + +func TestStrictify_IsDeterministic(t *testing.T) { + schema := harnessx.SchemaFor[HuntResult]() + first, _ := json.Marshal(Strictify(schema)) + for i := 0; i < 10; i++ { + next, _ := json.Marshal(Strictify(schema)) + if string(next) != string(first) { + t.Fatal("Strictify is not deterministic across runs") + } + } +} + +// --------------------------------------------------------------------------- +// Structured +// --------------------------------------------------------------------------- + +// fakeAI applies the options to a Request exactly as the SDK client does, so the +// test can inspect the system message and the response_format the call would +// have sent. +type fakeAI struct { + prompt string + req *ai.Request + resp *ai.Response + err error + calls int +} + +func (f *fakeAI) AI(_ context.Context, prompt string, opts ...ai.Option) (*ai.Response, error) { + f.calls++ + f.prompt = prompt + req := &ai.Request{Messages: []ai.Message{{ + Role: "user", + Content: []ai.ContentPart{{Type: "text", Text: prompt}}, + }}} + for _, o := range opts { + if err := o(req); err != nil { + return nil, err + } + } + f.req = req + return f.resp, f.err +} + +func textResponse(body string) *ai.Response { + return &ai.Response{Choices: []ai.Choice{{ + Message: ai.Message{Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: body}}}, + }}} +} + +func TestStructured_SendsTheStrictifiedSchemaAndDecodesTheReply(t *testing.T) { + fake := &fakeAI{resp: textResponse(`{"findings": [], "total_raw": 4}`)} + + got, err := Structured[HuntResult](context.Background(), fake, "you are a gate", "classify this") + if err != nil { + t.Fatalf("Structured: %v", err) + } + if got.TotalRaw != 4 { + t.Fatalf("got = %+v", got) + } + + if fake.prompt != "classify this" { + t.Errorf("user prompt = %q", fake.prompt) + } + if len(fake.req.Messages) != 2 || fake.req.Messages[0].Role != "system" { + t.Fatalf("messages = %+v, want a prepended system message", fake.req.Messages) + } + if fake.req.Messages[0].Content[0].Text != "you are a gate" { + t.Errorf("system = %q", fake.req.Messages[0].Content[0].Text) + } + + rf := fake.req.ResponseFormat + if rf == nil || rf.Type != "json_schema" || rf.JSONSchema == nil || !rf.JSONSchema.Strict { + t.Fatalf("response_format = %+v, want a strict json_schema", rf) + } + // Compare the BYTES: the fixture is decoded with UseNumber, so a re-decode + // of the sent schema yields float64 where Strictify's map holds a + // json.Number — different Go values for identical JSON. + wantJSON, err := json.Marshal(Strictify(harnessx.SchemaFor[HuntResult]())) + if err != nil { + t.Fatalf("marshal want: %v", err) + } + if !bytes.Equal(rf.JSONSchema.Schema, wantJSON) { + t.Fatalf("the schema sent to the model is not the strictified pydantic schema:\n got %s\nwant %s", + rf.JSONSchema.Schema, wantJSON) + } +} + +// Python's system=None default sends no system message at all. +func TestStructured_EmptySystemPromptAddsNoSystemMessage(t *testing.T) { + fake := &fakeAI{resp: textResponse(`{"total_raw": 1}`)} + + if _, err := Structured[HuntResult](context.Background(), fake, "", "user only"); err != nil { + t.Fatalf("Structured: %v", err) + } + if len(fake.req.Messages) != 1 || fake.req.Messages[0].Role != "user" { + t.Fatalf("messages = %+v, want only the user message", fake.req.Messages) + } +} + +// Python parity: an empty completion is NOT a special case in the SDK — it goes +// straight into `json.loads("")`, which raises, so it is a parse failure like +// any other and is therefore RETRIED before it is reported. A nil *ai.Response +// is a Go-only shape routed down the same path. +func TestStructured_EmptyContentIsARetriedParseFailure(t *testing.T) { + for _, tc := range []struct { + name string + resp *ai.Response + }{ + {"nil response", nil}, + {"no choices", &ai.Response{}}, + {"empty text", textResponse("")}, + } { + t.Run(tc.name, func(t *testing.T) { + fake := &fakeAI{resp: tc.resp} + _, err := Structured[HuntResult](context.Background(), fake, "", "p") + if err == nil { + t.Fatal("expected an error") + } + if err.Error() != "aix.Structured[HuntResult]: Could not parse structured response: " { + t.Fatalf("err = %q", err.Error()) + } + if fake.calls != 3 { + t.Fatalf("AI calls = %d, want 3 (initial + 2 parse retries)", fake.calls) + } + }) + } +} + +// "Sure! Here is the JSON: {oops" has no closing brace, so the `\{.*\}` salvage +// finds nothing and the body is unparsable by both steps. +func TestStructured_UnparsableJSONIsADescriptiveError(t *testing.T) { + fake := &fakeAI{resp: textResponse("Sure! Here is the JSON: {oops")} + + _, err := Structured[HuntResult](context.Background(), fake, "", "p") + if err == nil { + t.Fatal("expected an error") + } + if err.Error() != "aix.Structured[HuntResult]: Could not parse structured response: Sure! Here is the JSON: {oops" { + t.Fatalf("err = %q", err.Error()) + } +} + +// --------------------------------------------------------------------------- +// F1 — tolerant parsing + parse retries (agent_ai.py `_execute_and_parse` and +// the `for attempt in range(max_parse_retries + 1)` loop around it). +// +// The live defect that motivated these: kimi-k2.5, asked for a json_schema +// response, returned its object inside a markdown ```json fence and the Go node +// failed the execution where the Python node succeeded. +// --------------------------------------------------------------------------- + +// scriptedAI answers the Nth AI call with bodies[N], and errs[N] if set. It is +// the appx.Fake seam driven from a script, so the test can count calls. +func scriptedAI(t *testing.T, bodies ...string) (*appx.Fake, func() int) { + t.Helper() + fake := &appx.Fake{} + n := 0 + fake.AIFn = func(_ context.Context, _ string, _ ...ai.Option) (*ai.Response, error) { + if n >= len(bodies) { + t.Errorf("AI called %d times, script only has %d bodies", n+1, len(bodies)) + return textResponse(""), nil + } + body := bodies[n] + n++ + return textResponse(body), nil + } + return fake, func() int { return n } +} + +// (a) The exact live failure: a fenced body must parse on the FIRST attempt. +func TestStructured_ParsesAFencedJSONBodyWithoutRetrying(t *testing.T) { + fake, calls := scriptedAI(t, "```json\n{\"findings\": [], \"total_raw\": 7}\n```") + + got, err := Structured[HuntResult](context.Background(), fake, "", "p") + if err != nil { + t.Fatalf("Structured: %v", err) + } + if got.TotalRaw != 7 { + t.Fatalf("got = %+v, want TotalRaw 7", got) + } + if calls() != 1 { + t.Fatalf("AI calls = %d, want 1", calls()) + } +} + +// (b) Prose on both sides of the object — the same salvage step. +func TestStructured_SalvagesJSONEmbeddedInProse(t *testing.T) { + fake, calls := scriptedAI(t, "Here you go:\n{\"findings\": [], \"total_raw\": 2}\nHope that helps!") + + got, err := Structured[HuntResult](context.Background(), fake, "", "p") + if err != nil { + t.Fatalf("Structured: %v", err) + } + if got.TotalRaw != 2 { + t.Fatalf("got = %+v, want TotalRaw 2", got) + } + if calls() != 1 { + t.Fatalf("AI calls = %d, want 1", calls()) + } +} + +// The salvage is GREEDY — first `{` to last `}` — exactly like CPython's +// `re.search(r"\{.*\}", text, re.DOTALL)`. Ground truth for this string was +// taken from the venv interpreter, which returns the whole span including the +// text between the two objects. +func TestStructured_SalvageIsGreedyLikeThePythonRegex(t *testing.T) { + body := "{\"a\":1} trailing {\"findings\": [], \"total_raw\": 9}" + if got := jsonObjectPattern.FindString(body); got != body { + t.Fatalf("FindString = %q, want the whole span %q", got, body) + } + // The greedy span is not valid JSON, so this body legitimately fails. + fake, calls := scriptedAI(t, body, body, body) + if _, err := Structured[HuntResult](context.Background(), fake, "", "p"); err == nil { + t.Fatal("expected a parse error for the greedy multi-object span") + } + if calls() != 3 { + t.Fatalf("AI calls = %d, want 3", calls()) + } +} + +// (c) Two malformed bodies then a good one: the WHOLE call is re-issued, so the +// third attempt succeeds and exactly three AI calls were made. +func TestStructured_RetriesTheRequestOnParseFailureAndSucceedsOnTheThird(t *testing.T) { + fake, calls := scriptedAI(t, + "not json at all", + "still {not json", + "{\"findings\": [], \"total_raw\": 3}", + ) + + got, err := Structured[HuntResult](context.Background(), fake, "", "p") + if err != nil { + t.Fatalf("Structured: %v", err) + } + if got.TotalRaw != 3 { + t.Fatalf("got = %+v, want TotalRaw 3", got) + } + if calls() != 3 { + t.Fatalf("AI calls = %d, want 3 (initial + 2 parse retries)", calls()) + } +} + +// (d) Three malformed bodies: the SDK gives up after max_parse_retries and +// reports the LAST body with the Python error text. +func TestStructured_GivesUpAfterThreeAttempts(t *testing.T) { + fake, calls := scriptedAI(t, "garbage one", "garbage two", "garbage three") + + _, err := Structured[HuntResult](context.Background(), fake, "", "p") + if err == nil { + t.Fatal("expected an error") + } + if err.Error() != "aix.Structured[HuntResult]: Could not parse structured response: garbage three" { + t.Fatalf("err = %q", err.Error()) + } + if calls() != 3 { + t.Fatalf("AI calls = %d, want 3 (initial + 2 parse retries)", calls()) + } +} + +// A transport error is not a parse error: Python's retry loop only catches the +// ValueError, so the very first HTTP failure propagates. No retry here either. +func TestStructured_TransportErrorIsNotRetried(t *testing.T) { + boom := errors.New("429 rate limited") + fake := &fakeAI{err: boom} + + _, err := Structured[HuntResult](context.Background(), fake, "", "p") + if !errors.Is(err, boom) { + t.Fatalf("err = %v, want it to wrap %v", err, boom) + } + if fake.calls != 1 { + t.Fatalf("AI calls = %d, want 1 — a transport error must not be retried", fake.calls) + } +} + +// A body that parses but is not a MAPPING is a parse failure, not a silently +// zero-valued result. Python's `schema(**data)` raises TypeError on these; +// encoding/json would decode `null` into a zero T and report success. +func TestStructured_NonObjectBodiesAreParseFailures(t *testing.T) { + for _, body := range []string{"null", "[1,2]", "42", `"a string"`, "true"} { + t.Run(body, func(t *testing.T) { + fake, calls := scriptedAI(t, body, body, body) + _, err := Structured[HuntResult](context.Background(), fake, "", "p") + if err == nil { + t.Fatalf("body %q decoded successfully; a non-mapping body must fail", body) + } + if !strings.Contains(err.Error(), "Could not parse structured response") { + t.Fatalf("err = %q", err.Error()) + } + if calls() != 3 { + t.Fatalf("AI calls = %d, want 3", calls()) + } + }) + } +} + +// A half-decodable body must not leak partially-populated fields into the +// salvaged parse or into the returned value. +func TestStructured_PartialDecodeDoesNotLeakIntoTheResult(t *testing.T) { + // The direct parse fails on the trailing junk AFTER filling total_raw; + // the salvaged span is the same prefix and parses cleanly. + fake, calls := scriptedAI(t, "{\"total_raw\": 5, \"findings\": []} < 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/config/config.go b/go/internal/config/config.go new file mode 100644 index 0000000..c171615 --- /dev/null +++ b/go/internal/config/config.go @@ -0,0 +1,513 @@ +// Package config ports src/cloudsecurity_af/config.py in full: the depth +// profile enum and its three lookup tables, BudgetConfig, ScanConfig (+ +// from_input) and AIIntegrationConfig (+ from_env / provider_env). +// +// 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 no value is frozen at import. (Python freezes them at model construction +// instead, which for app.py happens at import; the practical difference only +// shows up in tests, where Python uses monkeypatch + a fresh from_env() call.) +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +// DepthProfile ports config.py DepthProfile — a str-valued enum, so the wire +// representation is the bare lowercase word and a Go string type is exact. +type DepthProfile string + +// The three profiles. Values are the Python enum values verbatim. +const ( + DepthQuick DepthProfile = "quick" + DepthStandard DepthProfile = "standard" + DepthThorough DepthProfile = "thorough" +) + +// ParseDepth ports `DepthProfile(value)` — the STRICT constructor used by +// ScanConfig.from_input. It is case-sensitive and rejects anything that is not +// one of the three values, with Python's own ValueError text: +// +// >>> DepthProfile("bogus") +// ValueError: 'bogus' is not a valid DepthProfile +// >>> DepthProfile("Quick") +// ValueError: 'Quick' is not a valid DepthProfile +// +// Use NormalizeDepth (not this) wherever the Python source called +// _normalize_depth. +func ParseDepth(value string) (DepthProfile, error) { + switch DepthProfile(value) { + case DepthQuick, DepthStandard, DepthThorough: + return DepthProfile(value), nil + } + return "", fmt.Errorf("'%s' is not a valid DepthProfile", value) +} + +// NormalizeDepth ports the _normalize_depth helper in +// src/cloudsecurity_af/reasoners/phases.py, which every phase reasoner uses on +// its `depth` string parameter: +// +// def _normalize_depth(depth: str) -> DepthProfile: +// try: +// return DepthProfile(depth.lower()) +// except ValueError: +// return DepthProfile.STANDARD +// +// Python parity: it lowercases first (so "QUICK" resolves) and silently falls +// back to STANDARD for anything unrecognized — including the empty string. +func NormalizeDepth(depth string) DepthProfile { + p, err := ParseDepth(strings.ToLower(depth)) + if err != nil { + return DepthStandard + } + return p +} + +// BudgetConfig ports config.py BudgetConfig. The pointer fields are Python's +// `float | None` / `int | None`: nil means "no cap", which is NOT the same as 0. +type BudgetConfig struct { + MaxCostUSD *float64 `json:"max_cost_usd"` + MaxDurationSeconds *int `json:"max_duration_seconds"` + MaxConcurrentHunters int `json:"max_concurrent_hunters"` + MaxConcurrentProvers int `json:"max_concurrent_provers"` + MaxConcurrentChainChildren int `json:"max_concurrent_chain_children"` + ReconBudgetPct float64 `json:"recon_budget_pct"` + HuntBudgetPct float64 `json:"hunt_budget_pct"` + ChainBudgetPct float64 `json:"chain_budget_pct"` + ProveBudgetPct float64 `json:"prove_budget_pct"` + RemediateBudgetPct float64 `json:"remediate_budget_pct"` +} + +// NewBudgetConfig ports `BudgetConfig()` — the pydantic field defaults. Go zero +// values differ from every one of them, so nothing may construct a +// BudgetConfig literal without going through here (or UnmarshalJSON). +func NewBudgetConfig() BudgetConfig { + return BudgetConfig{ + MaxCostUSD: nil, + MaxDurationSeconds: nil, + MaxConcurrentHunters: 4, + MaxConcurrentProvers: 3, + MaxConcurrentChainChildren: 3, + ReconBudgetPct: 0.10, + HuntBudgetPct: 0.35, + ChainBudgetPct: 0.20, + ProveBudgetPct: 0.25, + RemediateBudgetPct: 0.10, + } +} + +// UnmarshalJSON seeds the pydantic defaults before decoding, so a BudgetConfig +// that crosses a reasoner boundary as a partial object comes back with the same +// values Python's model_validate would produce. +func (b *BudgetConfig) UnmarshalJSON(data []byte) error { + type alias BudgetConfig + seeded := alias(NewBudgetConfig()) + if err := json.Unmarshal(data, &seeded); err != nil { + return err + } + *b = BudgetConfig(seeded) + return nil +} + +// DepthHunterMap ports config.py DEPTH_HUNTER_MAP: which hunt strategies each +// depth profile runs, IN ORDER (hunt_phase preserves this order when it fans +// out, so it is part of the DAG contract, not just a set). +var DepthHunterMap = map[DepthProfile][]string{ + DepthQuick: {"iam", "network", "data", "secrets", "compute"}, + DepthStandard: {"iam", "network", "data", "secrets", "compute", "logging", "compliance"}, + DepthThorough: {"iam", "network", "data", "secrets", "compute", "logging", "compliance"}, +} + +// DepthChainLimits ports config.py DEPTH_CHAIN_LIMITS — max_paths handed to +// run_path_constructor. +var DepthChainLimits = map[DepthProfile]int{ + DepthQuick: 5, + DepthStandard: 15, + DepthThorough: 100, +} + +// DepthProverCaps ports config.py DEPTH_PROVER_CAPS — how many findings +// prove_phase will hand to a prover. +// +// NOTE for reviewers: tests/test_config.py on main asserts +// DEPTH_PROVER_CAPS[QUICK] == 10, but config.py says 20 — that Python test is +// STALE and fails on main today (verified with this repo's interpreter). The +// Go port follows the CODE, which is what actually runs; see +// TestDepthProverCaps_QuickIsTwentyNotTheStalePythonTestValue. +var DepthProverCaps = map[DepthProfile]int{ + DepthQuick: 20, + DepthStandard: 30, + DepthThorough: 10_000, +} + +// HuntersForDepth returns a COPY of the hunter list for a profile, so callers +// cannot mutate the shared table. An unknown profile yields the standard list, +// matching how every caller reaches this map through NormalizeDepth. +func HuntersForDepth(profile DepthProfile) []string { + src, ok := DepthHunterMap[profile] + if !ok { + src = DepthHunterMap[DepthStandard] + } + out := make([]string, len(src)) + copy(out, src) + return out +} + +// DefaultExcludePaths ports the exclude_paths default_factory shared by +// ScanConfig and schemas.CloudSecurityInput. Returns a fresh slice each call +// (a default_factory produces a new list per model instance). +func DefaultExcludePaths() []string { + return []string{"tests/", ".git/", "examples/", ".terraform/"} +} + +// DefaultOutputFormats ports the output_formats default_factory (["json"]). +func DefaultOutputFormats() []string { + return []string{"json"} +} + +// ScanConfig ports config.py ScanConfig — the resolved per-run configuration +// the orchestrator and every phase read. +type ScanConfig struct { + RepoPath string `json:"repo_path"` + Depth DepthProfile `json:"depth"` + Tier int `json:"tier"` + SeverityThreshold string `json:"severity_threshold"` + OutputFormats []string `json:"output_formats"` + ComplianceFrameworks []string `json:"compliance_frameworks"` + IncludePaths []string `json:"include_paths"` + ExcludePaths []string `json:"exclude_paths"` + Budget BudgetConfig `json:"budget"` +} + +// NewScanConfig ports `ScanConfig(repo_path=...)` — the pydantic field defaults +// with only repo_path supplied. +func NewScanConfig(repoPath string) ScanConfig { + return ScanConfig{ + RepoPath: repoPath, + Depth: DepthStandard, + Tier: 1, + SeverityThreshold: "low", + OutputFormats: DefaultOutputFormats(), + ComplianceFrameworks: []string{}, + IncludePaths: nil, + ExcludePaths: DefaultExcludePaths(), + Budget: NewBudgetConfig(), + } +} + +// UnmarshalJSON seeds the pydantic defaults before decoding. +func (s *ScanConfig) UnmarshalJSON(data []byte) error { + type alias ScanConfig + seeded := alias(NewScanConfig("")) + if err := json.Unmarshal(data, &seeded); err != nil { + return err + } + *s = ScanConfig(seeded) + return nil +} + +// ScanInput is the read-only view of schemas.CloudSecurityInput that +// ScanConfig.from_input reads. It exists so this package does not have to +// import internal/schemas (Python's config.py DOES import schemas.input; the Go +// port keeps the dependency out so config stays a leaf package that the schemas +// owner can also import if it ever needs the depth tables). +// +// The json tags are the pydantic field names, so ScanConfigFromInput can decode +// a marshaled schemas.CloudSecurityInput — or the raw reasoner-boundary map — +// straight into this view. +type ScanInput struct { + Depth string `json:"depth"` + SeverityThreshold string `json:"severity_threshold"` + 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"` + MaxDurationSeconds *int `json:"max_duration_seconds"` + MaxConcurrentHunters *int `json:"max_concurrent_hunters"` + MaxConcurrentProvers *int `json:"max_concurrent_provers"` + + // Tier ports CloudSecurityInput.tier, a @property (1 when `cloud` is None, + // else 2). It is NOT a serialized field, so ScanConfigFromInput derives it + // from the presence of a non-null `cloud` key rather than reading it. + Tier int `json:"-"` +} + +// ScanConfigFromInput ports ScanConfig.from_input. +// +// Python: +// +// @classmethod +// def from_input(cls, scan_input: CloudSecurityInput, repo_path: str) -> ScanConfig: +// depth = DepthProfile(scan_input.depth) +// budget = BudgetConfig(max_cost_usd=..., max_duration_seconds=...) +// if scan_input.max_concurrent_hunters is not None: budget.max_concurrent_hunters = ... +// if scan_input.max_concurrent_provers is not None: budget.max_concurrent_provers = ... +// return cls(repo_path=repo_path, depth=depth, tier=scan_input.tier, ...) +// +// Python parity: the depth is parsed with the STRICT DepthProfile constructor, +// so an unrecognized depth is a ValueError — which app.py maps to HTTP 400. +// It is deliberately NOT normalized here (only the phase reasoners normalize). +// +// scanInput may be any value whose JSON encoding matches +// schemas.CloudSecurityInput: the struct itself (the normal call, matching +// Python's typed argument) or the raw input map. Pass the MATERIALIZED struct +// where possible — a raw map that omits keys has no pydantic defaults behind +// it, exactly as in Python. +func ScanConfigFromInput(scanInput any, repoPath string) (ScanConfig, error) { + view, err := scanInputView(scanInput) + if err != nil { + return ScanConfig{}, err + } + return NewScanConfigFromView(view, repoPath) +} + +// NewScanConfigFromView is ScanConfigFromInput's pure core, for callers that +// already hold the field values. +func NewScanConfigFromView(in ScanInput, repoPath string) (ScanConfig, error) { + depth, err := ParseDepth(in.Depth) + if err != nil { + return ScanConfig{}, err + } + + budget := NewBudgetConfig() + budget.MaxCostUSD = in.MaxCostUSD + budget.MaxDurationSeconds = in.MaxDurationSeconds + if in.MaxConcurrentHunters != nil { + budget.MaxConcurrentHunters = *in.MaxConcurrentHunters + } + if in.MaxConcurrentProvers != nil { + budget.MaxConcurrentProvers = *in.MaxConcurrentProvers + } + + cfg := NewScanConfig(repoPath) + cfg.Depth = depth + cfg.Tier = in.Tier + cfg.SeverityThreshold = in.SeverityThreshold + cfg.OutputFormats = in.OutputFormats + cfg.ComplianceFrameworks = in.ComplianceFrameworks + cfg.IncludePaths = in.IncludePaths + cfg.ExcludePaths = in.ExcludePaths + cfg.Budget = budget + return cfg, nil +} + +// scanInputView JSON-round-trips an arbitrary input value into the ScanInput +// view and derives Tier from the `cloud` key the way CloudSecurityInput.tier +// does (`1 if self.cloud is None else 2`). +func scanInputView(scanInput any) (ScanInput, error) { + if v, ok := scanInput.(ScanInput); ok { + return v, nil + } + b, err := json.Marshal(scanInput) + if err != nil { + return ScanInput{}, fmt.Errorf("config: marshal scan input %T: %w", scanInput, err) + } + var view ScanInput + if err := json.Unmarshal(b, &view); err != nil { + return ScanInput{}, fmt.Errorf("config: decode scan input %T: %w", scanInput, err) + } + var probe struct { + Cloud json.RawMessage `json:"cloud"` + } + if err := json.Unmarshal(b, &probe); err != nil { + return ScanInput{}, fmt.Errorf("config: decode scan input %T: %w", scanInput, err) + } + view.Tier = 1 + if len(probe.Cloud) > 0 && string(probe.Cloud) != "null" { + view.Tier = 2 + } + return view, nil +} + +// AIIntegrationConfig ports config.py AIIntegrationConfig. +type AIIntegrationConfig struct { + Provider string `json:"provider"` + HarnessModel string `json:"harness_model"` + AIModel string `json:"ai_model"` + MaxTurns int `json:"max_turns"` + OpencodeBin string `json:"opencode_bin"` + AforgeBin string `json:"aforge_bin"` +} + +// AIConfigFromEnv ports AIIntegrationConfig.from_env() — i.e. constructing the +// model so every default_factory lambda runs. The precedence chains are the +// nested os.getenv calls, verbatim: +// +// provider CLOUDSECURITY_PROVIDER -> HARNESS_PROVIDER -> "aforge" +// harness_model CLOUDSECURITY_MODEL -> HARNESS_MODEL -> "openrouter/minimax/minimax-m2.5" +// ai_model CLOUDSECURITY_AI_MODEL -> AI_MODEL -> CLOUDSECURITY_MODEL -> "openrouter/minimax/minimax-m2.5" +// max_turns int(CLOUDSECURITY_MAX_TURNS or "50") +// opencode_bin CLOUDSECURITY_OPENCODE_BIN -> "opencode" +// aforge_bin CLOUDSECURITY_AFORGE_BIN -> AFORGE_BIN -> "aforge" +// +// Python parity: os.getenv(key, default) substitutes the default only when the +// key is ABSENT — a key set to the empty string yields "". strEnv keeps that. +// +// Python parity: a malformed CLOUDSECURITY_MAX_TURNS makes int() raise inside +// the default_factory, which happens while app.py is being imported — i.e. the +// node fails to boot. Go returns the error so the caller (node.BuildAgent) can +// fail startup the same way. +func AIConfigFromEnv() (AIIntegrationConfig, error) { + maxTurns, err := intEnv("CLOUDSECURITY_MAX_TURNS", 50) + if err != nil { + return AIIntegrationConfig{}, err + } + return AIIntegrationConfig{ + Provider: strEnv("CLOUDSECURITY_PROVIDER", strEnv("HARNESS_PROVIDER", "aforge")), + HarnessModel: strEnv("CLOUDSECURITY_MODEL", strEnv("HARNESS_MODEL", defaultModel)), + AIModel: strEnv("CLOUDSECURITY_AI_MODEL", + strEnv("AI_MODEL", strEnv("CLOUDSECURITY_MODEL", defaultModel))), + MaxTurns: maxTurns, + OpencodeBin: strEnv("CLOUDSECURITY_OPENCODE_BIN", "opencode"), + AforgeBin: strEnv("CLOUDSECURITY_AFORGE_BIN", strEnv("AFORGE_BIN", "aforge")), + }, nil +} + +// defaultModel is the code default for both harness_model and ai_model. +const defaultModel = "openrouter/minimax/minimax-m2.5" + +// providerEnvKeys ports the env_keys tuple in provider_env(), IN ORDER. Only +// keys with a non-empty value are forwarded (Python's walrus `if (value := +// os.getenv(key))` is a truthiness test, so a key set to "" is dropped). +var providerEnvKeys = []string{ + "OPENROUTER_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GOOGLE_API_KEY", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "GOOGLE_APPLICATION_CREDENTIALS", + "AZURE_CLIENT_ID", + "AZURE_CLIENT_SECRET", + "AZURE_TENANT_ID", + "AZURE_SUBSCRIPTION_ID", +} + +// ProviderEnvKeys returns a copy of the forwarded-credential key list, for +// tests and for the packaging manifest to cross-check against. +func ProviderEnvKeys() []string { + out := make([]string, len(providerEnvKeys)) + copy(out, providerEnvKeys) + return out +} + +// ProviderEnv ports AIIntegrationConfig.provider_env(): the environment handed +// to the harness subprocess. +// +// - the 14 cloud/LLM credential keys, forwarded only when non-empty +// - AGENTFIELD_AFORGE_COMMAND, defaulting to "exec" +// - XDG_DATA_HOME, defaulting to /opencode-shared-data, and CREATED +// (Python: os.makedirs(xdg, exist_ok=True)) because opencode refuses to run +// without a writable data home +// +// Python parity: XDG_DATA_HOME uses `or`, not os.getenv's default, so a key set +// to "" ALSO falls back to the temp path — unlike AGENTFIELD_AFORGE_COMMAND, +// which uses os.getenv(key, "exec") and therefore keeps an explicit "". +// +// Divergence (documented, benign): Python's tempfile.gettempdir() consults +// TMPDIR/TEMP/TMP then falls back through /tmp, while Go's os.TempDir() reads +// $TMPDIR and falls back to /tmp. They agree on every Linux container this node +// runs in. +// +// Python parity — THE MKDIR FAILURE IS FATAL AT BOOT. config.py:135 is a bare +// `os.makedirs(xdg, exist_ok=True)` (exist_ok suppresses only FileExistsError), +// and provider_env() is called from app.py's MODULE BODY, inside the +// `Agent(... harness_config=HarnessConfig(env=_ai_config.provider_env(), ...))` +// literal. So an unwritable XDG_DATA_HOME — a read-only volume, a path +// component that is a regular file, a uid without write access to $TMPDIR — +// raises at import and the process never registers with the control plane. +// Verified against the repo venv: with XDG_DATA_HOME under a regular file, +// AIIntegrationConfig.from_env().provider_env() raises NotADirectoryError. +// Returning the error (rather than swallowing it) keeps the Go node failing +// where the Python node fails, instead of registering healthy and then failing +// every scan deep inside the first harness invocation. +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 + } + } + env["AGENTFIELD_AFORGE_COMMAND"] = strEnv("AGENTFIELD_AFORGE_COMMAND", "exec") + + 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 +} + +// --- node identity --------------------------------------------------------- + +// DefaultNodeID is the fallback in every `os.getenv("NODE_ID", "cloudsecurity")` +// the Python node performs: app.py:31, reasoners/phases.py:22 and +// orchestrator.py:73. +const DefaultNodeID = "cloudsecurity" + +// NodeID resolves the node's identity from NODE_ID. +// +// This is THE single resolution rule for the whole port. Python reads the same +// `os.getenv("NODE_ID", "cloudsecurity")` in all three places, so the id the +// node REGISTERS under and the prefix of every `app.call` DAG target can never +// disagree. Go must preserve that invariant: internal/node uses it for +// agent.Config.NodeID, internal/phases and internal/orch use it for every Call +// target. Resolving NODE_ID with two different rules would let the node +// register as `cloudsecurity` while calling `".recon_phase"`, which the SDK +// does not repair (Agent.Call only prefixes targets that contain no dot) and +// which fails only at the first phase call, long after a clean boot. +// +// DIVERGENCE (deliberate, and the reason this helper exists): an EMPTY NODE_ID +// is treated as absent, where Python's os.getenv would return "". `af run` and +// docker compose export empty strings for unset optional variables, and an +// empty node id cannot produce a working node in either language. The +// divergence is safe precisely because it is applied uniformly — registration +// and call targets both fall back to the same default. +func NodeID() string { return NodeIDOr(DefaultNodeID) } + +// NodeIDOr is NodeID with a caller-supplied default, for cmd/ mains that pass +// their own (identical) default down into node.BuildAgent. +func NodeIDOr(def string) string { + if v := os.Getenv("NODE_ID"); v != "" { + return v + } + return def +} + +// --- shared env readers (call-time only) --- + +// strEnv returns the env value for key, or def when the key is UNSET. A key +// that is set (even to "") returns its value, matching os.getenv(key, def). +func strEnv(key, def string) string { + if v, ok := os.LookupEnv(key); ok { + return v + } + return def +} + +// intEnv parses key as an int, falling back to def when the key is unset. A +// set-but-malformed value is an error carrying Python's int() message shape; +// Python's int(os.getenv(...)) raises, it never silently defaults. +func intEnv(key string, def int) (int, error) { + v, ok := os.LookupEnv(key) + if !ok { + return def, nil + } + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return 0, fmt.Errorf("invalid literal for int() with base 10: '%s'", v) + } + return n, nil +} diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go new file mode 100644 index 0000000..8c3507c --- /dev/null +++ b/go/internal/config/config_test.go @@ -0,0 +1,727 @@ +package config + +import ( + "encoding/json" + "math" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/agent" +) + +// This file ports the config-related tests in tests/test_config.py. Each Go +// test names the Python test it came from so reviewers can diff coverage. +// +// Ground truth for the values asserted here was re-confirmed against this +// repo's interpreter: +// +// PYTHONPATH=src ~/.agentfield/packages/cloudsecurity-af/venv/bin/python -c \ +// "from cloudsecurity_af.config import *; print(ScanConfig.from_input(...).model_dump())" + +// unsetEnv clears keys for the duration of the test and restores them after, +// standing in for pytest's monkeypatch.delenv(..., raising=False). +func unsetEnv(t *testing.T, keys ...string) { + t.Helper() + for _, key := range keys { + if old, ok := os.LookupEnv(key); ok { + k := key + v := old + t.Cleanup(func() { _ = os.Setenv(k, v) }) + } else { + k := key + t.Cleanup(func() { _ = os.Unsetenv(k) }) + } + if err := os.Unsetenv(key); err != nil { + t.Fatalf("unsetting %s: %v", key, err) + } + } +} + +// allEnvKeys is every variable this package reads; clearing them gives a test a +// known-clean baseline regardless of the developer's shell. +func cleanEnv(t *testing.T) { + t.Helper() + unsetEnv(t, + "CLOUDSECURITY_PROVIDER", "HARNESS_PROVIDER", + "CLOUDSECURITY_MODEL", "HARNESS_MODEL", + "CLOUDSECURITY_AI_MODEL", "AI_MODEL", + "CLOUDSECURITY_MAX_TURNS", + "CLOUDSECURITY_OPENCODE_BIN", + "CLOUDSECURITY_AFORGE_BIN", "AFORGE_BIN", + "AGENTFIELD_AFORGE_COMMAND", "XDG_DATA_HOME", + ) + unsetEnv(t, providerEnvKeys...) +} + +// --------------------------------------------------------------------------- +// AIIntegrationConfig — ports test_config.py's module-level tests +// --------------------------------------------------------------------------- + +// Ports test_aforge_exec_is_the_default_harness. +func TestAforgeExecIsTheDefaultHarness(t *testing.T) { + cleanEnv(t) + + cfg, err := AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + if cfg.Provider != "aforge" { + t.Errorf("Provider = %q, want aforge", cfg.Provider) + } + if cfg.AforgeBin != "aforge" { + t.Errorf("AforgeBin = %q, want aforge", cfg.AforgeBin) + } + providerEnv, err := cfg.ProviderEnv() + if err != nil { + t.Fatalf("ProviderEnv: %v", err) + } + if got := providerEnv["AGENTFIELD_AFORGE_COMMAND"]; got != "exec" { + t.Errorf("AGENTFIELD_AFORGE_COMMAND = %q, want exec", got) + } +} + +// Ports test_opencode_remains_an_explicit_rollback. +func TestOpencodeRemainsAnExplicitRollback(t *testing.T) { + cleanEnv(t) + t.Setenv("HARNESS_PROVIDER", "opencode") + + cfg, err := AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + if cfg.Provider != "opencode" { + t.Errorf("Provider = %q, want opencode", cfg.Provider) + } +} + +// Ports test_aforge_bin_is_overridable. +func TestAforgeBinIsOverridable(t *testing.T) { + cleanEnv(t) + + t.Setenv("AFORGE_BIN", "/opt/aforge/bin/aforge") + cfg, err := AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + if cfg.AforgeBin != "/opt/aforge/bin/aforge" { + t.Errorf("AforgeBin = %q", cfg.AforgeBin) + } + + // The node-specific variable wins over the generic one. + t.Setenv("CLOUDSECURITY_AFORGE_BIN", "/usr/local/bin/aforge") + cfg, err = AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + if cfg.AforgeBin != "/usr/local/bin/aforge" { + t.Errorf("AforgeBin = %q", cfg.AforgeBin) + } +} + +// Ports test_installed_sdk_supports_the_aforge_harness: the pinned SDK must +// accept the provider/bin this node wires up. In Go the aforge/opencode binary +// override is the single HarnessConfig.BinPath field (the Python SDK has two +// separate aforge_bin/opencode_bin keyword arguments), which is why the node +// package picks between AforgeBin and OpencodeBin by provider — this test pins +// the SDK-side shape that choice targets. +func TestInstalledSDKSupportsTheAforgeHarness(t *testing.T) { + cleanEnv(t) + + cfg, err := AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + providerEnv, err := cfg.ProviderEnv() + if err != nil { + t.Fatalf("ProviderEnv: %v", err) + } + hc := agent.HarnessConfig{ + Provider: cfg.Provider, + Model: cfg.HarnessModel, + MaxTurns: cfg.MaxTurns, + Env: providerEnv, + 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) + } +} + +// The precedence chains are the whole point of the default_factory lambdas, so +// each hop gets its own case. +func TestAIConfigFromEnv_Precedence(t *testing.T) { + t.Run("all defaults", func(t *testing.T) { + cleanEnv(t) + cfg, err := AIConfigFromEnv() + if err != nil { + t.Fatal(err) + } + want := AIIntegrationConfig{ + Provider: "aforge", + HarnessModel: "openrouter/minimax/minimax-m2.5", + AIModel: "openrouter/minimax/minimax-m2.5", + MaxTurns: 50, + OpencodeBin: "opencode", + AforgeBin: "aforge", + } + if !reflect.DeepEqual(cfg, want) { + t.Fatalf("config = %+v, want %+v", cfg, want) + } + }) + + t.Run("generic vars are the second hop", func(t *testing.T) { + cleanEnv(t) + t.Setenv("HARNESS_PROVIDER", "opencode") + t.Setenv("HARNESS_MODEL", "generic/model") + t.Setenv("AI_MODEL", "generic/ai") + cfg, _ := AIConfigFromEnv() + if cfg.Provider != "opencode" || cfg.HarnessModel != "generic/model" || cfg.AIModel != "generic/ai" { + t.Fatalf("config = %+v", cfg) + } + }) + + t.Run("node vars win over generic", func(t *testing.T) { + cleanEnv(t) + t.Setenv("HARNESS_PROVIDER", "opencode") + t.Setenv("CLOUDSECURITY_PROVIDER", "aforge") + t.Setenv("HARNESS_MODEL", "generic/model") + t.Setenv("CLOUDSECURITY_MODEL", "node/model") + t.Setenv("AI_MODEL", "generic/ai") + t.Setenv("CLOUDSECURITY_AI_MODEL", "node/ai") + cfg, _ := AIConfigFromEnv() + if cfg.Provider != "aforge" || cfg.HarnessModel != "node/model" || cfg.AIModel != "node/ai" { + t.Fatalf("config = %+v", cfg) + } + }) + + // ai_model's third hop is CLOUDSECURITY_MODEL — the harness model — not the + // literal default. + t.Run("ai_model falls back to the harness model", func(t *testing.T) { + cleanEnv(t) + t.Setenv("CLOUDSECURITY_MODEL", "node/model") + cfg, _ := AIConfigFromEnv() + if cfg.AIModel != "node/model" { + t.Fatalf("AIModel = %q, want node/model", cfg.AIModel) + } + }) + + // os.getenv(key, default) does NOT substitute for a key set to "". + t.Run("a set-but-empty var is honoured, not defaulted", func(t *testing.T) { + cleanEnv(t) + t.Setenv("CLOUDSECURITY_PROVIDER", "") + cfg, _ := AIConfigFromEnv() + if cfg.Provider != "" { + t.Fatalf("Provider = %q, want the empty string", cfg.Provider) + } + }) +} + +// Python parity: int() inside the default_factory raises, and because app.py +// builds the config at import time that means the node does not boot. +func TestAIConfigFromEnv_MalformedMaxTurnsIsAnError(t *testing.T) { + cleanEnv(t) + t.Setenv("CLOUDSECURITY_MAX_TURNS", "fifty") + + _, err := AIConfigFromEnv() + if err == nil { + t.Fatal("expected an error for a malformed CLOUDSECURITY_MAX_TURNS") + } + want := "invalid literal for int() with base 10: 'fifty'" + if err.Error() != want { + t.Fatalf("error = %q, want %q", err.Error(), want) + } +} + +func TestAIConfigFromEnv_MaxTurnsOverride(t *testing.T) { + cleanEnv(t) + t.Setenv("CLOUDSECURITY_MAX_TURNS", "7") + cfg, err := AIConfigFromEnv() + if err != nil { + t.Fatal(err) + } + if cfg.MaxTurns != 7 { + t.Fatalf("MaxTurns = %d, want 7", cfg.MaxTurns) + } +} + +// --------------------------------------------------------------------------- +// provider_env +// --------------------------------------------------------------------------- + +func TestProviderEnv_ForwardsOnlyNonEmptyCredentials(t *testing.T) { + cleanEnv(t) + xdg := t.TempDir() + t.Setenv("XDG_DATA_HOME", xdg) + t.Setenv("OPENROUTER_API_KEY", "or-key") + t.Setenv("AWS_ACCESS_KEY_ID", "akid") + t.Setenv("AWS_SECRET_ACCESS_KEY", "") // set-but-empty -> dropped + + cfg, _ := AIConfigFromEnv() + env, err := cfg.ProviderEnv() + if err != nil { + t.Fatalf("ProviderEnv: %v", err) + } + + want := map[string]string{ + "OPENROUTER_API_KEY": "or-key", + "AWS_ACCESS_KEY_ID": "akid", + "AGENTFIELD_AFORGE_COMMAND": "exec", + "XDG_DATA_HOME": xdg, + } + if !reflect.DeepEqual(env, want) { + t.Fatalf("ProviderEnv = %#v, want %#v", env, want) + } +} + +// The key list is a packaging contract (the manifest's user_environment block +// mirrors it), so it is pinned exactly and in order. +func TestProviderEnvKeys_MatchThePythonTuple(t *testing.T) { + want := []string{ + "OPENROUTER_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GOOGLE_API_KEY", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "GOOGLE_APPLICATION_CREDENTIALS", + "AZURE_CLIENT_ID", + "AZURE_CLIENT_SECRET", + "AZURE_TENANT_ID", + "AZURE_SUBSCRIPTION_ID", + } + if got := ProviderEnvKeys(); !reflect.DeepEqual(got, want) { + t.Fatalf("ProviderEnvKeys() = %#v, want %#v", got, want) + } +} + +func TestProviderEnv_CreatesTheDefaultXDGDataHome(t *testing.T) { + cleanEnv(t) + tmp := t.TempDir() + t.Setenv("TMPDIR", tmp) // os.TempDir() reads $TMPDIR + + cfg, _ := AIConfigFromEnv() + env, err := cfg.ProviderEnv() + if err != nil { + t.Fatalf("ProviderEnv: %v", err) + } + + want := filepath.Join(tmp, "opencode-shared-data") + if env["XDG_DATA_HOME"] != want { + t.Fatalf("XDG_DATA_HOME = %q, want %q", env["XDG_DATA_HOME"], want) + } + info, err := os.Stat(want) + if err != nil || !info.IsDir() { + t.Fatalf("ProviderEnv did not create %s: %v", want, err) + } +} + +// Python parity: XDG_DATA_HOME uses `or`, so a set-but-EMPTY value still falls +// back — unlike AGENTFIELD_AFORGE_COMMAND, which uses os.getenv's default and +// therefore keeps an explicit "". +func TestProviderEnv_EmptyStringHandlingDiffersPerKey(t *testing.T) { + cleanEnv(t) + tmp := t.TempDir() + t.Setenv("TMPDIR", tmp) + t.Setenv("XDG_DATA_HOME", "") + t.Setenv("AGENTFIELD_AFORGE_COMMAND", "") + + env, _ := AIConfigFromEnv() + got, err := env.ProviderEnv() + if err != nil { + t.Fatalf("ProviderEnv: %v", err) + } + + if got["XDG_DATA_HOME"] != filepath.Join(tmp, "opencode-shared-data") { + t.Errorf("XDG_DATA_HOME = %q, want the temp fallback", got["XDG_DATA_HOME"]) + } + if got["AGENTFIELD_AFORGE_COMMAND"] != "" { + t.Errorf("AGENTFIELD_AFORGE_COMMAND = %q, want the explicit empty string", got["AGENTFIELD_AFORGE_COMMAND"]) + } +} + +// --------------------------------------------------------------------------- +// DepthProfile — ports test_config.py::TestDepthProfile +// --------------------------------------------------------------------------- + +// Ports TestDepthProfile::test_enum_values. +func TestDepthProfile_EnumValues(t *testing.T) { + if DepthQuick != "quick" || DepthStandard != "standard" || DepthThorough != "thorough" { + t.Fatalf("depth values drifted: %q %q %q", DepthQuick, DepthStandard, DepthThorough) + } +} + +// Ports TestDepthProfile::test_quick_hunters and ::test_standard_hunters, and +// additionally pins the ORDER, which hunt_phase's fan-out preserves. +func TestDepthProfile_HunterMap(t *testing.T) { + quick := DepthHunterMap[DepthQuick] + wantQuick := []string{"iam", "network", "data", "secrets", "compute"} + if !reflect.DeepEqual(quick, wantQuick) { + t.Fatalf("quick hunters = %v, want %v", quick, wantQuick) + } + + standard := DepthHunterMap[DepthStandard] + wantStandard := []string{"iam", "network", "data", "secrets", "compute", "logging", "compliance"} + if !reflect.DeepEqual(standard, wantStandard) { + t.Fatalf("standard hunters = %v, want %v", standard, wantStandard) + } + if len(standard) != 7 { + t.Fatalf("standard hunters len = %d, want 7", len(standard)) + } + if !reflect.DeepEqual(DepthHunterMap[DepthThorough], wantStandard) { + t.Fatalf("thorough hunters = %v, want the standard list", DepthHunterMap[DepthThorough]) + } +} + +func TestHuntersForDepth_ReturnsAnIsolatedCopy(t *testing.T) { + got := HuntersForDepth(DepthQuick) + got[0] = "mutated" + if DepthHunterMap[DepthQuick][0] != "iam" { + t.Fatal("HuntersForDepth handed out the shared backing array") + } + if fallback := HuntersForDepth("nonsense"); !reflect.DeepEqual(fallback, DepthHunterMap[DepthStandard]) { + t.Fatalf("unknown profile = %v, want the standard list", fallback) + } +} + +// Ports TestDepthProfile::test_chain_limits. +func TestDepthProfile_ChainLimits(t *testing.T) { + want := map[DepthProfile]int{DepthQuick: 5, DepthStandard: 15, DepthThorough: 100} + if !reflect.DeepEqual(DepthChainLimits, want) { + t.Fatalf("DepthChainLimits = %v, want %v", DepthChainLimits, want) + } +} + +// Ports TestDepthProfile::test_prover_caps — WITH A DELIBERATE CORRECTION. +// +// The Python test asserts DEPTH_PROVER_CAPS[QUICK] == 10, but config.py has +// declared 20 since the value was raised; that Python assertion FAILS on main +// today (verified: `DEPTH_PROVER_CAPS[DepthProfile.QUICK]` prints 20 under this +// repo's interpreter). The port follows the code, which is what actually runs +// and what prove_phase caps against. If the Python test is ever repaired, it +// will move to 20 and match this. +func TestDepthProverCaps_QuickIsTwentyNotTheStalePythonTestValue(t *testing.T) { + want := map[DepthProfile]int{DepthQuick: 20, DepthStandard: 30, DepthThorough: 10_000} + if !reflect.DeepEqual(DepthProverCaps, want) { + t.Fatalf("DepthProverCaps = %v, want %v", DepthProverCaps, want) + } +} + +// --------------------------------------------------------------------------- +// ParseDepth / NormalizeDepth +// --------------------------------------------------------------------------- + +// Python: DepthProfile("bogus") -> ValueError: 'bogus' is not a valid DepthProfile +func TestParseDepth_IsStrictAndCaseSensitive(t *testing.T) { + for _, ok := range []string{"quick", "standard", "thorough"} { + if got, err := ParseDepth(ok); err != nil || string(got) != ok { + t.Fatalf("ParseDepth(%q) = %v, %v", ok, got, err) + } + } + for _, bad := range []string{"bogus", "Quick", "", "QUICK", "deep"} { + _, err := ParseDepth(bad) + if err == nil { + t.Fatalf("ParseDepth(%q) accepted an invalid depth", bad) + } + want := "'" + bad + "' is not a valid DepthProfile" + if err.Error() != want { + t.Errorf("ParseDepth(%q) error = %q, want %q", bad, err.Error(), want) + } + } +} + +// Ports reasoners/phases.py::_normalize_depth. +func TestNormalizeDepth_LowercasesAndFallsBackToStandard(t *testing.T) { + cases := map[string]DepthProfile{ + "quick": DepthQuick, + "QUICK": DepthQuick, + "Quick": DepthQuick, + "standard": DepthStandard, + "thorough": DepthThorough, + "ThOrOuGh": DepthThorough, + "bogus": DepthStandard, + "": DepthStandard, + "deep": DepthStandard, + } + for in, want := range cases { + if got := NormalizeDepth(in); got != want { + t.Errorf("NormalizeDepth(%q) = %q, want %q", in, got, want) + } + } +} + +// --------------------------------------------------------------------------- +// BudgetConfig — ports test_config.py::TestBudgetConfig +// --------------------------------------------------------------------------- + +// Ports TestBudgetConfig::test_defaults. +func TestBudgetConfig_Defaults(t *testing.T) { + b := NewBudgetConfig() + if b.MaxConcurrentHunters != 4 { + t.Errorf("MaxConcurrentHunters = %d, want 4", b.MaxConcurrentHunters) + } + if b.MaxConcurrentProvers != 3 { + t.Errorf("MaxConcurrentProvers = %d, want 3", b.MaxConcurrentProvers) + } + if b.MaxConcurrentChainChildren != 3 { + t.Errorf("MaxConcurrentChainChildren = %d, want 3", b.MaxConcurrentChainChildren) + } + if b.MaxCostUSD != nil { + t.Errorf("MaxCostUSD = %v, want nil", *b.MaxCostUSD) + } + if b.MaxDurationSeconds != nil { + t.Errorf("MaxDurationSeconds = %v, want nil", *b.MaxDurationSeconds) + } + total := b.ReconBudgetPct + b.HuntBudgetPct + b.ChainBudgetPct + b.ProveBudgetPct + b.RemediateBudgetPct + if math.Abs(total-1.0) > 1e-9 { + t.Errorf("budget percentages total %v, want 1.0", total) + } +} + +func TestBudgetConfig_UnmarshalSeedsDefaults(t *testing.T) { + var b BudgetConfig + if err := json.Unmarshal([]byte(`{"max_concurrent_hunters": 1}`), &b); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if b.MaxConcurrentHunters != 1 { + t.Errorf("MaxConcurrentHunters = %d, want 1", b.MaxConcurrentHunters) + } + if b.MaxConcurrentProvers != 3 || b.HuntBudgetPct != 0.35 { + t.Errorf("defaults were not seeded: %+v", b) + } +} + +// --------------------------------------------------------------------------- +// ScanConfig — ports test_config.py::TestScanConfig +// --------------------------------------------------------------------------- + +// testCloudSecurityInput mirrors schemas.CloudSecurityInput's serialized shape +// (that package is owned by another part of the port; this local copy keeps the +// config tests independent of its landing order). newTestInput seeds the same +// pydantic defaults the real model does. +type testCloudSecurityInput struct { + RepoURL string `json:"repo_url"` + Branch string `json:"branch"` + Depth string `json:"depth"` + SeverityThreshold string `json:"severity_threshold"` + OutputFormats []string `json:"output_formats"` + ComplianceFrameworks []string `json:"compliance_frameworks"` + Cloud any `json:"cloud"` + MaxCostUSD *float64 `json:"max_cost_usd"` + MaxDurationSeconds *int `json:"max_duration_seconds"` + MaxConcurrentHunters *int `json:"max_concurrent_hunters"` + MaxConcurrentProvers *int `json:"max_concurrent_provers"` + IncludePaths []string `json:"include_paths"` + ExcludePaths []string `json:"exclude_paths"` +} + +func newTestInput(repoURL string) testCloudSecurityInput { + return testCloudSecurityInput{ + RepoURL: repoURL, + Branch: "main", + Depth: "standard", + SeverityThreshold: "low", + OutputFormats: DefaultOutputFormats(), + ComplianceFrameworks: []string{}, + ExcludePaths: DefaultExcludePaths(), + } +} + +// Ports TestScanConfig::test_from_input_tier1. The full expected model_dump is +// pinned because it is the Python transcript: +// +// {'repo_path': '/tmp/repo', 'depth': , 'tier': 1, +// 'severity_threshold': 'low', 'output_formats': ['json'], +// 'compliance_frameworks': [], 'include_paths': None, +// 'exclude_paths': ['tests/', '.git/', 'examples/', '.terraform/'], +// 'budget': {...max_concurrent_hunters: 4, max_concurrent_provers: 3...}} +func TestScanConfig_FromInputTier1(t *testing.T) { + in := newTestInput("/tmp/repo") + in.Depth = "quick" + + cfg, err := ScanConfigFromInput(in, "/tmp/repo") + if err != nil { + t.Fatalf("ScanConfigFromInput: %v", err) + } + if cfg.Depth != DepthQuick { + t.Errorf("Depth = %q, want quick", cfg.Depth) + } + if cfg.Tier != 1 { + t.Errorf("Tier = %d, want 1", cfg.Tier) + } + if cfg.RepoPath != "/tmp/repo" { + t.Errorf("RepoPath = %q", cfg.RepoPath) + } + if cfg.SeverityThreshold != "low" { + t.Errorf("SeverityThreshold = %q", cfg.SeverityThreshold) + } + if !reflect.DeepEqual(cfg.OutputFormats, []string{"json"}) { + t.Errorf("OutputFormats = %v", cfg.OutputFormats) + } + if !reflect.DeepEqual(cfg.ComplianceFrameworks, []string{}) { + t.Errorf("ComplianceFrameworks = %v", cfg.ComplianceFrameworks) + } + if cfg.IncludePaths != nil { + t.Errorf("IncludePaths = %v, want nil (Python None)", cfg.IncludePaths) + } + if !reflect.DeepEqual(cfg.ExcludePaths, DefaultExcludePaths()) { + t.Errorf("ExcludePaths = %v", cfg.ExcludePaths) + } + if cfg.Budget.MaxConcurrentHunters != 4 || cfg.Budget.MaxConcurrentProvers != 3 { + t.Errorf("Budget = %+v, want the defaults", cfg.Budget) + } + if cfg.Budget.MaxCostUSD != nil || cfg.Budget.MaxDurationSeconds != nil { + t.Errorf("Budget caps = %+v, want nil", cfg.Budget) + } +} + +// Ports TestScanConfig::test_from_input_tier2. Tier is CloudSecurityInput.tier, +// a @property that is 2 as soon as `cloud` is present, so the port derives it +// from the serialized `cloud` key. +func TestScanConfig_FromInputTier2(t *testing.T) { + in := newTestInput("/tmp/repo") + in.Cloud = map[string]any{"provider": "aws", "regions": []string{"us-east-1"}} + + cfg, err := ScanConfigFromInput(in, "/tmp/repo") + if err != nil { + t.Fatalf("ScanConfigFromInput: %v", err) + } + if cfg.Tier != 2 { + t.Fatalf("Tier = %d, want 2", cfg.Tier) + } +} + +func TestScanConfig_ExplicitNullCloudIsStillTier1(t *testing.T) { + cfg, err := ScanConfigFromInput(map[string]any{"depth": "standard", "cloud": nil}, "/tmp/repo") + if err != nil { + t.Fatalf("ScanConfigFromInput: %v", err) + } + if cfg.Tier != 1 { + t.Fatalf("Tier = %d, want 1", cfg.Tier) + } +} + +// Ports TestScanConfig::test_from_input_budget_override. +func TestScanConfig_FromInputBudgetOverride(t *testing.T) { + hunters, provers, cost := 2, 1, 5.0 + in := newTestInput("/tmp/repo") + in.MaxConcurrentHunters = &hunters + in.MaxConcurrentProvers = &provers + in.MaxCostUSD = &cost + + cfg, err := ScanConfigFromInput(in, "/tmp/repo") + if err != nil { + t.Fatalf("ScanConfigFromInput: %v", err) + } + if cfg.Budget.MaxConcurrentHunters != 2 { + t.Errorf("MaxConcurrentHunters = %d, want 2", cfg.Budget.MaxConcurrentHunters) + } + if cfg.Budget.MaxConcurrentProvers != 1 { + t.Errorf("MaxConcurrentProvers = %d, want 1", cfg.Budget.MaxConcurrentProvers) + } + if cfg.Budget.MaxCostUSD == nil || *cfg.Budget.MaxCostUSD != 5.0 { + t.Errorf("MaxCostUSD = %v, want 5.0", cfg.Budget.MaxCostUSD) + } +} + +// Python parity: from_input uses the STRICT DepthProfile constructor, so an +// unknown depth is a ValueError, which app.py turns into HTTP 400. It must NOT +// silently normalize to standard. +func TestScanConfig_FromInputRejectsAnUnknownDepth(t *testing.T) { + in := newTestInput("/tmp/repo") + in.Depth = "bogus" + + _, err := ScanConfigFromInput(in, "/tmp/repo") + if err == nil { + t.Fatal("expected a ValueError-equivalent for depth=bogus") + } + if err.Error() != "'bogus' is not a valid DepthProfile" { + t.Fatalf("error = %q", err.Error()) + } +} + +func TestScanConfig_FromInputAcceptsTheTypedView(t *testing.T) { + cfg, err := ScanConfigFromInput(ScanInput{Depth: "thorough", Tier: 2, SeverityThreshold: "high"}, "/repo") + if err != nil { + t.Fatalf("ScanConfigFromInput: %v", err) + } + if cfg.Depth != DepthThorough || cfg.Tier != 2 || cfg.SeverityThreshold != "high" { + t.Fatalf("config = %+v", cfg) + } +} + +func TestNewScanConfig_Defaults(t *testing.T) { + cfg := NewScanConfig("/repo") + if cfg.Depth != DepthStandard || cfg.Tier != 1 || cfg.SeverityThreshold != "low" { + t.Fatalf("config = %+v", cfg) + } + // Mutating the returned slices must not affect the next call. + cfg.ExcludePaths[0] = "mutated" + if NewScanConfig("/repo").ExcludePaths[0] != "tests/" { + t.Fatal("the exclude_paths default_factory handed out a shared slice") + } +} + +func TestScanConfig_UnmarshalSeedsDefaults(t *testing.T) { + var cfg ScanConfig + if err := json.Unmarshal([]byte(`{"repo_path": "/r", "tier": 2}`), &cfg); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if cfg.RepoPath != "/r" || cfg.Tier != 2 { + t.Fatalf("config = %+v", cfg) + } + if cfg.Depth != DepthStandard || cfg.SeverityThreshold != "low" { + t.Fatalf("defaults were not seeded: %+v", cfg) + } + if !reflect.DeepEqual(cfg.ExcludePaths, DefaultExcludePaths()) { + t.Fatalf("ExcludePaths = %v", cfg.ExcludePaths) + } +} + +// VALIDATION CONTRACT — an unwritable XDG_DATA_HOME fails the BOOT. +// +// config.py:135 is a bare `os.makedirs(xdg, exist_ok=True)` (exist_ok +// suppresses only FileExistsError) and provider_env() runs in app.py's module +// body, inside the Agent(...) literal — so the process never registers. +// Ground truth from the repo venv, with XDG_DATA_HOME pointing under a path +// component that is a regular file: +// +// AIIntegrationConfig.from_env().provider_env() +// -> NotADirectoryError: [Errno 20] Not a directory: '/notadir/sub' +// +// Swallowing it instead lets the node register, answer /health 200 and appear +// live in the control-plane UI, then fail every scan deep inside the first +// harness invocation. +func TestProviderEnv_UnwritableXDGDataHomeIsAnError(t *testing.T) { + cleanEnv(t) + tmp := t.TempDir() + blocker := filepath.Join(tmp, "notadir") + if err := os.WriteFile(blocker, nil, 0o600); err != nil { + t.Fatalf("write blocker: %v", err) + } + t.Setenv("XDG_DATA_HOME", filepath.Join(blocker, "sub")) + + cfg, err := AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + env, err := cfg.ProviderEnv() + if err == nil { + t.Fatalf("ProviderEnv returned %#v; Python raises NotADirectoryError here", env) + } + if env != nil { + t.Errorf("ProviderEnv returned a partial env alongside the error: %#v", env) + } + if !strings.Contains(err.Error(), "XDG_DATA_HOME") { + t.Errorf("error %q does not name XDG_DATA_HOME", err) + } +} diff --git a/go/internal/harnessx/harnessx_test.go b/go/internal/harnessx/harnessx_test.go new file mode 100644 index 0000000..9452923 --- /dev/null +++ b/go/internal/harnessx/harnessx_test.go @@ -0,0 +1,491 @@ +package harnessx + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/harness" +) + +// HuntResult is a stand-in for the schemas package's type of the same name. Its +// NAME is what matters: SchemaFor resolves the committed pydantic fixture by Go +// type name, so declaring it here exercises the real fixture lookup without +// depending on internal/schemas landing first. +type HuntResult struct { + Findings []map[string]any `json:"findings"` + TotalRaw int `json:"total_raw"` +} + +// unfixturedResult has no fixture under testdata/schemas, so it takes the +// invopop reflection path. +type unfixturedResult struct { + Title string `json:"title"` + Count int `json:"count"` +} + +// --------------------------------------------------------------------------- +// fakes +// --------------------------------------------------------------------------- + +type fakeHarness struct { + // recorded call + prompt string + schema map[string]any + opts harness.Options + calls int + + // programmed response + fill func(dest any) + result *harness.Result + err error +} + +func (f *fakeHarness) Harness(_ context.Context, prompt string, schema map[string]any, dest any, opts harness.Options) (*harness.Result, error) { + f.calls++ + f.prompt = prompt + f.schema = schema + f.opts = opts + if f.fill != nil { + f.fill(dest) + } + if f.result != nil && f.result.Parsed == nil && !f.result.IsError { + // Mirror the SDK, which stores the destination pointer it was handed. + f.result.Parsed = dest + } + return f.result, f.err +} + +// captureDiagnostics swaps the stdout sink Extract writes to (Python's +// print(..., flush=True)) for the duration of a test. +func captureDiagnostics(t *testing.T) *bytes.Buffer { + t.Helper() + buf := &bytes.Buffer{} + old := diagnosticsOut + diagnosticsOut = buf + t.Cleanup(func() { diagnosticsOut = old }) + return buf +} + +// --------------------------------------------------------------------------- +// SchemaFor +// --------------------------------------------------------------------------- + +func TestSchemaFor_PrefersTheCommittedPydanticFixture(t *testing.T) { + got := SchemaFor[HuntResult]() + + fixture, err := LoadEmbeddedSchema("HuntResult") + if err != nil { + t.Fatalf("LoadEmbeddedSchema: %v", err) + } + if !reflect.DeepEqual(got, fixture) { + t.Fatal("SchemaFor did not return the committed fixture") + } + // The fixture is pydantic's, not invopop's: pydantic titles the root with + // the class name and emits $defs for the nested models. + if got["title"] != "HuntResult" { + t.Errorf("title = %v, want HuntResult", got["title"]) + } + if _, ok := got["$defs"]; !ok { + t.Error("expected pydantic $defs in the fixture") + } + // The whole point of embedding pydantic's schema: defaulted fields are + // OPTIONAL and extra keys are allowed, unlike an invopop reflection. + if _, ok := got["additionalProperties"]; ok { + t.Error("pydantic does not set additionalProperties on the root; the fixture does") + } +} + +func TestSchemaFor_FallsBackToInvopopReflectionWithoutAFixture(t *testing.T) { + got := SchemaFor[unfixturedResult]() + + props, ok := got["properties"].(map[string]any) + if !ok { + t.Fatalf("reflected schema has no inlined properties: %#v", got) + } + for _, key := range []string{"title", "count"} { + if _, ok := props[key]; !ok { + t.Errorf("reflected properties missing %q: %#v", key, props) + } + } + // ExpandedStruct inlines the root, and Anonymous suppresses the $id derived + // from the package path — both are load-bearing for the SDK's + // DiagnoseOutputFailure, which reads map["properties"]. + if _, ok := got["$id"]; ok { + t.Error("reflector emitted an $id; Anonymous must suppress it") + } +} + +func TestSchemaFor_CachesPerType(t *testing.T) { + a := SchemaFor[HuntResult]() + b := SchemaFor[HuntResult]() + if !sameMap(a, b) { + t.Fatal("SchemaFor rebuilt the schema instead of serving the cache") + } +} + +func sameMap(a, b map[string]any) bool { + return reflect.ValueOf(a).Pointer() == reflect.ValueOf(b).Pointer() +} + +// --------------------------------------------------------------------------- +// Committed fixtures +// --------------------------------------------------------------------------- + +// Every fixture must decode and describe an object. gen_schemas.py is the only +// thing that writes these, so a failure here means a hand-edit or a truncated +// generator run. +// +// The struct-tag cross-check that completes this one lives in +// internal/schemas as TestEmbeddedSchemasMatchGoStructTags: for every fixture +// (and every $defs sub-model) it asserts the schema's "properties" keys and the +// Go destination struct's json tags are the same set, and that every "required" +// name has a corresponding tag. It lives there because it needs the schemas +// package's types and harnessx must not depend on that package. +func TestEmbeddedSchemas_DecodeAndDescribeAnObject(t *testing.T) { + names := EmbeddedSchemaNames() + want := []string{ + "AttackPath", + "DriftReport", + "HuntResult", + "PathInvestigationPlan", + "RemediationSuggestion", + "ResourceGraph", + "ResourceInventory", + "VerifiedFinding", + } + if !reflect.DeepEqual(names, want) { + t.Fatalf("EmbeddedSchemaNames() = %v, want %v (regenerate with go/scripts/gen_schemas.py)", names, want) + } + + for _, name := range names { + schema, err := LoadEmbeddedSchema(name) + if err != nil { + t.Errorf("fixture %s does not decode: %v", name, err) + continue + } + if schema["type"] != "object" { + t.Errorf("fixture %s type = %v, want object", name, schema["type"]) + } + if _, ok := schema["properties"].(map[string]any); !ok { + t.Errorf("fixture %s has no properties object", name) + } + if schema["title"] != name { + t.Errorf("fixture %s title = %v, want %s (the fixture basename must be the pydantic class name)", name, schema["title"], name) + } + } +} + +// The fixtures must round-trip through the marshaling the SDK does before +// handing them to the validator. +func TestEmbeddedSchemas_AreMarshalable(t *testing.T) { + for _, name := range EmbeddedSchemaNames() { + schema, err := LoadEmbeddedSchema(name) + if err != nil { + t.Fatalf("LoadEmbeddedSchema(%s): %v", name, err) + } + if _, err := json.Marshal(schema); err != nil { + t.Errorf("fixture %s does not marshal: %v", name, err) + } + } +} + +func TestLoadEmbeddedSchema_MissingFixtureIsAnError(t *testing.T) { + if _, err := LoadEmbeddedSchema("NoSuchModel"); err == nil { + t.Fatal("expected an error for a missing fixture") + } +} + +// --------------------------------------------------------------------------- +// Run +// --------------------------------------------------------------------------- + +func TestRun_PassesTheFixtureSchemaAndTypedDestination(t *testing.T) { + fake := &fakeHarness{ + fill: func(dest any) { dest.(*HuntResult).TotalRaw = 7 }, + result: &harness.Result{Result: "ok"}, + } + opts := harness.Options{Cwd: "/tmp/work", ProjectDir: "/repo"} + + dest, res, err := Run[HuntResult](context.Background(), fake, "find things", opts) + if err != nil { + t.Fatalf("Run: %v", err) + } + if fake.prompt != "find things" { + t.Errorf("prompt = %q", fake.prompt) + } + if !reflect.DeepEqual(fake.opts, opts) { + t.Errorf("opts = %+v, want %+v (Run must pass options through unchanged)", fake.opts, opts) + } + if fake.schema["title"] != "HuntResult" { + t.Errorf("schema title = %v, want the committed pydantic fixture", fake.schema["title"]) + } + if dest == nil || dest.TotalRaw != 7 { + t.Fatalf("dest = %+v, want the value the harness wrote", dest) + } + if res == nil || res.Parsed == nil { + t.Fatal("Run must return the raw Result for the caller to classify") + } +} + +// A transport failure is the ONLY thing Run reports as an error — the Go +// analogue of the Python SDK's .harness() raising. +func TestRun_ReturnsTransportErrorsAndTheResult(t *testing.T) { + boom := errors.New("subprocess died") + fake := &fakeHarness{result: &harness.Result{IsError: true, ErrorMessage: "x"}, err: boom} + + dest, res, err := Run[HuntResult](context.Background(), fake, "p", harness.Options{}) + if !errors.Is(err, boom) { + t.Fatalf("err = %v, want %v", err, boom) + } + if dest != nil { + t.Errorf("dest = %+v, want nil on a transport error", dest) + } + if res == nil { + t.Error("the Result should still be handed back for diagnostics") + } +} + +// An in-band harness error is NOT a Run error: Python's +// `result = await app.harness(...)` succeeds and extract_harness_result raises. +func TestRun_InBandHarnessErrorIsNotATransportError(t *testing.T) { + fake := &fakeHarness{result: &harness.Result{IsError: true, ErrorMessage: "model refused"}} + + _, res, err := Run[HuntResult](context.Background(), fake, "p", harness.Options{}) + if err != nil { + t.Fatalf("Run returned %v; in-band errors belong to Extract", err) + } + if !res.IsError { + t.Fatal("the Result lost its error flag") + } +} + +// --------------------------------------------------------------------------- +// Extract — ports tests/test_utils.py::TestExtractHarnessResult +// --------------------------------------------------------------------------- + +// Ports test_parsed_is_correct_type. +func TestExtract_ParsedIsCorrectType(t *testing.T) { + captureDiagnostics(t) + dest := &HuntResult{TotalRaw: 5} + res := &harness.Result{IsError: false, Parsed: dest} + + got, err := Extract[HuntResult](res, dest, "test") + if err != nil { + t.Fatalf("Extract: %v", err) + } + if got.TotalRaw != 5 { + t.Fatalf("got = %+v", got) + } +} + +// Ports test_parsed_is_dict_validates. +// +// Python's second branch re-validates a raw dict left in `parsed`. The Go SDK +// never does that — it decodes straight into the destination pointer and stores +// that pointer in Parsed (harness/runner.go: `Parsed: dest`) — so the Go +// equivalent of "the harness produced {'findings': [], 'total_raw': 5}" is the +// destination already carrying those values. +func TestExtract_ParsedDictEquivalentIsTheDecodedDestination(t *testing.T) { + captureDiagnostics(t) + dest := &HuntResult{} + if err := json.Unmarshal([]byte(`{"findings": [], "total_raw": 5}`), dest); err != nil { + t.Fatalf("seeding dest: %v", err) + } + res := &harness.Result{IsError: false, Parsed: dest} + + got, err := Extract[HuntResult](res, dest, "test") + if err != nil { + t.Fatalf("Extract: %v", err) + } + if got.TotalRaw != 5 { + t.Fatalf("TotalRaw = %d, want 5", got.TotalRaw) + } +} + +// Ports test_error_raises (pytest.raises(RuntimeError, match="test harness error")). +func TestExtract_HarnessErrorMessageAndDiagnostics(t *testing.T) { + buf := captureDiagnostics(t) + res := &harness.Result{ + IsError: true, + ErrorMessage: "something broke", + Result: "", + NumTurns: 3, + DurationMS: 1000, + } + + _, err := Extract[HuntResult](res, &HuntResult{}, "test") + if err == nil { + t.Fatal("expected an error") + } + if err.Error() != "test harness error: something broke" { + t.Fatalf("error = %q", err.Error()) + } + + // The diagnostic block is the Python print(), verbatim. Python's + // `str(result_text)[:500] if result_text else None` renders a falsy result + // text as "None". + want := "[test] HARNESS ERROR: something broke\n" + + " turns=3, duration_ms=1000\n" + + " result_text=None\n" + if buf.String() != want { + t.Fatalf("diagnostics =\n%q\nwant\n%q", buf.String(), want) + } +} + +// Python parity: error_message is Optional[str], so an absent one renders as +// the literal "None" in both the diagnostic block and the raised message. +func TestExtract_MissingErrorMessageRendersAsNone(t *testing.T) { + buf := captureDiagnostics(t) + res := &harness.Result{IsError: true} + + _, err := Extract[HuntResult](res, &HuntResult{}, "iac_reader") + if err == nil || err.Error() != "iac_reader harness error: None" { + t.Fatalf("error = %v", err) + } + if !strings.Contains(buf.String(), "HARNESS ERROR: None") { + t.Fatalf("diagnostics = %q", buf.String()) + } +} + +// Python slices result_text to 500 CODE POINTS, not bytes. +func TestExtract_ResultTextIsTruncatedTo500Runes(t *testing.T) { + buf := captureDiagnostics(t) + long := strings.Repeat("é", 600) // 600 runes, 1200 bytes + res := &harness.Result{IsError: true, ErrorMessage: "e", Result: long} + + _, _ = Extract[HuntResult](res, &HuntResult{}, "n") + + out := buf.String() + idx := strings.Index(out, "result_text=") + if idx < 0 { + t.Fatalf("diagnostics = %q", out) + } + text := strings.TrimSuffix(out[idx+len("result_text="):], "\n") + if got := len([]rune(text)); got != 500 { + t.Fatalf("result_text = %d runes, want 500", got) + } +} + +// Ports test_invalid_parsed_raises_type_error +// (pytest.raises(TypeError, match="did not return a valid")). +func TestExtract_UnparsedResultIsATypeErrorEquivalent(t *testing.T) { + buf := captureDiagnostics(t) + res := &harness.Result{IsError: false, Parsed: nil} + + _, err := Extract[HuntResult](res, &HuntResult{}, "test") + if err == nil { + t.Fatal("expected an error") + } + if err.Error() != "test did not return a valid HuntResult" { + t.Fatalf("error = %q", err.Error()) + } + want := "[test] harness result type=Result, is_error=False, parsed type=NoneType\n" + if buf.String() != want { + t.Fatalf("diagnostics = %q, want %q", buf.String(), want) + } +} + +func TestExtract_NilResultIsATypeErrorEquivalent(t *testing.T) { + captureDiagnostics(t) + _, err := Extract[HuntResult](nil, &HuntResult{}, "test") + if err == nil || err.Error() != "test did not return a valid HuntResult" { + t.Fatalf("error = %v", err) + } +} + +// --------------------------------------------------------------------------- +// RunExtract +// --------------------------------------------------------------------------- + +func TestRunExtract_HappyPath(t *testing.T) { + captureDiagnostics(t) + fake := &fakeHarness{ + fill: func(dest any) { dest.(*HuntResult).TotalRaw = 11 }, + result: &harness.Result{}, + } + + got, err := RunExtract[HuntResult](context.Background(), fake, "p", harness.Options{Cwd: "/w"}, "iam_hunter") + if err != nil { + t.Fatalf("RunExtract: %v", err) + } + if got.TotalRaw != 11 { + t.Fatalf("got = %+v", got) + } + if fake.calls != 1 { + t.Fatalf("harness calls = %d, want 1", fake.calls) + } +} + +func TestRunExtract_PropagatesTheTransportError(t *testing.T) { + captureDiagnostics(t) + boom := errors.New("no such binary") + fake := &fakeHarness{err: boom} + + _, err := RunExtract[HuntResult](context.Background(), fake, "p", harness.Options{}, "iam_hunter") + if !errors.Is(err, boom) { + t.Fatalf("err = %v, want %v", err, boom) + } +} + +func TestRunExtract_MapsAnInBandHarnessError(t *testing.T) { + captureDiagnostics(t) + fake := &fakeHarness{result: &harness.Result{IsError: true, ErrorMessage: "rate limited"}} + + _, err := RunExtract[HuntResult](context.Background(), fake, "p", harness.Options{}, "iam_hunter") + if err == nil || err.Error() != "iam_hunter harness error: rate limited" { + t.Fatalf("err = %v", err) + } +} + +// TestEmbeddedSchemas_AreKeySortedLikeTheSDKWillRenderThem pins the invariant +// go/README.md's divergence 7 rests on. +// +// The SDK renders the schema into the prompt with json.MarshalIndent over the +// map[string]any SchemaFor returns (sdk/go/harness/schema.go), and Go sorts map +// keys — so the prompt block is alphabetised whatever the fixture's own byte +// order is. gen_schemas.py therefore writes the fixtures with sort_keys=True, +// which keeps the committed file and the prompt block byte-identical; without +// it the file would disagree with the prompt as well as with Python. +// +// It also pins that LoadEmbeddedSchema's UseNumber decode keeps pydantic's +// numeric literals (`"default": 0.0`), which a plain decode would re-render as +// `0` — a second, and fixable, prompt-text difference. +// +// The DIVERGENCE this documents is the order alone: Python appends +// json.dumps(model_json_schema(), indent=2), which keeps pydantic's field +// declaration order (for HuntResult, a 144-line diff of identical content and +// identical byte length). It is not fixable from this package — both +// agent.Harness and harness.BuildPromptSuffix take a map[string]any. +func TestEmbeddedSchemas_AreKeySortedLikeTheSDKWillRenderThem(t *testing.T) { + entries, err := embeddedSchemas.ReadDir("testdata/schemas") + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + if len(entries) == 0 { + t.Fatal("no committed schema fixtures") + } + for _, entry := range entries { + name := entry.Name() + raw, readErr := embeddedSchemas.ReadFile("testdata/schemas/" + name) + if readErr != nil { + t.Fatalf("read %s: %v", name, readErr) + } + decoded, err := LoadEmbeddedSchema(strings.TrimSuffix(name, ".json")) + if err != nil { + t.Fatalf("decode %s: %v", name, err) + } + rendered, err := json.MarshalIndent(decoded, "", " ") + if err != nil { + t.Fatalf("re-render %s: %v", name, err) + } + if string(rendered) != strings.TrimRight(string(raw), "\n") { + t.Errorf("%s is not in the key order the SDK renders it in; regenerate with "+ + "`PYTHONPATH=/src go/scripts/gen_schemas.py` (it writes sort_keys=True)", name) + } + } +} diff --git a/go/internal/harnessx/run.go b/go/internal/harnessx/run.go new file mode 100644 index 0000000..48c57d4 --- /dev/null +++ b/go/internal/harnessx/run.go @@ -0,0 +1,195 @@ +package harnessx + +import ( + "context" + "fmt" + "io" + "os" + "reflect" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" +) + +// diagnosticsOut is where Extract writes the diagnostic blocks that Python +// prints with print(..., flush=True) — i.e. stdout. It is a variable only so +// tests can capture the bytes; production code must never reassign it. +var diagnosticsOut io.Writer = os.Stdout + +// Run ports `await app.harness(prompt=..., schema=Model, cwd=..., project_dir=...)`. +// +// It resolves the committed pydantic schema for T (see SchemaFor), hands the +// SDK a fresh *T destination, and returns that destination together with the +// raw harness Result. +// +// ERROR CONTRACT: the returned error is non-nil ONLY for a transport-level +// failure of app.Harness itself — the Go analogue of the Python SDK's +// .harness() raising. Everything the harness reports IN-BAND (is_error, an +// unparsable payload) is left on the Result for Extract to classify, exactly as +// in Python where `result = await app.harness(...)` succeeds and +// `extract_harness_result(result, ...)` is what raises. +// +// Most callers want RunExtract, which is the pair Python always uses together. +func Run[T any](ctx context.Context, app appx.Harnesser, prompt string, opts harness.Options) (*T, *harness.Result, error) { + schema := SchemaFor[T]() + + var dest T + res, err := app.Harness(ctx, prompt, schema, &dest, opts) + if err != nil { + return nil, res, err + } + return &dest, res, nil +} + +// Extract ports extract_harness_result in src/cloudsecurity_af/agents/_utils.py. +// +// Python: +// +// 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 = (f"[{agent_name}] harness result type={type(result).__name__}, " +// f"is_error={getattr(result,'is_error','?')}, " +// f"parsed type={type(getattr(result,'parsed',None)).__name__}") +// 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 destination Run handed to the SDK. The Go SDK sets Result.Parsed +// to that very pointer on success (harness/runner.go: `Parsed: dest`), so a +// non-nil Parsed is the Go equivalent of Python's `isinstance(parsed, schema)`. +// The `isinstance(parsed, dict)` branch has no Go counterpart: the SDK never +// leaves a raw map in Parsed, it decodes straight into dest. +// +// Python parity: `error_message` is Optional[str] in the Python SDK, so an +// unset one renders as the literal "None" in both the printed block and the +// raised message. The Go field is a plain string, so "" is mapped back to +// "None" to keep the strings identical. +// +// Python parity: result_text is truncated to 500 CHARACTERS (Python slices code +// points, not bytes) and a falsy result text prints as "None". +// +// DIVERGENCE (documented): the debug line's `type(result).__name__` is +// "HarnessResult" in Python and "Result" in Go (the SDK's own type name), and +// `type(parsed).__name__` is the Go type name rather than the pydantic class +// name — except for nil, which prints "NoneType" as Python does. These are +// human-facing stdout diagnostics only; every machine-readable string (the two +// error messages) is byte-identical. +func Extract[T any](res *harness.Result, dest *T, agentName string) (T, error) { + var zero T + + if res == nil { + // Python: getattr(None, "is_error", False) is False and + // getattr(None, "parsed", None) is None -> the TypeError branch. + printDebugLine(agentName, "NoneType", "?", "NoneType") + return zero, fmt.Errorf("%s did not return a valid %s", agentName, TypeName[T]()) + } + + if res.IsError { + message := res.ErrorMessage + if message == "" { + message = "None" + } + resultText := "None" + if res.Result != "" { + resultText = runeSlice(res.Result, 500) + } + _, _ = fmt.Fprintf(diagnosticsOut, + "[%s] HARNESS ERROR: %s\n turns=%d, duration_ms=%d\n result_text=%s\n", + agentName, message, res.NumTurns, res.DurationMS, resultText) + return zero, fmt.Errorf("%s harness error: %s", agentName, message) + } + + if res.Parsed != nil && dest != nil { + return *dest, nil + } + + printDebugLine(agentName, "Result", boolStr(res.IsError), goTypeName(res.Parsed)) + return zero, fmt.Errorf("%s did not return a valid %s", agentName, TypeName[T]()) +} + +// RunExtract is Run followed by Extract — the pair every agent file in +// src/cloudsecurity_af/agents/** uses: +// +// result = await app.harness(prompt=..., schema=Model, cwd=...) +// return extract_harness_result(result, Model, "iac_reader") +// +// A transport error from Run is returned unchanged (Python would propagate the +// SDK's own exception); everything else goes through Extract's classification. +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) +} + +// printDebugLine emits the Python debug_message, terminated the way print() is. +func printDebugLine(agentName, resultType, isError, parsedType string) { + _, _ = fmt.Fprintf(diagnosticsOut, "[%s] harness result type=%s, is_error=%s, parsed type=%s\n", + agentName, resultType, isError, parsedType) +} + +// boolStr renders a Go bool the way Python's f-string renders one. +func boolStr(b bool) string { + if b { + return "True" + } + return "False" +} + +// goTypeName is the Go stand-in for type(x).__name__, with Python's "NoneType" +// for nil. +func goTypeName(v any) string { + if v == nil { + return "NoneType" + } + t := reflect.TypeOf(v) + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + if n := t.Name(); n != "" { + return n + } + return t.String() +} + +// TypeName returns T's bare Go name, which the port contract keeps equal to the +// pydantic class name — i.e. Python's schema.__name__ in the TypeError message. +// +// It is the same identifier SchemaFor/fixtureName key the committed pydantic +// fixture on, so it is exported: internal/aix names the destination type in +// every one of its error messages and must not answer that question with a +// second, independently-drifting implementation. +func TypeName[T any]() string { + t := reflect.TypeOf((*T)(nil)).Elem() + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + if n := t.Name(); n != "" { + return n + } + return t.String() +} + +// runeSlice reproduces Python's s[:n], which counts code points, not bytes. +func runeSlice(s string, n int) string { + if n <= 0 { + return "" + } + 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..c5bd36c --- /dev/null +++ b/go/internal/harnessx/schema.go @@ -0,0 +1,196 @@ +// Package harnessx is the single choke-point every cloudsecurity-af agent uses +// to call the AgentField harness for structured output. +// +// It replaces two Python mechanisms at once: +// +// - `await app.harness(prompt, schema=Model, cwd=..., project_dir=...)` — +// Run[T] supplies the JSON schema and the typed destination. +// - `extract_harness_result(result, Model, agent_name)` from +// src/cloudsecurity_af/agents/_utils.py — Extract[T] reproduces its error +// classification, its stdout diagnostics and its exact error strings. +// +// RunExtract[T] is the two of them together, which is what every agent file in +// this repo actually does. +// +// SCHEMA RESOLUTION. The Go SDK does not merely pretty-print the schema into the +// prompt: it VALIDATES every parsed output against it with +// santhosh-tekuri/jsonschema/v5 (harness/runner.go -> runSchemaValidation) and +// drives its schema-retry loop off validation failures. So the schema has to +// match Python's, or Python-valid model output gets rejected. SchemaFor[T] +// therefore prefers the COMMITTED pydantic fixture — generated by +// go/scripts/gen_schemas.py from the very model the Python agent passes — and +// only falls back to invopop reflection for types with no fixture. +// +// DIVERGENCE (documented, go/README.md item 7) — KEY ORDER, NOT CONTENT. The +// schema CONTENT matches Python's; its key ORDER does not, and cannot from +// here. Python appends `json.dumps(model_json_schema(), indent=2)` to the +// prompt, which keeps pydantic's field declaration order. Go's path is a +// `map[string]any` end to end — this file decodes the fixture into one, and the +// SDK renders it with `json.MarshalIndent`, which SORTS map keys — so the +// prompt's schema block is alphabetised. Both `agent.Harness` and +// `harness.BuildPromptSuffix` take a `map[string]any`, so an order-preserving +// schema type would have to come from the SDK. The content is unaffected, and +// so is the byte length that decides the SDK's large-schema branch — for +// HuntResult both renderings are 4174 bytes (a 199-line reordering diff), +// which is why LoadEmbeddedSchema's UseNumber decode matters: without it +// pydantic's `"default": 0.0` would render as `0` and the two would differ in +// content too. +// +// DIVERGENCE (documented, go/README.md item 8) — SCALAR STRICTNESS. Python +// validates the parsed harness output with `schema.model_validate(data)`, i.e. +// pydantic's LAX mode, so `"iac_line": "12"` is accepted on the first attempt. +// The SDK validates with a plain `json.Unmarshal` PLUS this fixture, and both +// reject it, so the schema-retry budget burns and the agent ends as a harness +// error. `internal/afx/lax.go` closes the same gap everywhere the port owns the +// bind; closing it here needs a change in sdk/go/harness. +// +// Unlike the pr-af port this is adapted from, there is NO RegisterSchema +// registry: a fixture is resolved by the Go destination type's NAME, i.e. +// testdata/schemas/.json. That works because the port +// contract requires Go struct names to equal the pydantic class names exactly, +// and it removes a whole class of "forgot to register" bugs — adding a model +// means running gen_schemas.py and nothing else. +package harnessx + +import ( + "bytes" + "embed" + "encoding/json" + "reflect" + "sync" + + "github.com/invopop/jsonschema" +) + +// embeddedSchemas holds the committed pydantic-generated JSON schemas, one per +// destination type that flows through Run[T]. They are produced by +// go/scripts/gen_schemas.py; see that script for the enumerated `schema=` call +// sites they cover. +// +// The embed directive skips files whose names begin with "_" or "." unless the +// pattern uses the all: prefix, so every fixture basename is plain. +// +//go:embed testdata/schemas/*.json +var embeddedSchemas embed.FS + +// schemaCache memoizes the resolved schema map per concrete type T, so the +// fixture decode (or the non-trivial invopop reflection) runs once per type. +// The stored map is treated as immutable by every caller — the harness only +// marshals and reads it — so sharing it across goroutines is safe. +var schemaCache sync.Map // reflect.Type -> map[string]any + +// SchemaFor returns the JSON-schema map the Go SDK harness consumes for T: +// the committed pydantic fixture named after T when one exists, otherwise an +// invopop reflection of T. +// +// It is exported because internal/aix needs the same schema for the `.ai(...)` +// structured-output path (where it is additionally strictified for OpenAI). +func SchemaFor[T any]() map[string]any { + return schemaForType(reflect.TypeOf((*T)(nil)).Elem()) +} + +// schemaForType is SchemaFor's non-generic core, so the drift test can iterate +// over reflect.Types. +func schemaForType(t reflect.Type) map[string]any { + if cached, ok := schemaCache.Load(t); ok { + return cached.(map[string]any) + } + + var m map[string]any + if name := fixtureName(t); name != "" { + // A load failure "cannot happen" for a committed fixture (go:embed + // compiles it in and gen_schemas.py emits valid JSON), but if it ever + // did we fall through to invopop rather than panic. + if loaded, err := LoadEmbeddedSchema(name); err == nil { + m = loaded + } + } + if m == nil { + m = reflectSchema(t) + } + + schemaCache.Store(t, m) + return m +} + +// fixtureName is the fixture basename for a destination type: its bare Go type +// name, which the port contract keeps equal to the pydantic class name. +// Anonymous types (and pointers/slices, which are never Run[T] destinations) +// have no name and get the reflection fallback. +func fixtureName(t reflect.Type) string { + return t.Name() +} + +// LoadEmbeddedSchema decodes a committed schema fixture by basename (no +// extension). Exported for the fixture tests and for the schemas package's +// cross-check. +// +// The decode uses UseNumber, which is load-bearing for PROMPT TEXT. pydantic +// writes a `float` default as `0.0` (HuntResult.hunt_duration_seconds, +// VerifiedFinding.risk_score), and Python appends the schema to the prompt with +// json.dumps, keeping that spelling. A plain decode turns `0.0` into float64(0) +// and json.MarshalIndent — which is how the SDK renders this map into the +// prompt — writes it back as `0`, changing the prompt. json.Number carries the +// literal through both hops verbatim. It is also what the SDK's jsonschema +// validation sees, and json.Number marshals verbatim there too. +func LoadEmbeddedSchema(name string) (map[string]any, error) { + b, err := embeddedSchemas.ReadFile("testdata/schemas/" + name + ".json") + if err != nil { + return nil, err + } + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + var m map[string]any + if err := dec.Decode(&m); err != nil { + return nil, err + } + return m, nil +} + +// EmbeddedSchemaNames lists every committed fixture basename, sorted by +// embed.FS's own (lexical) directory order. Used by the fixture tests and by +// the schemas package's parity test. +func EmbeddedSchemaNames() []string { + entries, err := embeddedSchemas.ReadDir("testdata/schemas") + if err != nil { + return nil + } + out := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if len(name) > 5 && name[len(name)-5:] == ".json" { + out = append(out, name[:len(name)-5]) + } + } + return out +} + +// reflectSchema is the invopop fallback for types with no committed fixture. +// +// Reflector configuration (kept identical to the pr-af port so the two behave +// the same for ad-hoc types): +// - 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 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/testdata/schemas/AttackPath.json b/go/internal/harnessx/testdata/schemas/AttackPath.json new file mode 100644 index 0000000..25bd7ba --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/AttackPath.json @@ -0,0 +1,151 @@ +{ + "$defs": { + "AttackStep": { + "description": "One step in a multi-resource attack path.", + "properties": { + "action": { + "description": "What the attacker does at this step", + "title": "Action", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "permission_used": { + "description": "The specific permission or config that enables this step", + "title": "Permission Used", + "type": "string" + }, + "resource_id": { + "title": "Resource Id", + "type": "string" + }, + "resource_type": { + "title": "Resource Type", + "type": "string" + }, + "step_number": { + "title": "Step Number", + "type": "integer" + } + }, + "required": [ + "step_number", + "resource_id", + "resource_type", + "action", + "permission_used" + ], + "title": "AttackStep", + "type": "object" + }, + "BlastRadius": { + "description": "Impact assessment for a confirmed attack path.", + "properties": { + "compute_reachable": { + "items": { + "type": "string" + }, + "title": "Compute Reachable", + "type": "array" + }, + "data_stores_reachable": { + "items": { + "type": "string" + }, + "title": "Data Stores Reachable", + "type": "array" + }, + "estimated_data_volume": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Estimated Data Volume" + }, + "services_affected": { + "items": { + "type": "string" + }, + "title": "Services Affected", + "type": "array" + } + }, + "title": "BlastRadius", + "type": "object" + }, + "Severity": { + "enum": [ + "critical", + "high", + "medium", + "low", + "info" + ], + "title": "Severity", + "type": "string" + } + }, + "description": "A multi-resource attack path assembled from individual findings.", + "properties": { + "blast_radius": { + "$ref": "#/$defs/BlastRadius" + }, + "combined_severity": { + "$ref": "#/$defs/Severity", + "default": "high" + }, + "description": { + "title": "Description", + "type": "string" + }, + "entry_point": { + "description": "Public-facing resource where attack begins", + "title": "Entry Point", + "type": "string" + }, + "findings_involved": { + "description": "IDs of HUNT findings that compose this path", + "items": { + "type": "string" + }, + "title": "Findings Involved", + "type": "array" + }, + "id": { + "title": "Id", + "type": "string" + }, + "steps": { + "items": { + "$ref": "#/$defs/AttackStep" + }, + "title": "Steps", + "type": "array" + }, + "target": { + "description": "What the attacker ultimately reaches", + "title": "Target", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "title", + "description", + "entry_point", + "target" + ], + "title": "AttackPath", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/DriftReport.json b/go/internal/harnessx/testdata/schemas/DriftReport.json new file mode 100644 index 0000000..e7fb044 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/DriftReport.json @@ -0,0 +1,113 @@ +{ + "$defs": { + "ConfigDiff": { + "description": "Single attribute difference between IaC and live state.", + "properties": { + "attribute": { + "title": "Attribute", + "type": "string" + }, + "iac_value": { + "default": null, + "title": "Iac Value" + }, + "live_value": { + "default": null, + "title": "Live Value" + }, + "security_impact": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Security Impact" + } + }, + "required": [ + "attribute" + ], + "title": "ConfigDiff", + "type": "object" + }, + "DriftedResource": { + "description": "A resource that has drifted from its IaC declaration.", + "properties": { + "diffs": { + "items": { + "$ref": "#/$defs/ConfigDiff" + }, + "title": "Diffs", + "type": "array" + }, + "iac_config": { + "additionalProperties": true, + "title": "Iac Config", + "type": "object" + }, + "live_config": { + "additionalProperties": true, + "title": "Live Config", + "type": "object" + }, + "resource_id": { + "title": "Resource Id", + "type": "string" + }, + "resource_type": { + "title": "Resource Type", + "type": "string" + }, + "security_relevant": { + "default": false, + "title": "Security Relevant", + "type": "boolean" + }, + "significance": { + "default": "medium", + "description": "critical | high | medium | low", + "title": "Significance", + "type": "string" + } + }, + "required": [ + "resource_id", + "resource_type" + ], + "title": "DriftedResource", + "type": "object" + } + }, + "description": "Complete drift analysis between IaC and live cloud.", + "properties": { + "cloud_only_resources": { + "description": "Deployed but not in IaC (shadow IT)", + "items": { + "type": "string" + }, + "title": "Cloud Only Resources", + "type": "array" + }, + "drifted_resources": { + "items": { + "$ref": "#/$defs/DriftedResource" + }, + "title": "Drifted Resources", + "type": "array" + }, + "iac_only_resources": { + "description": "Declared in IaC but not deployed", + "items": { + "type": "string" + }, + "title": "Iac Only Resources", + "type": "array" + } + }, + "title": "DriftReport", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/HuntResult.json b/go/internal/harnessx/testdata/schemas/HuntResult.json new file mode 100644 index 0000000..4894feb --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/HuntResult.json @@ -0,0 +1,176 @@ +{ + "$defs": { + "AffectedResource": { + "description": "A specific resource attribute that is misconfigured.", + "properties": { + "attribute": { + "description": "The specific attribute that is misconfigured", + "title": "Attribute", + "type": "string" + }, + "current_value": { + "default": "", + "title": "Current Value", + "type": "string" + }, + "recommended_value": { + "default": "", + "title": "Recommended Value", + "type": "string" + }, + "resource_id": { + "title": "Resource Id", + "type": "string" + }, + "resource_type": { + "title": "Resource Type", + "type": "string" + } + }, + "required": [ + "resource_id", + "resource_type", + "attribute" + ], + "title": "AffectedResource", + "type": "object" + }, + "Confidence": { + "description": "Confidence level for provisional findings.", + "enum": [ + "high", + "medium", + "low" + ], + "title": "Confidence", + "type": "string" + }, + "RawFinding": { + "description": "Potential misconfiguration or policy violation from a hunter.", + "properties": { + "benchmark_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "CIS control ID, SOC2 control, etc.", + "title": "Benchmark Id" + }, + "category": { + "description": "Finding category from FindingCategory enum", + "title": "Category", + "type": "string" + }, + "confidence": { + "$ref": "#/$defs/Confidence", + "default": "medium" + }, + "config_snippet": { + "default": "", + "title": "Config Snippet", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "estimated_severity": { + "$ref": "#/$defs/Severity", + "default": "medium" + }, + "fingerprint": { + "title": "Fingerprint", + "type": "string" + }, + "hunter_strategy": { + "description": "iam | network | data | secrets | compute | logging | compliance", + "title": "Hunter Strategy", + "type": "string" + }, + "iac_file": { + "default": "", + "title": "Iac File", + "type": "string" + }, + "iac_line": { + "default": 0, + "title": "Iac Line", + "type": "integer" + }, + "id": { + "title": "Id", + "type": "string" + }, + "resources": { + "items": { + "$ref": "#/$defs/AffectedResource" + }, + "title": "Resources", + "type": "array" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "hunter_strategy", + "title", + "description", + "category" + ], + "title": "RawFinding", + "type": "object" + }, + "Severity": { + "enum": [ + "critical", + "high", + "medium", + "low", + "info" + ], + "title": "Severity", + "type": "string" + } + }, + "description": "Deduplicated and correlated HUNT phase output.", + "properties": { + "deduplicated_count": { + "default": 0, + "title": "Deduplicated Count", + "type": "integer" + }, + "findings": { + "items": { + "$ref": "#/$defs/RawFinding" + }, + "title": "Findings", + "type": "array" + }, + "hunt_duration_seconds": { + "default": 0.0, + "title": "Hunt Duration Seconds", + "type": "number" + }, + "strategies_run": { + "items": { + "type": "string" + }, + "title": "Strategies Run", + "type": "array" + }, + "total_raw": { + "default": 0, + "title": "Total Raw", + "type": "integer" + } + }, + "title": "HuntResult", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/PathInvestigationPlan.json b/go/internal/harnessx/testdata/schemas/PathInvestigationPlan.json new file mode 100644 index 0000000..d6b8877 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/PathInvestigationPlan.json @@ -0,0 +1,45 @@ +{ + "$defs": { + "ChildInvestigation": { + "properties": { + "child_prompt": { + "title": "Child Prompt", + "type": "string" + }, + "findings_involved": { + "items": { + "type": "string" + }, + "title": "Findings Involved", + "type": "array" + }, + "rationale": { + "default": "", + "title": "Rationale", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "title", + "child_prompt" + ], + "title": "ChildInvestigation", + "type": "object" + } + }, + "properties": { + "investigations": { + "items": { + "$ref": "#/$defs/ChildInvestigation" + }, + "title": "Investigations", + "type": "array" + } + }, + "title": "PathInvestigationPlan", + "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..74aef38 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/RemediationSuggestion.json @@ -0,0 +1,93 @@ +{ + "$defs": { + "IaCDiff": { + "description": "A unified diff patch for remediation.", + "properties": { + "end_line": { + "default": 0, + "title": "End Line", + "type": "integer" + }, + "file_path": { + "title": "File Path", + "type": "string" + }, + "original_lines": { + "title": "Original Lines", + "type": "string" + }, + "patched_lines": { + "title": "Patched Lines", + "type": "string" + }, + "start_line": { + "default": 0, + "title": "Start Line", + "type": "integer" + } + }, + "required": [ + "file_path", + "original_lines", + "patched_lines" + ], + "title": "IaCDiff", + "type": "object" + } + }, + "description": "Actionable IaC fix for a finding.", + "properties": { + "alternative_approaches": { + "items": { + "type": "string" + }, + "title": "Alternative Approaches", + "type": "array" + }, + "breaking_change": { + "default": false, + "title": "Breaking Change", + "type": "boolean" + }, + "description": { + "title": "Description", + "type": "string" + }, + "diffs": { + "items": { + "$ref": "#/$defs/IaCDiff" + }, + "title": "Diffs", + "type": "array" + }, + "downtime_estimate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "none | seconds | minutes | requires_maintenance_window", + "title": "Downtime Estimate" + }, + "effort": { + "default": "moderate", + "description": "trivial | moderate | significant", + "title": "Effort", + "type": "string" + }, + "finding_id": { + "default": "", + "title": "Finding Id", + "type": "string" + } + }, + "required": [ + "description" + ], + "title": "RemediationSuggestion", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/ResourceGraph.json b/go/internal/harnessx/testdata/schemas/ResourceGraph.json new file mode 100644 index 0000000..7ee3d1e --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/ResourceGraph.json @@ -0,0 +1,25 @@ +{ + "description": "Graph pointer from Resource Graph Builder harness.", + "properties": { + "graph_saved_path": { + "description": "Absolute path to the generated graph.json file", + "title": "Graph Saved Path", + "type": "string" + }, + "total_edges": { + "default": 0, + "title": "Total Edges", + "type": "integer" + }, + "total_nodes": { + "default": 0, + "title": "Total Nodes", + "type": "integer" + } + }, + "required": [ + "graph_saved_path" + ], + "title": "ResourceGraph", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/ResourceInventory.json b/go/internal/harnessx/testdata/schemas/ResourceInventory.json new file mode 100644 index 0000000..00631e3 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/ResourceInventory.json @@ -0,0 +1,38 @@ +{ + "description": "Inventory pointer from IaC reader harness.", + "properties": { + "iac_type": { + "default": "terraform", + "description": "terraform | cloudformation | kubernetes", + "title": "Iac Type", + "type": "string" + }, + "iac_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Iac Version" + }, + "inventory_saved_path": { + "description": "Absolute path to the generated inventory.json file", + "title": "Inventory Saved Path", + "type": "string" + }, + "total_resources": { + "default": 0, + "title": "Total Resources", + "type": "integer" + } + }, + "required": [ + "inventory_saved_path" + ], + "title": "ResourceInventory", + "type": "object" +} diff --git a/go/internal/harnessx/testdata/schemas/VerifiedFinding.json b/go/internal/harnessx/testdata/schemas/VerifiedFinding.json new file mode 100644 index 0000000..eb30722 --- /dev/null +++ b/go/internal/harnessx/testdata/schemas/VerifiedFinding.json @@ -0,0 +1,550 @@ +{ + "$defs": { + "AffectedResource": { + "description": "A specific resource attribute that is misconfigured.", + "properties": { + "attribute": { + "description": "The specific attribute that is misconfigured", + "title": "Attribute", + "type": "string" + }, + "current_value": { + "default": "", + "title": "Current Value", + "type": "string" + }, + "recommended_value": { + "default": "", + "title": "Recommended Value", + "type": "string" + }, + "resource_id": { + "title": "Resource Id", + "type": "string" + }, + "resource_type": { + "title": "Resource Type", + "type": "string" + } + }, + "required": [ + "resource_id", + "resource_type", + "attribute" + ], + "title": "AffectedResource", + "type": "object" + }, + "AttackPath": { + "description": "A multi-resource attack path assembled from individual findings.", + "properties": { + "blast_radius": { + "$ref": "#/$defs/BlastRadius" + }, + "combined_severity": { + "$ref": "#/$defs/Severity", + "default": "high" + }, + "description": { + "title": "Description", + "type": "string" + }, + "entry_point": { + "description": "Public-facing resource where attack begins", + "title": "Entry Point", + "type": "string" + }, + "findings_involved": { + "description": "IDs of HUNT findings that compose this path", + "items": { + "type": "string" + }, + "title": "Findings Involved", + "type": "array" + }, + "id": { + "title": "Id", + "type": "string" + }, + "steps": { + "items": { + "$ref": "#/$defs/AttackStep" + }, + "title": "Steps", + "type": "array" + }, + "target": { + "description": "What the attacker ultimately reaches", + "title": "Target", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "title", + "description", + "entry_point", + "target" + ], + "title": "AttackPath", + "type": "object" + }, + "AttackStep": { + "description": "One step in a multi-resource attack path.", + "properties": { + "action": { + "description": "What the attacker does at this step", + "title": "Action", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "permission_used": { + "description": "The specific permission or config that enables this step", + "title": "Permission Used", + "type": "string" + }, + "resource_id": { + "title": "Resource Id", + "type": "string" + }, + "resource_type": { + "title": "Resource Type", + "type": "string" + }, + "step_number": { + "title": "Step Number", + "type": "integer" + } + }, + "required": [ + "step_number", + "resource_id", + "resource_type", + "action", + "permission_used" + ], + "title": "AttackStep", + "type": "object" + }, + "BlastRadius": { + "description": "Impact assessment for a confirmed attack path.", + "properties": { + "compute_reachable": { + "items": { + "type": "string" + }, + "title": "Compute Reachable", + "type": "array" + }, + "data_stores_reachable": { + "items": { + "type": "string" + }, + "title": "Data Stores Reachable", + "type": "array" + }, + "estimated_data_volume": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Estimated Data Volume" + }, + "services_affected": { + "items": { + "type": "string" + }, + "title": "Services Affected", + "type": "array" + } + }, + "title": "BlastRadius", + "type": "object" + }, + "ConfigDiff": { + "description": "Single attribute difference between IaC and live state.", + "properties": { + "attribute": { + "title": "Attribute", + "type": "string" + }, + "iac_value": { + "default": null, + "title": "Iac Value" + }, + "live_value": { + "default": null, + "title": "Live Value" + }, + "security_impact": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Security Impact" + } + }, + "required": [ + "attribute" + ], + "title": "ConfigDiff", + "type": "object" + }, + "DriftedResource": { + "description": "A resource that has drifted from its IaC declaration.", + "properties": { + "diffs": { + "items": { + "$ref": "#/$defs/ConfigDiff" + }, + "title": "Diffs", + "type": "array" + }, + "iac_config": { + "additionalProperties": true, + "title": "Iac Config", + "type": "object" + }, + "live_config": { + "additionalProperties": true, + "title": "Live Config", + "type": "object" + }, + "resource_id": { + "title": "Resource Id", + "type": "string" + }, + "resource_type": { + "title": "Resource Type", + "type": "string" + }, + "security_relevant": { + "default": false, + "title": "Security Relevant", + "type": "boolean" + }, + "significance": { + "default": "medium", + "description": "critical | high | medium | low", + "title": "Significance", + "type": "string" + } + }, + "required": [ + "resource_id", + "resource_type" + ], + "title": "DriftedResource", + "type": "object" + }, + "IaCDiff": { + "description": "A unified diff patch for remediation.", + "properties": { + "end_line": { + "default": 0, + "title": "End Line", + "type": "integer" + }, + "file_path": { + "title": "File Path", + "type": "string" + }, + "original_lines": { + "title": "Original Lines", + "type": "string" + }, + "patched_lines": { + "title": "Patched Lines", + "type": "string" + }, + "start_line": { + "default": 0, + "title": "Start Line", + "type": "integer" + } + }, + "required": [ + "file_path", + "original_lines", + "patched_lines" + ], + "title": "IaCDiff", + "type": "object" + }, + "Proof": { + "description": "Evidence supporting the verdict.", + "properties": { + "evidence": { + "items": { + "type": "string" + }, + "title": "Evidence", + "type": "array" + }, + "method": { + "$ref": "#/$defs/ProofMethod", + "default": "static_analysis" + }, + "scripts_executed": { + "description": "Actual commands/scripts the harness ran", + "items": { + "type": "string" + }, + "title": "Scripts Executed", + "type": "array" + }, + "verification_tier": { + "default": "static", + "description": "static | live", + "title": "Verification Tier", + "type": "string" + } + }, + "title": "Proof", + "type": "object" + }, + "ProofMethod": { + "description": "Verification method used to reach the verdict.", + "enum": [ + "static_analysis", + "live_api_verification", + "iam_simulation", + "drift_comparison" + ], + "title": "ProofMethod", + "type": "string" + }, + "RemediationSuggestion": { + "description": "Actionable IaC fix for a finding.", + "properties": { + "alternative_approaches": { + "items": { + "type": "string" + }, + "title": "Alternative Approaches", + "type": "array" + }, + "breaking_change": { + "default": false, + "title": "Breaking Change", + "type": "boolean" + }, + "description": { + "title": "Description", + "type": "string" + }, + "diffs": { + "items": { + "$ref": "#/$defs/IaCDiff" + }, + "title": "Diffs", + "type": "array" + }, + "downtime_estimate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "none | seconds | minutes | requires_maintenance_window", + "title": "Downtime Estimate" + }, + "effort": { + "default": "moderate", + "description": "trivial | moderate | significant", + "title": "Effort", + "type": "string" + }, + "finding_id": { + "default": "", + "title": "Finding Id", + "type": "string" + } + }, + "required": [ + "description" + ], + "title": "RemediationSuggestion", + "type": "object" + }, + "Severity": { + "enum": [ + "critical", + "high", + "medium", + "low", + "info" + ], + "title": "Severity", + "type": "string" + }, + "Verdict": { + "description": "Exploitability verdict semantics.", + "enum": [ + "confirmed", + "likely", + "inconclusive", + "not_exploitable" + ], + "title": "Verdict", + "type": "string" + } + }, + "description": "Finding fully assessed by PROVE phase.", + "properties": { + "attack_path": { + "anyOf": [ + { + "$ref": "#/$defs/AttackPath" + }, + { + "type": "null" + } + ], + "default": null + }, + "category": { + "title": "Category", + "type": "string" + }, + "compliance_mappings": { + "description": "CIS control IDs, SOC2 controls, etc.", + "items": { + "type": "string" + }, + "title": "Compliance Mappings", + "type": "array" + }, + "config_snippet": { + "default": "", + "title": "Config Snippet", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "drift": { + "anyOf": [ + { + "$ref": "#/$defs/DriftedResource" + }, + { + "type": "null" + } + ], + "default": null + }, + "drop_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Drop Reason" + }, + "fingerprint": { + "title": "Fingerprint", + "type": "string" + }, + "hunter_strategy": { + "default": "", + "title": "Hunter Strategy", + "type": "string" + }, + "iac_file": { + "default": "", + "title": "Iac File", + "type": "string" + }, + "iac_line": { + "default": 0, + "title": "Iac Line", + "type": "integer" + }, + "id": { + "title": "Id", + "type": "string" + }, + "proof": { + "$ref": "#/$defs/Proof" + }, + "remediation": { + "anyOf": [ + { + "$ref": "#/$defs/RemediationSuggestion" + }, + { + "type": "null" + } + ], + "default": null + }, + "resources": { + "items": { + "$ref": "#/$defs/AffectedResource" + }, + "title": "Resources", + "type": "array" + }, + "risk_score": { + "default": 0.0, + "title": "Risk Score", + "type": "number" + }, + "sarif_rule_id": { + "default": "", + "title": "Sarif Rule Id", + "type": "string" + }, + "sarif_security_severity": { + "default": 0.0, + "title": "Sarif Security Severity", + "type": "number" + }, + "severity": { + "$ref": "#/$defs/Severity" + }, + "title": { + "title": "Title", + "type": "string" + }, + "verdict": { + "$ref": "#/$defs/Verdict" + } + }, + "required": [ + "title", + "verdict", + "severity", + "category" + ], + "title": "VerifiedFinding", + "type": "object" +} diff --git a/go/internal/prompts/drift_test.go b/go/internal/prompts/drift_test.go new file mode 100644 index 0000000..e346fc9 --- /dev/null +++ b/go/internal/prompts/drift_test.go @@ -0,0 +1,85 @@ +package prompts + +import ( + "bytes" + "os" + "path/filepath" + "sort" + "testing" +) + +// pythonPromptRoot is the Python source of truth, relative to THIS package +// directory (go/internal/prompts -> ../../../ is the repo root). +const pythonPromptRoot = "../../../src/cloudsecurity_af/prompts" + +// TestPromptsMatchThePythonTree is the anti-drift gate for the whole port: the +// embedded templates under files/ must be byte-identical to +// src/cloudsecurity_af/prompts/**, with the same relative layout and NO file +// present on one side only. +// +// The port contract says prompts are byte-verbatim; a whitespace-level edit on +// either side changes what the LLM sees and silently breaks parity, so this +// walks BOTH trees and compares in both directions. +// +// It is skipped when the Python tree is absent, which is the case for a +// module-only checkout (e.g. `go install`-ing the module, or a Docker build +// stage that copies only go/). +func TestPromptsMatchThePythonTree(t *testing.T) { + info, err := os.Stat(pythonPromptRoot) + if err != nil || !info.IsDir() { + t.Skipf("Python prompt tree not present at %s — module-only checkout", pythonPromptRoot) + } + + pythonFiles := map[string][]byte{} + err = filepath.Walk(pythonPromptRoot, func(p string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + if fi.IsDir() { + return nil + } + rel, err := filepath.Rel(pythonPromptRoot, p) + if err != nil { + return err + } + body, err := os.ReadFile(p) + if err != nil { + return err + } + pythonFiles[filepath.ToSlash(rel)] = body + return nil + }) + if err != nil { + t.Fatalf("walking %s: %v", pythonPromptRoot, err) + } + if len(pythonFiles) == 0 { + t.Fatalf("no prompt files found under %s", pythonPromptRoot) + } + + embedded := Names() + pythonNames := make([]string, 0, len(pythonFiles)) + for name := range pythonFiles { + pythonNames = append(pythonNames, name) + } + sort.Strings(pythonNames) + + // Direction 1: every Python template is embedded, with identical bytes. + for _, name := range pythonNames { + got, err := Load(name) + if err != nil { + t.Errorf("prompt %q exists in the Python tree but is NOT embedded", name) + continue + } + if !bytes.Equal([]byte(got), pythonFiles[name]) { + t.Errorf("prompt %q differs from the Python source (%d embedded bytes vs %d Python bytes)", + name, len(got), len(pythonFiles[name])) + } + } + + // Direction 2: nothing is embedded that the Python tree does not have. + for _, name := range embedded { + if _, ok := pythonFiles[name]; !ok { + t.Errorf("prompt %q is embedded but does NOT exist in the Python tree", name) + } + } +} diff --git a/go/internal/prompts/files/chain/path_constructor.txt b/go/internal/prompts/files/chain/path_constructor.txt new file mode 100644 index 0000000..85640be --- /dev/null +++ b/go/internal/prompts/files/chain/path_constructor.txt @@ -0,0 +1,67 @@ +ROLE: +You are the CHAIN parent harness in CloudSecurity AF. + +OBJECTIVE: +Construct attack-path investigations by clustering related findings and crafting high-signal child prompts. + +INPUTS: +- max_paths: {{MAX_PATHS}} +- max_children: {{MAX_CHILDREN}} +- findings: +{{FINDINGS_JSON}} +- resource graph: +{{RESOURCE_GRAPH_JSON}} +- drift report: +{{DRIFT_REPORT_JSON}} + +TASK: +1) Identify clusters of related misconfigurations that can realistically be chained by an attacker. +2) Use resource graph topology and drift context to separate exploitable chains from isolated findings. +3) Prioritize clusters with clear entry points, privilege escalation potential, lateral movement, and sensitive targets. +4) Produce up to max_children investigations. For each investigation, write a specific child prompt that asks a child harness to test: + - starting resource and attacker foothold, + - intermediate pivots and permissions/configurations abused, + - final target and blast radius. +5) Child prompts must reference concrete finding IDs, resource IDs, and relevant graph edges from input. +6) Child prompts must explicitly ask the child to return one AttackPath JSON object. + +GRAPH-AWARE PLANNING INSTRUCTIONS: +1) Build candidate chains by walking trust, execution, network_path, and data_access relationships. +2) Favor chains where edge directionality aligns with realistic attacker movement. +3) Penalize chains that require contradictory assumptions or unavailable access prerequisites. +4) Incorporate drift_report signals when live drift makes IaC-only paths stronger or weaker. +5) Include compensating controls in rationale so child prompts can verify or refute exploitability. + +CHILD PROMPT QUALITY RULES: +1) Each child prompt must include explicit attacker starting assumptions. +2) Each child prompt must enumerate expected pivot sequence in order. +3) Each child prompt must specify required evidence for confirming each hop. +4) Each child prompt must request concrete impacted resources and business-impact framing. +5) Each child prompt must ask for confidence and uncertainty notes when hops are conditional. + +PRIORITIZATION HEURISTICS: +1) Rank higher when chains cross trust boundaries (internet to private, low-privilege to admin, account-to-account). +2) Rank higher when path length is short and prerequisites are low complexity. +3) Rank higher when endpoints contain sensitive data, control-plane access, or broad automation privileges. +4) Rank lower when chain requires speculative assumptions unsupported by graph/finding evidence. +5) Keep portfolio diversity across identity, network, data, and compute abuse patterns. + +OUTPUT: +Return JSON matching PathInvestigationPlan: +{ + "investigations": [ + { + "title": "...", + "rationale": "...", + "findings_involved": ["finding-id-1", "finding-id-2"], + "child_prompt": "specific prompt for the child harness" + } + ] +} + +CONSTRAINTS: +- Do not invent findings or resources not present in inputs. +- Focus on realistic exploit chains, not speculative trivia. +- Prefer fewer, higher-confidence investigations. +- Keep JSON schema fidelity exact for PathInvestigationPlan. +- No markdown fences or prose outside JSON. diff --git a/go/internal/prompts/files/hunt/compliance.txt b/go/internal/prompts/files/hunt/compliance.txt new file mode 100644 index 0000000..3663267 --- /dev/null +++ b/go/internal/prompts/files/hunt/compliance.txt @@ -0,0 +1,71 @@ +ROLE: +You are a principal cloud compliance security engineer specializing in control interpretation, technical evidence mapping, and graph-aware control gap analysis for CloudSecurity AF HUNT phase. + +CONTEXT: +- Repository path: {{REPO_PATH}} +- Depth profile: {{DEPTH}} + +RESOURCE GRAPH CONTEXT: +The following resources and relationships are relevant to your domain: + +{{RESOURCE_GRAPH_SUMMARY}} + +INFRASTRUCTURE STATISTICS: +{{INVENTORY_STATS}} + +CONNECTED RELATIONSHIPS: +{{RELEVANT_EDGES}} + +TASK: +Read repository IaC and evaluate compliance control gaps using full graph coverage across IAM, network, data, compute, and logging domains. + +GRAPH-AWARE ANALYSIS INSTRUCTIONS: +1. Use the full graph scope and do not limit analysis to a single domain slice. +2. Map each control requirement to concrete nodes and edges that satisfy or violate control intent. +3. Trace transitive dependencies to detect inherited or bypassed controls across connected resources. +4. Validate that compensating controls are technically connected to the at-risk nodes. +5. Prioritize gaps where graph topology amplifies business impact across multiple control families. + +SECURITY REASONING METHODOLOGY: +You are not checking boxes against a compliance framework. You are reasoning about regulatory and security posture the way a principal compliance architect would — asking "if an auditor examined this infrastructure today, what control gaps would they find, and do those gaps represent genuine security risk or just documentation gaps?" + +For the entire infrastructure, apply this reasoning process: + +Step 1 — UNDERSTAND CONTROL INTENT: For each compliance control (CIS, SOC2, PCI-DSS), understand what the control is trying to PROTECT against, not just what it requires. A control that says "enable encryption at rest" is really asking "can an attacker who gains physical or logical access to storage media read sensitive data?" Map each control to the actual threat it mitigates. + +Step 2 — MAP CONTROLS TO RESOURCES: For each control requirement, identify which specific resources in the infrastructure graph are relevant. Then verify whether those resources have the technical configuration that satisfies the control's intent. A control is only satisfied when the technical implementation actually prevents the threat the control addresses. + +Step 3 — IDENTIFY ABSENT CONTROLS: The most important compliance findings are controls that are entirely missing — not misconfigured, but simply not present. Walk through the major control families (identity, network, data, logging, resilience) and identify where the infrastructure has NO technical implementation of a required control. + +Step 4 — ASSESS CASCADING FAILURES: Some missing controls affect multiple compliance frameworks simultaneously. A single gap (like missing audit logging) can cascade across CIS, SOC2, and PCI-DSS at the same time. Identify these high-impact gaps that create the largest compliance surface area. + +Step 5 — DISTINGUISH RISK FROM CHECKBOX: Every compliance finding should reflect a genuine security risk, not just a gap in documentation or policy. If a control is missing but the threat it addresses cannot materialize in this specific infrastructure, note it as informational rather than critical. + +Your domain spans all compliance frameworks and all resource types. You use the full graph to assess whether the infrastructure's security posture meets the intent of regulatory controls across identity, network, data protection, logging, and operational resilience. + +IMPORTANT: Compliance analysis requires examining the ENTIRE infrastructure, not just resources that look like they belong to a specific control family. A compliance gap might be a missing resource type (no CloudTrail), a missing attribute on an existing resource (no encryption), or a systemic pattern (no resource in the entire infrastructure has logging enabled). + +WORKFLOW: +1. Use Bash to identify ALL IaC files related to identity, network, data stores, compute runtime, and telemetry. +2. Use Read to extract concrete evidence for control evaluation, including policy docs and module defaults. +3. Build control-to-resource mapping tables mentally from graph nodes and edges before asserting non-compliance. +4. Validate each compliance finding with exact evidence lines and explicit control IDs. +5. Use Write to emit strict HuntResult JSON only. + +FINDING QUALITY REQUIREMENTS: +- Every finding must cite specific graph nodes and edges showing why control intent fails. +- benchmark_id must contain the most precise control identifier available. +- Explain compliance impact and security impact separately when both apply. +- Avoid generic checklist language; findings must be technically actionable. + +OUTPUT: +- Return strict JSON matching HuntResult with no markdown or wrapper text. +- Set hunter_strategy to "compliance" on every RawFinding. +- Use valid FindingCategory values only. +- Populate title, description, iac_file, iac_line, config_snippet, benchmark_id, estimated_severity, and confidence. +- Keep total_raw, deduplicated_count, strategies_run, and hunt_duration_seconds coherent. + +CONSTRAINTS: +- Base findings only on validated IaC and graph evidence. +- Do not use regex-only pattern matching as final logic. +- Do not invent placeholders beyond those defined in this template. diff --git a/go/internal/prompts/files/hunt/compute.txt b/go/internal/prompts/files/hunt/compute.txt new file mode 100644 index 0000000..c3a3791 --- /dev/null +++ b/go/internal/prompts/files/hunt/compute.txt @@ -0,0 +1,71 @@ +ROLE: +You are a senior cloud compute security engineer specializing in workload isolation, runtime hardening, and identity-to-execution attack path analysis for CloudSecurity AF HUNT phase. + +CONTEXT: +- Repository path: {{REPO_PATH}} +- Depth profile: {{DEPTH}} + +RESOURCE GRAPH CONTEXT: +The following resources and relationships are relevant to your domain: + +{{RESOURCE_GRAPH_SUMMARY}} + +INFRASTRUCTURE STATISTICS: +{{INVENTORY_STATS}} + +CONNECTED RELATIONSHIPS: +{{RELEVANT_EDGES}} + +TASK: +Read repository IaC and identify compute findings by combining runtime posture checks with graph-based privilege and reachability analysis. + +GRAPH-AWARE ANALYSIS INSTRUCTIONS: +1. Follow execution edges to map which principals can run code on which compute resources. +2. Trace trust and data_access edges from compute nodes to identify post-compromise blast radius. +3. Correlate network_path edges with compute placement to separate isolated from internet-reachable workloads. +4. Prioritize findings where weak runtime controls combine with high-privilege execution identities. +5. Use multi-hop graph paths to detect transitive privilege escalation beyond single-resource misconfigurations. + +SECURITY REASONING METHODOLOGY: +You are not checking compute resources against a hardening checklist. You are reasoning about workload security the way a principal platform security architect would — asking "if an attacker gained code execution on this workload, what could they do next, and what stops them?" + +For every compute-related resource you encounter, apply this reasoning process: + +Step 1 — ENUMERATE: Read every attribute of the resource configuration. For compute resources, configuration spans runtime settings, identity bindings, network placement, storage attachments, bootstrap scripts, container settings, and orchestration controls. Each attribute affects a different aspect of the workload's security posture. + +Step 2 — INFER DEFAULTS: For attributes that are NOT present, determine the cloud provider's default behavior. Compute resources have many security features that must be explicitly enabled — metadata service hardening, volume encryption, image scanning, audit logging, secrets encryption, and more. Every missing security attribute is a potential finding because the default is typically the less secure option. + +Step 3 — ASSESS POST-COMPROMISE IMPACT: For each compute resource, reason about what an attacker could do after gaining code execution. Trace the workload's identity (role, instance profile) through the graph to understand what data, services, and other identities it can reach. Trace its network position to understand what it can communicate with. The severity of a compute misconfiguration is proportional to what becomes reachable after compromise. + +Step 4 — EVALUATE SUPPLY CHAIN: For container-based workloads, reason about the image supply chain. Can images be tampered with? Are images scanned for vulnerabilities? Is there provenance verification? Can an attacker push a malicious image that gets deployed automatically? The integrity of the software running on compute resources is as important as the configuration of the resources themselves. + +Step 5 — ASSESS COMPLETENESS: After analyzing individual resources, step back and ask: are there systemic compute security gaps? Sometimes the finding isn't a single misconfigured instance — it's the absence of a security baseline across all compute resources (no metadata hardening anywhere, no volume encryption standard, no container scanning strategy, no audit logging on any orchestration platform). + +Your domain covers everything related to code execution, workload identity, runtime configuration, container orchestration, image management, and compute-attached storage. This includes but is not limited to: EC2 instances, Lambda functions, ECS tasks and services, EKS clusters and node groups, ECR repositories, auto-scaling groups, launch templates, batch jobs, and any resource that runs code or hosts containers. + +IMPORTANT: Do not limit your analysis to resource types you expect to find. Examine every compute resource provided in the graph context across every attribute. A single compute resource can have findings across runtime hardening, identity, network, storage, and supply chain dimensions simultaneously. + +WORKFLOW: +1. Use Bash to identify ALL compute IaC artifacts across the repository. +2. Use Read to inspect every attribute of each compute resource. Pay special attention to attributes that are ABSENT — missing security configurations are findings. +3. Build graph-based attack paths from exposed compute to privileged identities and sensitive targets. +4. Validate each candidate finding against exact IaC evidence and remove assumptions unsupported by config. +5. Use Write to emit strict HuntResult JSON only. + +FINDING QUALITY REQUIREMENTS: +- Every finding must reference specific compute nodes and connected trust/execution/network/data_access edges. +- Explain exploitability as an attack sequence, not a single static misconfiguration statement. +- Include whether impact is lateral movement, privilege escalation, data compromise, or persistence risk. +- Include benchmark_id when a CIS mapping is applicable. + +OUTPUT: +- Return strict JSON matching HuntResult with no markdown or wrapper text. +- Set hunter_strategy to "compute" on every RawFinding. +- Use valid FindingCategory values only. +- Populate title, description, iac_file, iac_line, config_snippet, benchmark_id, estimated_severity, and confidence. +- Keep total_raw, deduplicated_count, strategies_run, and hunt_duration_seconds coherent. + +CONSTRAINTS: +- Base findings only on validated IaC and graph evidence. +- Do not use regex-only pattern matching as final logic. +- Do not invent placeholders beyond those defined in this template. diff --git a/go/internal/prompts/files/hunt/data.txt b/go/internal/prompts/files/hunt/data.txt new file mode 100644 index 0000000..6467c1e --- /dev/null +++ b/go/internal/prompts/files/hunt/data.txt @@ -0,0 +1,71 @@ +ROLE: +You are a senior cloud data security engineer specializing in storage protection, key management integration, and graph-based data access risk analysis for CloudSecurity AF HUNT phase. + +CONTEXT: +- Repository path: {{REPO_PATH}} +- Depth profile: {{DEPTH}} + +RESOURCE GRAPH CONTEXT: +The following resources and relationships are relevant to your domain: + +{{RESOURCE_GRAPH_SUMMARY}} + +INFRASTRUCTURE STATISTICS: +{{INVENTORY_STATS}} + +CONNECTED RELATIONSHIPS: +{{RELEVANT_EDGES}} + +TASK: +Read repository IaC and identify data protection findings by combining storage configuration review with graph-based access path analysis. + +GRAPH-AWARE ANALYSIS INSTRUCTIONS: +1. Trace data_access edges to identify which identities or workloads can read/write sensitive data stores. +2. Correlate execution and trust edges with data_access edges to reveal transitive data exposure paths. +3. Evaluate encryption controls in context of key policy relationships and key usage dependencies. +4. Distinguish public exposure from authenticated over-broad access and cross-account data sharing risk. +5. Prioritize findings where compromise of one node enables broad downstream data impact. + +SECURITY REASONING METHODOLOGY: +You are not checking a list. You are reasoning about data security the way a principal security engineer would during a design review. Your goal is to find every way data can be exposed, lost, corrupted, or accessed by unauthorized parties — including ways that no checklist would anticipate. + +For every data-related resource you encounter, apply this reasoning process: + +Step 1 — ENUMERATE: Read every attribute of the resource configuration. For each attribute, understand what it controls and what security property it affects. + +Step 2 — INFER DEFAULTS: For attributes that are NOT present in the configuration, determine what the cloud provider's default behavior is. In cloud infrastructure, the most dangerous misconfigurations are often things that are ABSENT — security features that exist but were never enabled. If a security-relevant attribute is missing, the resource is likely using an insecure default. + +Step 3 — ASSESS SECURITY POSTURE: For each resource, reason about what an ideally-secured version of this resource would look like given its role in the infrastructure. Compare the actual configuration against that ideal. Every gap between actual and ideal is a potential finding. + +Step 4 — TRACE IMPACT: Use the resource graph to understand what happens if this resource is compromised. Follow edges to connected resources. A misconfiguration on an isolated resource is less severe than the same misconfiguration on a resource connected to sensitive data stores or privileged identities. + +Step 5 — ASSESS COMPLETENESS: After analyzing individual resources, step back and ask: are there entire categories of data protection that are missing from this infrastructure? Sometimes the finding isn't a misconfigured resource — it's the complete absence of a protection mechanism (no encryption strategy, no backup strategy, no access logging anywhere, no key rotation policy). + +Your domain covers everything related to data at rest, data in transit, data lifecycle, data access control, encryption key management, backup and recovery, and data observability. This includes but is not limited to: object stores, databases, volumes, snapshots, caches, queues, streams, search indices, data warehouses, and any resource that stores, processes, or transmits data. + +IMPORTANT: Do not limit your analysis to resource types you expect to find. Examine every resource provided in the graph context and determine whether it has data security implications. A resource that stores data — even temporarily — is in your domain. + +WORKFLOW: +1. Use Bash to locate ALL data-related IaC definitions across the repository. +2. Use Read to inspect every attribute of each data resource. Pay special attention to attributes that are ABSENT — missing security configurations are findings. +3. Build graph-based access paths showing identity/workload to data-store relationships. +4. Re-validate each finding with exact file and line evidence before severity assignment. +5. Use Write to emit strict HuntResult JSON only. + +FINDING QUALITY REQUIREMENTS: +- Each finding must reference specific data-store nodes and connected access/trust/execution edges. +- Include concrete confidentiality and integrity impact, not only control absence statements. +- Explain whether the risk is immediate exposure, conditional exposure, or resilience/retention gap. +- Include benchmark_id mapping when control alignment is clear. + +OUTPUT: +- Return strict JSON matching HuntResult with no markdown or wrapper text. +- Set hunter_strategy to "data" on every RawFinding. +- Use valid FindingCategory values only. +- Populate title, description, iac_file, iac_line, config_snippet, benchmark_id, estimated_severity, and confidence. +- Keep total_raw, deduplicated_count, strategies_run, and hunt_duration_seconds coherent. + +CONSTRAINTS: +- Base findings only on validated IaC and graph evidence. +- Avoid regex-only pattern matching as final logic. +- Do not invent placeholders beyond those defined in this template. diff --git a/go/internal/prompts/files/hunt/iam.txt b/go/internal/prompts/files/hunt/iam.txt new file mode 100644 index 0000000..f0b188e --- /dev/null +++ b/go/internal/prompts/files/hunt/iam.txt @@ -0,0 +1,71 @@ +ROLE: +You are a senior cloud security engineer specializing in AWS IAM architecture, delegated administration, and identity-based attack path analysis for CloudSecurity AF HUNT phase. + +CONTEXT: +- Repository path: {{REPO_PATH}} +- Depth profile: {{DEPTH}} + +RESOURCE GRAPH CONTEXT: +The following resources and relationships are relevant to your domain: + +{{RESOURCE_GRAPH_SUMMARY}} + +INFRASTRUCTURE STATISTICS: +{{INVENTORY_STATS}} + +CONNECTED RELATIONSHIPS: +{{RELEVANT_EDGES}} + +TASK: +Read repository IaC and hunt IAM findings using graph-aware privilege path reasoning, not isolated resource checks. + +GRAPH-AWARE ANALYSIS INSTRUCTIONS: +1. Trace trust chains through the resource graph edges to identify transitive privilege escalation. +2. Follow execution edges from compute principals to roles and downstream data/network access. +3. Use trust edge directionality explicitly; A trusts B does not imply B trusts A. +4. Correlate wildcard permissions with node config to verify whether resource-level scope or condition keys materially constrain access. +5. Prioritize findings that create shortest paths from low-trust principals to high-impact resources. + +SECURITY REASONING METHODOLOGY: +You are not auditing against a checklist of IAM anti-patterns. You are reasoning about identity and access the way a principal security engineer would during a threat model — asking "if this identity is compromised, what is the worst possible outcome, and what prevents it?" + +For every identity-related resource you encounter, apply this reasoning process: + +Step 1 — ENUMERATE PERMISSIONS: Read every policy document, trust policy, and permission attachment. For each statement, understand the effective scope: what actions are allowed, on what resources, under what conditions. Wildcards and missing conditions are the most common sources of overprivilege. + +Step 2 — INFER DEFAULTS: For attributes that are NOT present, determine the default behavior. Many IAM resources have dangerous defaults when security features are omitted (no MFA requirement, no permission boundary, no condition keys constraining access). The absence of a security constraint is itself a finding. + +Step 3 — TRACE PRIVILEGE PATHS: For each identity, trace the full chain of what it can do. Start from the identity, follow trust relationships, assume-role chains, pass-role capabilities, and compute execution paths. An identity's effective power is not just its direct permissions — it's the transitive closure of everything reachable through delegation, assumption, and execution chains. + +Step 4 — ASSESS BLAST RADIUS: For each identity, reason about the worst-case scenario if it's compromised. What data can it reach? What other identities can it pivot to? Can it modify its own permissions? Can it disable security controls or logging? The severity of an IAM finding is proportional to the blast radius of compromise. + +Step 5 — ASSESS COMPLETENESS: After analyzing individual identities, step back and ask: are there systemic IAM patterns that indicate structural weakness? Sometimes the finding isn't a single overprivileged role — it's the absence of guardrails across the entire identity architecture (no permission boundaries anywhere, no MFA enforcement, no separation of duties, all identities sharing similar broad policies). + +Your domain covers everything related to identity, authentication, authorization, delegation, and privilege management. This includes but is not limited to: IAM users, roles, policies, groups, instance profiles, access keys, trust relationships, permission boundaries, service-linked roles, and cross-account access configurations. + +IMPORTANT: Do not limit your analysis to identities that look obviously overprivileged. A role with narrowly-scoped permissions can still be dangerous if its trust policy is overly broad, or if it can pass itself to a compute service. Reason about the full attack surface of each identity. + +WORKFLOW: +1. Use Bash to inventory ALL IAM-related IaC definitions across the repository. +2. Use Read to inspect every policy document, trust policy, and identity configuration. Pay special attention to what is ABSENT — missing conditions, boundaries, and constraints are findings. +3. Build explicit candidate attack paths from graph nodes and edges before writing any finding. +4. Re-open supporting files with Read to verify exact evidence lines, principals, actions, and resources. +5. Use Write to produce strict HuntResult JSON only after all findings are evidence-backed. + +FINDING QUALITY REQUIREMENTS: +- Every finding must cite specific graph nodes and edge types involved in the risk path. +- Include concrete principal, action, and target resource semantics, not generic overprivilege language. +- Distinguish direct exploitability from conditional exploitability and explain the missing preconditions. +- Tie benchmark_id to the most relevant CIS control when available. + +OUTPUT: +- Return strict JSON matching HuntResult with no markdown or wrapper text. +- Set hunter_strategy to "iam" on every RawFinding. +- Use valid FindingCategory values only. +- Populate title, description, iac_file, iac_line, config_snippet, benchmark_id, estimated_severity, and confidence. +- Keep total_raw, deduplicated_count, strategies_run, and hunt_duration_seconds internally consistent. + +CONSTRAINTS: +- Base findings only on IaC content and graph context you actually validated. +- Do not rely on regex-only pattern detection as final logic. +- Do not invent placeholders beyond those provided in this template. diff --git a/go/internal/prompts/files/hunt/logging.txt b/go/internal/prompts/files/hunt/logging.txt new file mode 100644 index 0000000..6ac6245 --- /dev/null +++ b/go/internal/prompts/files/hunt/logging.txt @@ -0,0 +1,71 @@ +ROLE: +You are a senior cloud detection engineering specialist focused on audit coverage, telemetry integrity, and graph-based visibility gap analysis for CloudSecurity AF HUNT phase. + +CONTEXT: +- Repository path: {{REPO_PATH}} +- Depth profile: {{DEPTH}} + +RESOURCE GRAPH CONTEXT: +The following resources and relationships are relevant to your domain: + +{{RESOURCE_GRAPH_SUMMARY}} + +INFRASTRUCTURE STATISTICS: +{{INVENTORY_STATS}} + +CONNECTED RELATIONSHIPS: +{{RELEVANT_EDGES}} + +TASK: +Read repository IaC and identify logging, monitoring, and detection gaps by reasoning over graph coverage of critical resources and paths. + +GRAPH-AWARE ANALYSIS INSTRUCTIONS: +1. Map critical graph nodes to required telemetry sources and detect visibility blind spots. +2. Follow network_path and execution edges to ensure high-risk paths are observable end-to-end. +3. Validate log integrity and retention controls for evidence continuity across connected services. +4. Correlate detection controls with the resources they protect to avoid control-presence false confidence. +5. Prioritize findings where missing telemetry obscures high-impact attack paths. + +SECURITY REASONING METHODOLOGY: +You are not checking whether specific logging services are enabled. You are reasoning about observability the way an incident response lead would — asking "if an attacker compromised any resource in this infrastructure right now, would I have the telemetry to detect it, investigate it, and prove what happened?" + +For the entire infrastructure, apply this reasoning process: + +Step 1 — MAP CRITICAL RESOURCES: Identify every resource in the graph that an attacker would target or traverse. For each, ask: is there telemetry that would capture unauthorized access, modification, or exfiltration? If a resource has no associated logging, monitoring, or alerting, that's a visibility blind spot. + +Step 2 — TRACE ATTACK PATHS: For each attack path visible in the graph, trace whether every step would generate detectable telemetry. An attack that can proceed from entry point to data exfiltration without triggering any log, alarm, or detection mechanism represents a critical observability gap. + +Step 3 — INFER MISSING TELEMETRY: For resources where logging or monitoring is NOT explicitly configured, determine the cloud provider's default behavior. Many critical telemetry sources must be explicitly enabled — they are not on by default. The complete absence of logging configuration for a resource type is often the most important finding. + +Step 4 — ASSESS LOG INTEGRITY: For telemetry that IS configured, evaluate whether it can be trusted. Can logs be tampered with? Can an attacker delete evidence? Are logs stored in a location the attacker could reach? Is there validation that logs haven't been modified? + +Step 5 — ASSESS COMPLETENESS: After analyzing individual resources, step back and ask: is there a coherent observability strategy, or are there systemic gaps? Sometimes the finding isn't a single missing log source — it's the complete absence of an observability architecture (no centralized logging, no alerting pipeline, no detection rules, no log retention policy). + +Your domain covers everything related to audit trails, telemetry, monitoring, alerting, detection, and forensic readiness. You are looking for blind spots — places where an attacker could act without being observed. + +IMPORTANT: Observability findings often come from ABSENCE, not misconfiguration. A resource that has no logging configuration at all is a finding. An infrastructure with no alerting pipeline is a finding. Reason about what SHOULD exist for security operations, not just what IS configured. + +WORKFLOW: +1. Use Bash to locate ALL IaC files — observability gaps are found by identifying what SHOULD have logging but doesn't, not just by examining logging resources. +2. Use Read to inspect both logging configurations and the resources they should cover. Pay special attention to resources with NO associated telemetry. +3. Build graph-based visibility maps from critical resources to corresponding telemetry and alerting controls. +4. Validate each finding with precise IaC evidence and control-to-resource mapping. +5. Use Write to emit strict HuntResult JSON only. + +FINDING QUALITY REQUIREMENTS: +- Each finding must reference specific graph nodes whose activity is insufficiently logged or monitored. +- Include the missing or weak telemetry control and the impacted attack path visibility. +- Distinguish between coverage gap, integrity gap, and detection-response gap. +- Include benchmark_id mappings where control alignment is explicit. + +OUTPUT: +- Return strict JSON matching HuntResult with no markdown or wrapper text. +- Set hunter_strategy to "logging" on every RawFinding. +- Use valid FindingCategory values only. +- Populate title, description, iac_file, iac_line, config_snippet, benchmark_id, estimated_severity, and confidence. +- Keep total_raw, deduplicated_count, strategies_run, and hunt_duration_seconds coherent. + +CONSTRAINTS: +- Base findings only on validated IaC and graph evidence. +- Do not use regex-only pattern matching as final logic. +- Do not invent placeholders beyond those defined in this template. diff --git a/go/internal/prompts/files/hunt/network.txt b/go/internal/prompts/files/hunt/network.txt new file mode 100644 index 0000000..195d162 --- /dev/null +++ b/go/internal/prompts/files/hunt/network.txt @@ -0,0 +1,71 @@ +ROLE: +You are a senior cloud network security engineer specializing in cloud segmentation, transit routing, and graph-based exposure analysis for CloudSecurity AF HUNT phase. + +CONTEXT: +- Repository path: {{REPO_PATH}} +- Depth profile: {{DEPTH}} + +RESOURCE GRAPH CONTEXT: +The following resources and relationships are relevant to your domain: + +{{RESOURCE_GRAPH_SUMMARY}} + +INFRASTRUCTURE STATISTICS: +{{INVENTORY_STATS}} + +CONNECTED RELATIONSHIPS: +{{RELEVANT_EDGES}} + +TASK: +Read repository IaC and identify network findings by tracing real reachability paths across network graph edges. + +GRAPH-AWARE ANALYSIS INSTRUCTIONS: +1. Follow network_path edges to determine which resources are reachable from public subnets. +2. Build internet-to-workload reachability chains through IGW, route tables, load balancers, and target groups. +3. Correlate security group ingress with downstream connected nodes to measure true blast radius. +4. Evaluate edge direction, route precedence, and overlap to avoid false assumptions about isolation. +5. Prioritize findings where exposure traverses multiple trust zones or VPC boundaries. + +SECURITY REASONING METHODOLOGY: +You are not checking a list of known-bad configurations. You are reasoning about network security the way a principal security architect would during a threat model review. Your goal is to find every way an attacker could enter, traverse, or exfiltrate from this network — including paths that no checklist would anticipate. + +For every network-related resource you encounter, apply this reasoning process: + +Step 1 — ENUMERATE: Read every attribute of the resource configuration — every rule, every CIDR block, every port range, every protocol setting, every boolean flag. Network security is defined by the totality of these attributes, not a subset. + +Step 2 — INFER DEFAULTS: For attributes that are NOT present, determine the cloud provider's default behavior. Many network resources have insecure defaults (e.g., subnets may auto-assign public IPs, security groups may allow all outbound traffic, load balancers may accept unencrypted traffic). The absence of an explicit security setting is often the finding. + +Step 3 — MAP REACHABILITY: For each resource, trace the full network path from the internet to the resource and from the resource to internal targets. A security group rule is only dangerous in the context of what sits behind it. An open port on a security group attached to a database is very different from the same port on a bastion host. + +Step 4 — EVALUATE DEFENSE IN DEPTH: Security should not depend on a single control. For each network path, verify that multiple independent controls (security groups, NACLs, subnet placement, route tables) collectively enforce the intended isolation. If removing any single control would expose a sensitive resource, that's a finding. + +Step 5 — ASSESS COMPLETENESS: After analyzing individual resources, step back and ask: are there entire categories of network security that are missing? Sometimes the finding isn't a misconfigured resource — it's the complete absence of a control layer (no egress filtering anywhere, no flow logs, no network segmentation between tiers, all subnets configured identically). + +Your domain covers everything related to network topology, traffic flow, perimeter security, internal segmentation, and network-layer observability. This includes but is not limited to: VPCs, subnets, security groups, NACLs, route tables, internet gateways, NAT gateways, load balancers, VPC endpoints, peering connections, transit gateways, network interfaces, and flow logs. + +IMPORTANT: Do not limit your analysis to resources that look obviously misconfigured. Examine every network resource and every rule within it. A finding can be a single overly-broad rule in an otherwise well-configured security group, or a structural issue like all subnets being public when a tiered architecture is needed. + +WORKFLOW: +1. Use Bash to locate ALL networking IaC files across the repository. +2. Use Read to inspect every attribute of every network resource — every rule, every flag, every CIDR. Pay special attention to attributes that are ABSENT. +3. Build graph-backed reachability paths and validate each path with file-level evidence. +4. Re-check ambiguous routes or inherited module defaults before finalizing severity. +5. Use Write to emit strict HuntResult JSON only. + +FINDING QUALITY REQUIREMENTS: +- Each finding must reference concrete graph nodes and network_path edges used in the exposure path. +- Include the external source, intermediate routing/forwarding nodes, and final sensitive destination. +- Explain whether exposure is direct, transitive, or conditional based on additional controls. +- Use benchmark_id where relevant and align evidence to the cited control intent. + +OUTPUT: +- Return strict JSON matching HuntResult with no markdown or wrapper text. +- Set hunter_strategy to "network" on every RawFinding. +- Use valid FindingCategory values only. +- Populate title, description, iac_file, iac_line, config_snippet, benchmark_id, estimated_severity, and confidence. +- Keep total_raw, deduplicated_count, strategies_run, and hunt_duration_seconds coherent. + +CONSTRAINTS: +- Base findings only on IaC and graph evidence you actually verified. +- Avoid regex-only matching as final logic; reason about exploitability and topology. +- Do not invent placeholders beyond those defined in this template. diff --git a/go/internal/prompts/files/hunt/secrets.txt b/go/internal/prompts/files/hunt/secrets.txt new file mode 100644 index 0000000..7aad969 --- /dev/null +++ b/go/internal/prompts/files/hunt/secrets.txt @@ -0,0 +1,71 @@ +ROLE: +You are a senior cloud secrets security engineer specializing in credential lifecycle hardening, secret distribution paths, and graph-based compromise analysis for CloudSecurity AF HUNT phase. + +CONTEXT: +- Repository path: {{REPO_PATH}} +- Depth profile: {{DEPTH}} + +RESOURCE GRAPH CONTEXT: +The following resources and relationships are relevant to your domain: + +{{RESOURCE_GRAPH_SUMMARY}} + +INFRASTRUCTURE STATISTICS: +{{INVENTORY_STATS}} + +CONNECTED RELATIONSHIPS: +{{RELEVANT_EDGES}} + +TASK: +Read repository IaC and identify secrets findings by combining content-level exposure checks with graph-based credential propagation analysis. + +GRAPH-AWARE ANALYSIS INSTRUCTIONS: +1. Trace execution edges to identify Lambda, ECS, EKS, EC2, and CI/CD nodes that may receive credentials at runtime. +2. Correlate references and data_access edges to determine where secret material is stored and consumed. +3. Prioritize findings where a single leaked credential provides transitive access to high-impact resources. +4. Evaluate whether secret protections are inherited, explicit, or bypassable through connected graph paths. +5. Use edge directionality to avoid incorrect assumptions about secret retrieval permissions. + +SECURITY REASONING METHODOLOGY: +You are not scanning for known credential patterns. You are reasoning about secrets hygiene the way a principal security engineer would during a security architecture review — asking "if an attacker gained read access to this repository, what credentials could they extract, and what could they do with them?" + +For every resource and configuration file you encounter, apply this reasoning process: + +Step 1 — SCAN FOR SENSITIVE MATERIAL: Read every attribute, variable, output, provider block, and resource configuration. For each value, ask: could this be a credential, key, password, token, or other sensitive material? Secrets appear in unexpected places — not just dedicated secrets resources, but variable defaults, output values, user_data scripts, environment blocks, provider configurations, and module inputs. + +Step 2 — ASSESS EXPOSURE SURFACE: For every piece of sensitive material found, determine all the ways it could be accessed by an unauthorized party. This includes: direct repo access, Terraform state files, CloudWatch logs, deployment artifacts, instance metadata, and any connected system that receives the secret. + +Step 3 — TRACE CREDENTIAL POWER: For each exposed credential, trace the full scope of what it grants access to. Follow the credential to the identity it authenticates, then trace that identity's permissions through the graph. A hardcoded database password is severe if the database contains sensitive data and is publicly accessible; it's even more severe if the same credentials are reused elsewhere. + +Step 4 — EVALUATE SECRETS ARCHITECTURE: Step back from individual secrets and assess the overall secrets management posture. Are secrets managed through a proper secrets management service, or scattered across IaC files? Are there rotation mechanisms? Is there a pattern of secure secret injection (runtime retrieval from a vault) or insecure injection (build-time embedding in configs)? The absence of a secrets management strategy is itself a critical finding. + +Step 5 — ASSESS COMPLETENESS: After analyzing individual resources, ask: are there entire categories of secret exposure that this infrastructure is vulnerable to? Sometimes the most important finding isn't a specific hardcoded key — it's a systemic pattern like "all credentials are embedded in IaC with no rotation mechanism." + +Your domain covers everything related to credentials, keys, tokens, passwords, certificates, and any sensitive configuration value whose exposure would grant unauthorized access. This includes scanning all IaC files, not just resources explicitly related to secrets management. + +IMPORTANT: Secrets hide in unexpected places. Do not limit your search to resources that look like they should contain secrets. Examine EVERY file and EVERY attribute. Variable defaults, output blocks, provider blocks, and user_data scripts are common hiding places for sensitive material. + +WORKFLOW: +1. Use Bash to locate ALL IaC files across the repository — secrets can appear anywhere. +2. Use Read to inspect every file and every attribute. Pay special attention to variable defaults, outputs, provider blocks, environment blocks, and user_data scripts. +3. Build graph-backed credential exposure paths linking secret sources, execution principals, and target assets. +4. Confirm each finding with exact file/line evidence and remove false positives caused by placeholders or test fixtures. +5. Use Write to emit strict HuntResult JSON only. + +FINDING QUALITY REQUIREMENTS: +- Every finding must reference specific graph nodes and edges that explain secret exposure or misuse. +- Describe compromise impact in terms of reachable systems and privilege scope. +- Distinguish actual plaintext material from risky configuration patterns that enable future exposure. +- Include benchmark_id where control mapping is supported. + +OUTPUT: +- Return strict JSON matching HuntResult with no markdown or wrapper text. +- Set hunter_strategy to "secrets" on every RawFinding. +- Use valid FindingCategory values only. +- Populate title, description, iac_file, iac_line, config_snippet, benchmark_id, estimated_severity, and confidence. +- Keep total_raw, deduplicated_count, strategies_run, and hunt_duration_seconds coherent. + +CONSTRAINTS: +- Base findings only on validated IaC and graph evidence. +- Do not rely on regex-only pattern matching as final logic. +- Do not invent placeholders beyond those defined in this template. diff --git a/go/internal/prompts/files/prove/live_prover.txt b/go/internal/prompts/files/prove/live_prover.txt new file mode 100644 index 0000000..7794983 --- /dev/null +++ b/go/internal/prompts/files/prove/live_prover.txt @@ -0,0 +1,74 @@ +ROLE: +You are the CloudSecurity live prover. + +OBJECTIVE: +Verify a finding using both IaC inspection and live cloud checks via executable scripts. + +INPUTS: +- Repo path: {{REPO_PATH}} +- Tier: {{TIER}} +- Finding title: {{TITLE}} +- Description: {{DESCRIPTION}} +- Category: {{CATEGORY}} +- Strategy: {{HUNTER_STRATEGY}} +- Estimated severity: {{ESTIMATED_SEVERITY}} +- Confidence: {{CONFIDENCE}} +- IaC location: {{IAC_FILE}}:{{IAC_LINE}} +- Config snippet: +{{CONFIG_SNIPPET}} +- Attack path context: +{{ATTACK_PATH_JSON}} +- Full finding JSON: +{{FINDING_JSON}} + +TASK: +1) Re-check the finding statically in IaC files. +2) Write Python verification scripts using boto3 and/or google-cloud SDK as applicable. +3) Prefer dry-run or read-only enumeration commands and API calls wherever supported. +4) Execute scripts in the harness environment and capture deterministic outputs. +5) Interpret script output to confirm, weaken, or refute exploitability. +6) Identify compensating controls from live state where relevant. +7) Return VerifiedFinding with proof.method=live_api_verification. +8) Record executed script names/commands in proof.scripts_executed and key observations in proof.evidence. +9) Populate verdict, severity, risk_score, sarif_rule_id, and sarif_security_severity. + +SCRIPT PATTERN GUIDANCE: +1) Use boto3 clients for read-only describe/list/get operations, for example: + - IAM: get_role, list_attached_role_policies, simulate_principal_policy + - EC2/VPC: describe_instances, describe_security_groups, describe_route_tables + - S3/RDS/KMS: get_bucket_policy_status, get_public_access_block, describe_db_instances, describe_key + - CloudTrail/GuardDuty/CloudWatch: describe_trails, get_trail_status, list_detectors, describe_alarms +2) Use gcloud/google SDK equivalents with list/describe/get for GCP findings. +3) Add pagination handling for list APIs to avoid false negatives. +4) Include minimal error handling for AccessDenied/NotFound and continue partial verification. + +SAFETY CONSTRAINTS: +1) Never modify resources; verification is enumerate/describe only. +2) Do not run create/update/delete/put operations. +3) Use --dry-run where command patterns support it. +4) If write-capable scripts are present, explicitly disable mutation code paths. +5) Respect account/project scope from available credentials; do not attempt lateral credential discovery. + +ANALYSIS QUALITY RULES: +1) Correlate live evidence back to the IaC finding location and intended configuration. +2) Distinguish configuration drift from false-positive IaC interpretation. +3) Evaluate compensating controls before final verdict and severity. +4) Use inconclusive when access gaps prevent confidence, not confirmed by assumption. + +OUTPUT: +Return one JSON object matching VerifiedFinding. + +MANDATORY FIELDS: +- verdict +- severity +- proof.method +- proof.evidence +- proof.scripts_executed +- sarif_rule_id +- risk_score + +CONSTRAINTS: +- Run scripts only for verification, not mutation. +- Be explicit when credentials/access are missing and use inconclusive if needed. +- Keep all output fields schema-compatible and internally coherent. +- No markdown, no prose wrapper, JSON only. diff --git a/go/internal/prompts/files/prove/static_prover.txt b/go/internal/prompts/files/prove/static_prover.txt new file mode 100644 index 0000000..a5907a5 --- /dev/null +++ b/go/internal/prompts/files/prove/static_prover.txt @@ -0,0 +1,69 @@ +ROLE: +You are the CloudSecurity static prover. + +OBJECTIVE: +Re-verify a HUNT finding directly from IaC source and determine exploitability verdict with high-quality, control-aware evidence. + +INPUTS: +- Repo path: {{REPO_PATH}} +- Tier: {{TIER}} +- Finding title: {{TITLE}} +- Description: {{DESCRIPTION}} +- Category: {{CATEGORY}} +- Strategy: {{HUNTER_STRATEGY}} +- Estimated severity: {{ESTIMATED_SEVERITY}} +- Confidence: {{CONFIDENCE}} +- IaC location: {{IAC_FILE}}:{{IAC_LINE}} +- Config snippet: +{{CONFIG_SNIPPET}} +- Attack path context: +{{ATTACK_PATH_JSON}} +- Full finding JSON: +{{FINDING_JSON}} + +TASK: +1) Read IaC files at and around the finding location. +2) Verify whether the reported misconfiguration is real in current IaC. +3) Check compensating controls in surrounding IaC context (module boundaries, network restrictions, scoped trust, encryption, logging, policy constraints). +4) Verify whether higher-level policy already mitigates the issue (for example SCPs, organization-level policy constraints, centralized guardrails). +5) Determine verdict (confirmed | likely | inconclusive | not_exploitable). +6) Set final severity and risk_score based on exploitability, preconditions, and blast radius. +7) Populate proof with method=static_analysis and concrete evidence lines. +8) Populate sarif_rule_id and sarif_security_severity. + +ANALYSIS REQUIREMENTS: +1) Reconstruct the alleged attack path from finding context and IaC relationships. +2) Validate whether the vulnerable setting is active, inherited, overridden, or unused. +3) Distinguish direct exploitability from conditional exploitability requiring additional compromise steps. +4) Downgrade confidence when critical context is unresolved. +5) Avoid false confirmations caused by dead code, non-deployed examples, or commented artifacts. + +COMPENSATING CONTROL CHECKLIST: +1) Identity controls: permission boundaries, deny policies, SCP limits, principal restrictions. +2) Network controls: private subnet placement, restrictive security groups/NACLs, endpoint-only access. +3) Data controls: encryption requirements, key policy restrictions, immutable retention constraints. +4) Observability controls: robust logging and alerting that reduces stealth or persistence risk. +5) Execution controls: runtime restrictions that prevent practical abuse of a nominal misconfiguration. + +EVIDENCE QUALITY RULES: +1) Each proof.evidence entry must be traceable to concrete file and setting details. +2) Quote or summarize exact risky attributes and any mitigating attributes in adjacent context. +3) If mitigated, explicitly explain why the finding is not exploitable despite initial signal. +4) If inconclusive, state what missing context prevented a definitive verdict. + +OUTPUT: +Return one JSON object matching VerifiedFinding. + +MANDATORY FIELDS: +- verdict +- severity +- proof.method +- proof.evidence +- sarif_rule_id +- risk_score + +CONSTRAINTS: +- Do not use live cloud APIs in this mode. +- Evidence must be traceable to IaC files and settings. +- Keep all output fields schema-compatible and internally consistent. +- No markdown, no prose wrapper, JSON only. diff --git a/go/internal/prompts/files/recon/cloud_connector.txt b/go/internal/prompts/files/recon/cloud_connector.txt new file mode 100644 index 0000000..3e0b215 --- /dev/null +++ b/go/internal/prompts/files/recon/cloud_connector.txt @@ -0,0 +1,42 @@ +ROLE: +You are a live cloud inventory analyst that enumerates deployed cloud resources for security validation and drift-aware graph alignment. + +CONTEXT: +- Cloud configuration JSON: +{{CLOUD_CONFIG_JSON}} + +TASK: +Use your Read, Write, and Bash tools to enumerate live cloud resources and return a ResourceInventory JSON object representing deployed state. You may write short Python scripts and execute them via Bash to query provider APIs (for example boto3 for AWS and gcloud SDK commands/APIs for GCP). Prefer read-only enumeration and avoid destructive actions. + +ENUMERATION REQUIREMENTS: +1. Determine provider scope, account/project boundaries, and regional scope from cloud_config. +2. Enumerate identity, network, data, compute, logging, and key-management resources needed for security reasoning. +3. Capture normalized identifiers that can be correlated back to IaC-inferred resources. +4. Collect security-critical attributes (public exposure, encryption, trust config, logging, policy bindings). +5. Preserve provider-native metadata fields when they materially influence risk analysis. + +SAFETY AND EXECUTION GUIDANCE: +1. Use read-only API operations only (describe/get/list equivalents). +2. Prefer least-privilege enumeration scripts and keep temporary scripts narrowly scoped. +3. Handle partial credential access gracefully; continue enumerating accessible domains. +4. Record unknown fields as absent or null-equivalent schema-compatible structures. +5. Never modify resources, rotate keys, or apply updates during this phase. + +WORKFLOW: +1. Parse cloud_config to determine providers, regions/projects/accounts, and credentials strategy. +2. Write temporary discovery scripts for each provider and service in scope. +3. Execute scripts with Bash, capture results, normalize into schema-compatible resources/modules/provider configs. +4. Include explicit provider metadata, resource identifiers, and security-relevant config fields from live state. +5. Write final JSON output with your Write tool. + +QUALITY RULES: +- Prefer complete coverage of critical security services over broad but shallow enumeration. +- Distinguish truly missing resources from inaccessible resources due to permission boundaries. +- Keep raw identifiers stable so downstream drift correlation remains deterministic. +- Validate major counts against provider APIs to detect script truncation or pagination errors. + +OUTPUT REQUIREMENTS: +- Output must be valid JSON only. +- Must match ResourceInventory schema. +- If provider access is unavailable, return best-effort partial inventory with empty arrays where unknown. +- Do not include markdown or narrative in output. diff --git a/go/internal/prompts/files/recon/drift_detector.txt b/go/internal/prompts/files/recon/drift_detector.txt new file mode 100644 index 0000000..cce4f2a --- /dev/null +++ b/go/internal/prompts/files/recon/drift_detector.txt @@ -0,0 +1,53 @@ +ROLE: +You are a cloud drift and misconfiguration analyst focused on security-relevant deviations between IaC intent and live cloud reality. + +CONTEXT: +- IaC graph JSON: +{{IAC_GRAPH_JSON}} + +- Cloud configuration JSON: +{{CLOUD_CONFIG_JSON}} + +TASK: +Compare declared IaC topology and relationships against live cloud state, then produce a DriftReport JSON object. Use Read/Write/Bash tools to inspect provided context, write helper scripts, and execute provider queries (for example boto3/gcloud enumeration scripts) to obtain live attributes required for diffing. + +DRIFT SCOPING REQUIREMENTS: +1. Treat IaC graph as the declared baseline and live cloud as observed runtime state. +2. Compare identity, network, data protection, compute hardening, logging, and key-management attributes. +3. Detect both missing controls and control downgrades (for example encryption disabled, logging reduced, public exposure introduced). +4. Prioritize drift with exploitability or blast-radius implications. +5. Separate cosmetic drift from security-significant drift. + +ANALYSIS REQUIREMENTS: +1. Compare trust relationships and policy bindings for privilege drift. +2. Compare network paths, routing, and exposure for segmentation drift. +3. Compare encryption and key attachment states for data protection drift. +4. Compare logging and monitoring configuration for observability drift. +5. Compare runtime identities and execution relationships for compute privilege drift. +6. Detect orphaned cloud resources not represented in IaC and stale IaC resources not present live. + +SAFETY AND VALIDATION GUIDANCE: +1. Use read-only API interactions; no resource mutation under any circumstance. +2. Handle permissions gaps explicitly and classify affected comparisons as inconclusive where needed. +3. Validate high-impact drift with at least two corroborating attributes when possible. +4. Preserve enough detail in diff output for downstream proving and remediation. + +WORKFLOW: +1. Parse IaC graph nodes/edges as desired baseline. +2. Enumerate corresponding live resources and relevant security attributes from cloud providers. +3. Compute per-resource diffs for meaningful drift (IAM trust/policies, network exposure, encryption, logging, data access paths, execution permissions). +4. Mark drift significance and security relevance based on exploitability and blast radius. +5. Identify `iac_only_resources` and `cloud_only_resources`. +6. Write final DriftReport JSON using your Write tool. + +QUALITY RULES: +- Include attribute-level before/after semantics when available. +- Avoid false drift from environment-specific known variance unless it creates security risk. +- Keep identifiers consistent with IaC graph IDs for deterministic downstream linking. +- Flag drift that invalidates prior compliance assumptions. + +OUTPUT REQUIREMENTS: +- Output must be valid JSON only. +- Must match DriftReport schema exactly. +- Populate `drifted_resources`, `iac_only_resources`, and `cloud_only_resources`. +- For each drifted resource, include structured diffs with attribute-level impact notes when available. diff --git a/go/internal/prompts/files/recon/iac_reader.txt b/go/internal/prompts/files/recon/iac_reader.txt new file mode 100644 index 0000000..9561ab3 --- /dev/null +++ b/go/internal/prompts/files/recon/iac_reader.txt @@ -0,0 +1,33 @@ +ROLE: +You are a principal IaC reconnaissance analyst responsible for extracting a complete, security-relevant infrastructure inventory suitable for graph construction and downstream attack-path reasoning. + +CONTEXT: +- Repository path: {{REPO_PATH}} + +TASK: +Write a Python script using `pyhcl2` to parse the Terraform files in the repository. +Save the resulting inventory to a file named `inventory.json` in your current working directory. +Then return the absolute path to that file in your final output schema. + +IAC DETECTION REQUIREMENTS: +1. Detect primary cloud provider scope (AWS, GCP, Azure, or multi-cloud) from provider blocks and module sources. +2. Detect IaC format and framework usage: Terraform, CloudFormation, Pulumi, CDK synth output, Kubernetes manifests. +3. Record mixed-format repositories explicitly when multiple IaC systems coexist. +4. Capture version constraints and provider aliases that materially change resource resolution. + +SECURITY-RELEVANT EXTRACTION REQUIREMENTS: +1. Capture module boundaries and parent-child module relationships for graph clustering. +2. Extract variable defaults that affect security posture, including booleans like enable_encryption or public flags. +3. Resolve locals and references where feasible to preserve practical config intent. +4. Record explicit dependencies and inferred references only when evidence exists in the file content. +5. Preserve line numbers when available so downstream findings are traceable. +6. Capture resource-level identity, network, encryption, logging, and secret-management attributes. + +WORKFLOW: +1. Use Bash to list top-level directories, detect IaC roots, and enumerate candidate files. +2. Write a Python script that uses `pyhcl2` to parse the Terraform files. +3. Execute the script to generate `inventory.json`. +4. Use Write to output the final schema containing the absolute path to `inventory.json` to the required `.agentfield_output.json` file. + +OUTPUT REQUIREMENTS: +- The `inventory_saved_path` must be the absolute path to the `inventory.json` file you created. diff --git a/go/internal/prompts/files/recon/resource_graph_builder.txt b/go/internal/prompts/files/recon/resource_graph_builder.txt new file mode 100644 index 0000000..b4a293d --- /dev/null +++ b/go/internal/prompts/files/recon/resource_graph_builder.txt @@ -0,0 +1,33 @@ +ROLE: +You are a principal cloud relationship modeler responsible for constructing a security-accurate ResourceGraph from inventory data. + +CONTEXT: +- Inventory JSON Path: {{INVENTORY_PATH}} + +TASK: +Read the inventory JSON file. Write a Python script to build a ResourceGraph JSON object that preserves exploit-relevant relationships for HUNT, PROVE, and REMEDIATE phases. +Save the resulting graph to a file named `graph.json` in your current working directory. +Then return the absolute path to that file in your final output schema. + +EDGE MODEL REQUIREMENTS: +1. Build edges using explicit types: `trust`, `network_path`, `data_access`, `execution`, and `references`. +2. Ensure every edge has directional semantics aligned with real control or access flow. +3. Treat bidirectionality explicitly; A trusts B does not imply B trusts A. +4. Avoid collapsing distinct relationships into generic dependency edges. +5. Preserve multi-hop relationship data where source and destination are not directly connected in IaC text. + +NODE AND CLUSTER REQUIREMENTS: +1. Create nodes for all inventory resources with concise config summaries relevant to security decisions. +2. Build clusters by VPC, account, region, and module boundaries when evidence exists. +3. Include namespace or project-level clustering for non-AWS provider ecosystems when present. +4. Preserve stable identifiers and source file traceability from the inventory input. +5. Highlight resources with privileged or internet-facing characteristics in node summaries. + +WORKFLOW: +1. Use Read to parse the inventory JSON file. +2. Write a Python script to build the nodes, edges, and clusters. +3. Execute the script to generate `graph.json`. +4. Use Write to output the final schema containing the absolute path to `graph.json` to the required `.agentfield_output.json` file. + +OUTPUT REQUIREMENTS: +- The `graph_saved_path` must be the absolute path to the `graph.json` file you created. diff --git a/go/internal/prompts/files/remediate/fix_generator.txt b/go/internal/prompts/files/remediate/fix_generator.txt new file mode 100644 index 0000000..9e329f9 --- /dev/null +++ b/go/internal/prompts/files/remediate/fix_generator.txt @@ -0,0 +1,71 @@ +ROLE: +You are the CloudSecurity remediation generator. + +OBJECTIVE: +Produce a minimal, actionable IaC remediation patch for a verified finding. + +INPUTS: +- Repo path: {{REPO_PATH}} +- Title: {{TITLE}} +- Description: {{DESCRIPTION}} +- Verdict: {{VERDICT}} +- Severity: {{SEVERITY}} +- Category: {{CATEGORY}} +- Risk score: {{RISK_SCORE}} +- SARIF rule: {{SARIF_RULE_ID}} +- IaC location: {{IAC_FILE}}:{{IAC_LINE}} +- Config snippet: +{{CONFIG_SNIPPET}} +- Full finding JSON: +{{FINDING_JSON}} + +TASK: +1) Read the relevant IaC file(s) and full surrounding context. +2) Design the smallest safe change that remediates the issue. +3) Output concrete IaCDiff entries with original_lines and patched_lines. +4) Assess if the fix is a breaking change. +5) Estimate downtime impact. +6) Check if the fix introduces circular dependencies or invalid reference ordering. +7) Estimate blast radius of the fix across connected modules/resources. +8) Provide alternatives when there are multiple valid remediation strategies. + +REMEDIATION DESIGN REQUIREMENTS: +1) Keep patches minimal and localized to the true root-cause configuration. +2) Preserve existing naming, style, and module interface conventions. +3) Prefer secure defaults when introducing new attributes. +4) Avoid introducing hidden operational coupling unless required for security. + +PROVIDER-SPECIFIC GUIDANCE: +1) Terraform fixes must respect interpolation, variable flow, module outputs, and lifecycle semantics. +2) CloudFormation fixes must preserve intrinsic function correctness, parameter contracts, and stack update behavior. +3) For mixed IaC repos, keep remediation syntax native to each file's framework. +4) If equivalent remediations exist, prefer the one with lowest migration risk and clearest intent. + +DEPENDENCY AND BLAST RADIUS ANALYSIS: +1) Check for circular dependencies created by new references, depends_on entries, or policy attachments. +2) Evaluate whether fixes alter shared modules consumed by multiple environments. +3) Identify resources likely to be replaced versus updated in place. +4) Estimate impact on identity paths, network connectivity, data availability, and deployment pipelines. +5) Reflect major downstream effects in description and downtime_estimate. + +QUALITY RULES: +1) original_lines and patched_lines must be concrete and directly applicable. +2) Keep diffs self-contained and avoid broad refactors unrelated to remediation. +3) If uncertainty exists, provide a conservative safe patch and document trade-off in description. +4) Ensure suggested changes remain consistent with the verified finding context. + +OUTPUT: +Return one JSON object matching RemediationSuggestion. + +MANDATORY FIELDS: +- finding_id +- description +- diffs (each with file_path, original_lines, patched_lines, start_line, end_line) +- breaking_change +- downtime_estimate + +CONSTRAINTS: +- Preserve provider-specific IaC syntax and semantics. +- Avoid broad refactors; keep the patch focused. +- Do not generate changes that mutate unrelated resources. +- Do not output markdown fences or additional prose. diff --git a/go/internal/prompts/prompts.go b/go/internal/prompts/prompts.go new file mode 100644 index 0000000..328cf18 --- /dev/null +++ b/go/internal/prompts/prompts.go @@ -0,0 +1,82 @@ +// Package prompts serves the LLM prompt templates that the cloudsecurity-af +// agents interpolate and hand to the harness. +// +// The files under files/ are BYTE-IDENTICAL copies of +// src/cloudsecurity_af/prompts/**, laid out with the same relative paths, so +// "recon/iac_reader.txt" names the same template on both sides. The Python +// agents read theirs at call time: +// +// PROMPT_PATH = Path(__file__).resolve().parents[2] / "prompts" / "recon" / "iac_reader.txt" +// prompt_template = PROMPT_PATH.read_text(encoding="utf-8") +// +// The Go port embeds them into the binary instead, which is what makes the +// single static binary self-contained (the Python package has to ship the +// prompts/ tree as package data). prompts_drift_test.go re-verifies the copies +// against the Python tree whenever the checkout contains it, so the two can +// never silently diverge. +// +// The embed directive, given a bare directory name, walks it recursively and +// skips names beginning with "." or "_"; every prompt path here is plain, so +// the whole tree is embedded. +package prompts + +import ( + "embed" + "fmt" + "io/fs" + "path" + "sort" + "strings" +) + +//go:embed files +var files embed.FS + +// Load returns the prompt template at rel — a slash-separated path relative to +// the prompts root, e.g. "recon/iac_reader.txt" — or an error when no such +// template is embedded. +// +// Python parity: the Python agents call Path.read_text() at INVOCATION time, so +// a missing template surfaces as a FileNotFoundError inside the reasoner (a +// failed execution), not at import. Load's error return is the faithful +// equivalent for a caller that wants to map the failure onto a reasoner error; +// MustLoad is for package-level template constants. +func Load(rel string) (string, error) { + clean := path.Clean("/" + strings.ReplaceAll(rel, "\\", "/"))[1:] + b, err := files.ReadFile("files/" + clean) + if err != nil { + return "", fmt.Errorf("prompts: no embedded template %q", rel) + } + return string(b), nil +} + +// MustLoad is Load for call sites where a missing template is a programmer +// error rather than a runtime condition: the file set is fixed at compile time +// by go:embed, so a failure here means the constant path was mistyped and no +// amount of retrying will help. It panics. +func MustLoad(rel string) string { + s, err := Load(rel) + if err != nil { + panic(err) + } + return s +} + +// Names lists every embedded template path (slash-separated, relative to the +// prompts root) in sorted order. Used by the drift test and useful for +// diagnostics. +func Names() []string { + var out []string + _ = fs.WalkDir(files, "files", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + out = append(out, strings.TrimPrefix(p, "files/")) + return nil + }) + sort.Strings(out) + return out +} diff --git a/go/internal/prompts/prompts_test.go b/go/internal/prompts/prompts_test.go new file mode 100644 index 0000000..e014aad --- /dev/null +++ b/go/internal/prompts/prompts_test.go @@ -0,0 +1,134 @@ +package prompts + +import ( + "reflect" + "strings" + "testing" +) + +// The template set is a cross-package contract: the agent packages reference +// these paths as constants, so an accidental rename must fail loudly here +// rather than at runtime inside a reasoner. +func TestNames_IsTheFullPythonPromptTree(t *testing.T) { + want := []string{ + "chain/path_constructor.txt", + "hunt/compliance.txt", + "hunt/compute.txt", + "hunt/data.txt", + "hunt/iam.txt", + "hunt/logging.txt", + "hunt/network.txt", + "hunt/secrets.txt", + "prove/live_prover.txt", + "prove/static_prover.txt", + "recon/cloud_connector.txt", + "recon/drift_detector.txt", + "recon/iac_reader.txt", + "recon/resource_graph_builder.txt", + "remediate/fix_generator.txt", + } + if got := Names(); !reflect.DeepEqual(got, want) { + t.Fatalf("Names() = %#v, want %#v", got, want) + } +} + +func TestLoad_ReturnsNonEmptyTemplates(t *testing.T) { + for _, name := range Names() { + body, err := Load(name) + if err != nil { + t.Fatalf("Load(%q): %v", name, err) + } + if strings.TrimSpace(body) == "" { + t.Errorf("Load(%q) is blank", name) + } + } +} + +func TestLoad_MissingTemplateIsAnError(t *testing.T) { + if _, err := Load("recon/nope.txt"); err == nil { + t.Fatal("expected an error for a missing template") + } + // A traversal attempt must not escape the embedded tree. + if _, err := Load("../../../src/cloudsecurity_af/app.py"); err == nil { + t.Fatal("expected an error for a path outside the prompts root") + } +} + +func TestMustLoad_PanicsOnAMissingTemplate(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("MustLoad did not panic") + } + }() + _ = MustLoad("nope/nope.txt") +} + +func TestMustLoad_ReturnsTheSameBytesAsLoad(t *testing.T) { + want, err := Load("recon/iac_reader.txt") + if err != nil { + t.Fatalf("Load: %v", err) + } + if got := MustLoad("recon/iac_reader.txt"); got != want { + t.Fatal("MustLoad and Load disagree") + } +} + +// --------------------------------------------------------------------------- +// Ports tests/test_utils.py::TestPromptTemplatesExist and +// ::TestHuntPromptPlaceholders — WITH A CORRECTED PROMPT ROOT. +// +// Both Python classes resolve PROMPT_ROOT as +// `Path(__file__).resolve().parents[1] / "prompts"`, i.e. /prompts. That +// directory NO LONGER EXISTS — the templates were moved into the package at +// src/cloudsecurity_af/prompts (which is what every agent's PROMPT_PATH, +// `parents[2] / "prompts" / ...`, actually reads), so those Python tests fail +// on main today. The Go port checks the templates the runtime really loads. +// --------------------------------------------------------------------------- + +// Ports TestPromptTemplatesExist::test_template_exists (the EXPECTED_TEMPLATES +// list) — already covered by TestNames_IsTheFullPythonPromptTree — and +// ::test_template_not_empty. +func TestTemplatesAreNotSuspiciouslyShort(t *testing.T) { + for _, name := range Names() { + body, err := Load(name) + if err != nil { + t.Fatalf("Load(%q): %v", name, err) + } + if len(strings.TrimSpace(body)) <= 50 { + t.Errorf("template %q is suspiciously short (%d trimmed bytes)", name, len(strings.TrimSpace(body))) + } + } +} + +// Ports TestHuntPromptPlaceholders::test_hunt_prompt_has_required_placeholders. +// The hunter agents substitute each of these; a template that lost one would +// silently ship an un-substituted prompt to the LLM. +func TestHuntTemplatesCarryTheRequiredPlaceholders(t *testing.T) { + required := []string{ + "{{RESOURCE_GRAPH_SUMMARY}}", + "{{INVENTORY_STATS}}", + "{{RELEVANT_EDGES}}", + "{{REPO_PATH}}", + "{{DEPTH}}", + } + huntTemplates := []string{ + "hunt/iam.txt", + "hunt/network.txt", + "hunt/data.txt", + "hunt/secrets.txt", + "hunt/compute.txt", + "hunt/logging.txt", + "hunt/compliance.txt", + } + for _, name := range huntTemplates { + body, err := Load(name) + if err != nil { + t.Fatalf("Load(%q): %v", name, err) + } + for _, placeholder := range required { + if !strings.Contains(body, placeholder) { + t.Errorf("%s missing placeholder: %s", name, placeholder) + } + } + } +} diff --git a/go/internal/pyfmt/pyfmt.go b/go/internal/pyfmt/pyfmt.go new file mode 100644 index 0000000..537006b --- /dev/null +++ b/go/internal/pyfmt/pyfmt.go @@ -0,0 +1,417 @@ +// Package pyfmt reproduces the CPython value-rendering primitives that the +// cloudsecurity-af Python node relies on when it builds prompt text, note +// messages and JSON payloads: +// +// - Round == Python's builtin round(x, ndigits) (correct decimal rounding +// of the exact binary value, ties to even) +// - FormatFloat == Python's str(float) / repr(float) +// - Repr == Python's repr() for the value kinds these prompts interpolate +// (str, bool, None, int, float, list, dict) +// - Str == Python's str() (repr for containers, bare text for strings) +// +// Go's own strconv/fmt disagree with CPython on all four (Go's %g flips to +// scientific notation at 1e6, Go's %v renders maps unordered and with Go +// syntax, Go has no half-even decimal round). Every Go port that renders a +// number or a container INTO PROMPT TEXT must go through this package, because +// the LLM sees the difference and the golden tests pin it. +// +// Every behaviour here was verified against the repo's own interpreter: +// +// ~/.agentfield/packages/cloudsecurity-af/venv/bin/python (CPython 3.11.12) +// +// The exact one-liners and their captured output live in pyfmt_test.go next to +// the assertions they justify. +package pyfmt + +import ( + "encoding/json" + "math" + "math/big" + "sort" + "strconv" + "strings" +) + +// --------------------------------------------------------------------------- +// round() +// --------------------------------------------------------------------------- + +// ndigitsMax / ndigitsMin mirror CPython's NDIGITS_MAX / NDIGITS_MIN guards in +// Objects/floatobject.c:double_round. Beyond them the answer is decided without +// touching the decimal conversion at all: no double has a representable digit +// past 10**-323, and none survives rounding at 10**323. +const ( + ndigitsMax = 323 + ndigitsMin = -323 +) + +// Round ports Python's builtin round(x, ndigits) for float arguments. +// +// CPython (Objects/floatobject.c: double_round) converts the double to its +// EXACT decimal expansion with _Py_dg_dtoa in mode 3 (ndigits after the decimal +// point, ties-to-even), then parses that decimal string back with +// _Py_dg_strtod. The two facts that follow from "exact expansion" and that a +// naive `math.Floor(x*p+0.5)/p` gets wrong: +// +// round(2.675, 2) == 2.67 # 2.675 is really 2.67499999999999982236431606 +// round(0.125, 2) == 0.12 # 0.125 is exact; tie -> even -> 2 +// round(0.375, 2) == 0.38 # 0.375 is exact; tie -> even -> 8 +// +// This implementation reproduces both properties by carrying the value as an +// exact big.Rat (big.Rat.SetFloat64 is lossless for a finite float64), scaling +// by 10**ndigits, rounding half-to-even on the exact quotient, then converting +// back with big.Rat.Float64 — which, like strtod, returns the nearest double. +// +// Python parity: round() with no second argument returns an int; Go has no +// dynamic return type, so callers pass ndigits=0 and get the identical VALUE as +// a float64 (round(1.5) == 2 == Round(1.5, 0); round(2.5) == 2 == Round(2.5, 0)). +// +// Python parity: the sign of a zero result follows the sign of the input — +// round(-0.5, 0) is -0.0, not 0.0 — because CPython's strtod parses the "-0" +// that dtoa emitted. +// +// Python parity: a non-finite input is returned unchanged (round(inf, 2) is inf, +// round(nan, 2) is nan); CPython returns early for !isfinite(x). +func Round(x float64, ndigits int) float64 { + if math.IsNaN(x) || math.IsInf(x, 0) { + return x + } + // CPython: ndigits above NDIGITS_MAX always rounds x to itself; below + // NDIGITS_MIN it always rounds to a signed zero. + if ndigits > ndigitsMax { + return x + } + if ndigits < ndigitsMin { + return math.Copysign(0, x) + } + if x == 0 { + // Short-circuits big.Rat and preserves -0.0 (big.Rat has no signed zero). + return x + } + + exact := new(big.Rat).SetFloat64(x) // lossless for finite float64 + scale := pow10Rat(ndigits) + scaled := new(big.Rat).Mul(exact, scale) + + rounded := roundHalfEvenRat(scaled) + + out := new(big.Rat).SetInt(rounded) + out.Quo(out, scale) + f, _ := out.Float64() + + if f == 0 { + // big.Rat cannot carry -0.0; restore CPython's signed zero. + return math.Copysign(0, x) + } + return f +} + +// pow10Rat returns 10**n as an exact rational, for positive or negative n. +func pow10Rat(n int) *big.Rat { + abs := n + if abs < 0 { + abs = -abs + } + p := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(abs)), nil) + if n >= 0 { + return new(big.Rat).SetInt(p) + } + return new(big.Rat).SetFrac(big.NewInt(1), p) +} + +// roundHalfEvenRat rounds an exact rational to the nearest integer, ties to +// even — the rule _Py_dg_dtoa applies to the exact decimal expansion. +func roundHalfEvenRat(r *big.Rat) *big.Int { + num, den := r.Num(), r.Denom() // den > 0 by big.Rat invariant + q := new(big.Int) + rem := new(big.Int) + q.QuoRem(num, den, rem) // truncated toward zero; rem carries num's sign + + twiceRem := new(big.Int).Abs(rem) + twiceRem.Lsh(twiceRem, 1) + + cmp := twiceRem.Cmp(den) + // q.Bit(0) is the two's-complement low bit, so parity is correct for + // negative quotients too (-3 is odd, -4 is even). + if cmp > 0 || (cmp == 0 && q.Bit(0) == 1) { + if num.Sign() < 0 { + q.Sub(q, big.NewInt(1)) + } else { + q.Add(q, big.NewInt(1)) + } + } + return q +} + +// --------------------------------------------------------------------------- +// str(float) / repr(float) +// --------------------------------------------------------------------------- + +// FormatFloat ports Python's str(float), which since Python 3.1 is identical to +// repr(float): the SHORTEST decimal string that round-trips to the same double, +// rendered so it always reads as a float. +// +// CPython (Python/pystrtod.c: format_float_short, format_code 'r') picks fixed +// vs. scientific notation from the decimal point position `decpt` — the value is +// 0.d1d2... * 10**decpt: +// +// decpt <= -4 || decpt > 16 -> scientific +// otherwise -> fixed, with a forced ".0" when integral +// +// The 1e16 cutoff is deliberate in CPython ("we used to convert at 1e17, but +// that gives odd-looking results"), which is why: +// +// str(1e15) == '1000000000000000.0' # decpt 16 -> fixed +// str(1e16) == '1e+16' # decpt 17 -> scientific +// str(0.0001) == '0.0001' # decpt -3 -> fixed +// str(0.00001) == '1e-05' # decpt -4 -> scientific +// +// Go's strconv 'g' verb with shortest precision is NOT a substitute: it flips to +// scientific at an exponent of 6, so it renders 1234567.0 as "1.234567e+06" +// where Python renders "1234567.0". +// +// Python parity: the special values print as 'inf', '-inf', 'nan' (no sign on +// nan), and negative zero prints as '-0.0'. +func FormatFloat(f float64) string { + if math.IsNaN(f) { + return "nan" + } + if math.IsInf(f, 1) { + return "inf" + } + if math.IsInf(f, -1) { + return "-inf" + } + + sign := "" + if math.Signbit(f) { + sign = "-" + } + + digits, decpt := ShortestDigits(math.Abs(f)) + + if decpt <= -4 || decpt > 16 { + return sign + sciNotation(digits, decpt) + } + return sign + fixedNotation(digits, decpt) +} + +// ShortestDigits returns the shortest round-tripping significand digits of a +// non-negative finite float and its CPython `decpt` (the value is +// 0. * 10**decpt). It reuses strconv's shortest 'e' rendering, which +// solves the same Ryu/Grisu shortest-representation problem _Py_dg_dtoa does. +// +// It is EXPORTED because the digits are a spelling-independent primitive: +// internal/output/pydantic.go renders floats with pydantic's spelling (a +// different fixed/scientific threshold and an unpadded exponent) but needs the +// exact same significand. Two copies of a shortest-round-trip routine is +// precisely the kind of thing that drifts silently, so there is one. +func ShortestDigits(f float64) (digits string, decpt int) { + s := strconv.FormatFloat(f, 'e', -1, 64) // e.g. "1.5e+16", "0e+00" + ePos := strings.IndexByte(s, 'e') + mant := s[:ePos] + exp, err := strconv.Atoi(s[ePos+1:]) + if err != nil { // unreachable: strconv always emits a parseable exponent + exp = 0 + } + digits = strings.Replace(mant, ".", "", 1) + return digits, exp + 1 +} + +// sciNotation renders [.]e±XX, the exponent always signed and at least +// two digits wide (Python: '1e+16', '1e-05', '5e-324'). +func sciNotation(digits string, decpt int) string { + var b strings.Builder + b.WriteByte(digits[0]) + if len(digits) > 1 { + b.WriteByte('.') + b.WriteString(digits[1:]) + } + exp := decpt - 1 + b.WriteByte('e') + if exp < 0 { + b.WriteByte('-') + exp = -exp + } else { + b.WriteByte('+') + } + es := strconv.Itoa(exp) + if len(es) < 2 { + b.WriteByte('0') + } + b.WriteString(es) + return b.String() +} + +// fixedNotation renders the digits with the decimal point at decpt, always +// keeping at least one digit on each side (Python: '0.0001', '100.0', '0.0'). +func fixedNotation(digits string, decpt int) string { + switch { + case decpt <= 0: + return "0." + strings.Repeat("0", -decpt) + digits + case decpt >= len(digits): + return digits + strings.Repeat("0", decpt-len(digits)) + ".0" + default: + return digits[:decpt] + "." + digits[decpt:] + } +} + +// --------------------------------------------------------------------------- +// repr() / str() +// --------------------------------------------------------------------------- + +// KV is one entry of an insertion-ordered Python dict. +type KV struct { + K string + V any +} + +// Ordered is an insertion-ordered Python dict. Repr renders it in slice order, +// which is what CPython does for a dict literal / a JSON-decoded dict. Build +// one wherever the Python source repr()s a dict whose key order is observable +// in prompt text. +type Ordered []KV + +// Get returns the value for key and whether it was present (Python `d[k]` / +// `k in d`). First match wins, mirroring dict semantics for a well-formed +// Ordered. +func (o Ordered) Get(key string) (any, bool) { + for _, kv := range o { + if kv.K == key { + return kv.V, true + } + } + return nil, false +} + +// Repr ports Python's repr() for the value kinds the ported prompt builders and +// error messages interpolate. +// +// nil -> None +// bool -> True / False +// string -> 'single quoted' (see reprString for the quote rules) +// int kinds -> decimal +// float kinds -> FormatFloat (repr(float) == str(float)) +// Ordered / []KV -> {'k': 'v'} in slice order +// map[string]any -> {'k': 'v'} with keys SORTED (see the warning below) +// any slice/array -> ['a', 'b'] +// pointer -> the pointee's repr, or None when nil +// +// WARNING — map key order: a Python dict preserves insertion order and repr() +// shows it, but a Go map has no order at all and ranging one is deliberately +// randomized. Rendering a map[string]any would therefore be non-deterministic, +// which the port contract forbids, so Repr SORTS map keys. That is a documented +// divergence from Python whenever the Python dict's insertion order was not +// already alphabetical. Use Ordered when the order is load-bearing (i.e. when +// the rendered text reaches an LLM or a golden test). +func Repr(v any) string { + switch x := v.(type) { + case nil: + return "None" + case string: + return reprString(x) + case bool: + if x { + return "True" + } + return "False" + case Ordered: + return reprPairs(x) + case []KV: + return reprPairs(x) + case map[string]any: + keys := make([]string, 0, len(x)) + for k := range x { + keys = append(keys, k) + } + sort.Strings(keys) + pairs := make([]KV, 0, len(keys)) + for _, k := range keys { + pairs = append(pairs, KV{K: k, V: x[k]}) + } + return reprPairs(pairs) + case map[string]string: + keys := make([]string, 0, len(x)) + for k := range x { + keys = append(keys, k) + } + sort.Strings(keys) + pairs := make([]KV, 0, len(keys)) + for _, k := range keys { + pairs = append(pairs, KV{K: k, V: x[k]}) + } + return reprPairs(pairs) + case json.Number: + // pyfmt.Load and recon.ctyToValue use json.Number for a Python int that + // overflows Go's int (and for any number whose literal must survive + // verbatim). repr() of that value is the int's digits; a literal with a + // fraction or an exponent was a float in Python and reprs as one. + return jsonNumberLiteral(string(x)) + case int: + return strconv.Itoa(x) + case int8: + return strconv.FormatInt(int64(x), 10) + case int16: + return strconv.FormatInt(int64(x), 10) + case int32: + return strconv.FormatInt(int64(x), 10) + case int64: + return strconv.FormatInt(x, 10) + case uint: + return strconv.FormatUint(uint64(x), 10) + case uint8: + return strconv.FormatUint(uint64(x), 10) + case uint16: + return strconv.FormatUint(uint64(x), 10) + case uint32: + return strconv.FormatUint(uint64(x), 10) + case uint64: + return strconv.FormatUint(x, 10) + case float32: + return FormatFloat(float64(x)) + case float64: + return FormatFloat(x) + case []string: + parts := make([]string, len(x)) + for i, e := range x { + parts[i] = reprString(e) + } + return "[" + strings.Join(parts, ", ") + "]" + case []any: + parts := make([]string, len(x)) + for i, e := range x { + parts[i] = Repr(e) + } + return "[" + strings.Join(parts, ", ") + "]" + } + return reprReflect(v) +} + +// reprPairs renders {'k': v, ...} in the given order (CPython dict repr: keys +// are repr'd as strings, ": " between key and value, ", " between entries). +func reprPairs(pairs []KV) string { + if len(pairs) == 0 { + return "{}" + } + parts := make([]string, len(pairs)) + for i, kv := range pairs { + parts[i] = reprString(kv.K) + ": " + Repr(kv.V) + } + return "{" + strings.Join(parts, ", ") + "}" +} + +// Str ports Python's str(): identical to repr() for every kind EXCEPT a bare +// string, which str() renders unquoted and unescaped. This is what an f-string +// `{value}` interpolation produces. +func Str(v any) string { + if s, ok := v.(string); ok { + return s + } + if p, ok := v.(*string); ok { + if p == nil { + return "None" + } + return *p + } + return Repr(v) +} diff --git a/go/internal/pyfmt/pyfmt_test.go b/go/internal/pyfmt/pyfmt_test.go new file mode 100644 index 0000000..699006c --- /dev/null +++ b/go/internal/pyfmt/pyfmt_test.go @@ -0,0 +1,191 @@ +package pyfmt + +import ( + "math" + "strconv" + "testing" +) + +// Every expectation in this file was captured from the repo's own interpreter: +// +// ~/.agentfield/packages/cloudsecurity-af/venv/bin/python -c '' +// +// (CPython 3.11.12). The one-liner that produced each block is quoted above it, +// so the table can be regenerated and re-diffed at any time. + +// --------------------------------------------------------------------------- +// Round +// --------------------------------------------------------------------------- + +// Captured with: +// +// python -c "print([round(2.675,2), round(0.125,2), round(0.375,2), round(1.5,0), round(2.5,0), round(33.3333333,2)])" +// -> [2.67, 0.12, 0.38, 2.0, 2.0, 33.33] +// python -c "print([round(-2.675,2), round(2.5,1), round(1.005,2), round(0.5,0), round(1.25,1), round(1.35,1)])" +// -> [-2.67, 2.5, 1.0, 0.0, 1.2, 1.4] +// python -c "print([round(1234.5678,-2), round(1250.0,-2), round(1350.0,-2), round(123456789.987654321,4), round(1e16,2), round(2.675,10)])" +// -> [1200.0, 1200.0, 1400.0, 123456789.9877, 1e+16, 2.675] +func TestRound_PythonGroundTruth(t *testing.T) { + cases := []struct { + name string + x float64 + ndigits int + want float64 + }{ + // The canonical "why a naive x*100+0.5 is wrong" trio. + {"2.675 is really 2.67499999999999982", 2.675, 2, 2.67}, + {"0.125 is an exact tie -> even", 0.125, 2, 0.12}, + {"0.375 is an exact tie -> even (up)", 0.375, 2, 0.38}, + {"round(1.5) ties to even", 1.5, 0, 2}, + {"round(2.5) ties to even (down)", 2.5, 0, 2}, + {"plain truncation case", 33.3333333, 2, 33.33}, + + {"negative mirrors positive", -2.675, 2, -2.67}, + {"already at ndigits", 2.5, 1, 2.5}, + {"1.005 is really 1.00499999999999989", 1.005, 2, 1.0}, + {"round(0.5) ties down to zero", 0.5, 0, 0}, + {"1.25 ties to even (down)", 1.25, 1, 1.2}, + {"1.35 is really 1.350000000000000088", 1.35, 1, 1.4}, + + {"negative ndigits", 1234.5678, -2, 1200}, + {"negative ndigits tie to even (down)", 1250.0, -2, 1200}, + {"negative ndigits tie to even (up)", 1350.0, -2, 1400}, + {"many digits", 123456789.987654321, 4, 123456789.9877}, + {"huge magnitude is unchanged", 1e16, 2, 1e16}, + {"ndigits past the precision is a no-op", 2.675, 10, 2.675}, + + {"zero", 0.0, 2, 0.0}, + {"guard: ndigits above NDIGITS_MAX", 1.23456, 400, 1.23456}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := Round(tc.x, tc.ndigits) + if got != tc.want { + t.Fatalf("Round(%v, %d) = %v, want %v", tc.x, tc.ndigits, got, tc.want) + } + }) + } +} + +// Captured with: +// +// python -c "import math; print(repr(round(-0.5,0)), repr(round(-0.0,2)), math.copysign(1, round(-0.5,0)))" +// -> -0.0 -0.0 -1.0 +// +// The sign of a zero result follows the input, because CPython round-trips +// through a decimal string that carries the "-". +func TestRound_PreservesNegativeZero(t *testing.T) { + for _, tc := range []struct { + x float64 + ndigits int + }{{-0.5, 0}, {math.Copysign(0, -1), 2}, {-0.0001, 2}} { + got := Round(tc.x, tc.ndigits) + if got != 0 { + t.Fatalf("Round(%v, %d) = %v, want a zero", tc.x, tc.ndigits, got) + } + if !math.Signbit(got) { + t.Fatalf("Round(%v, %d) lost the negative zero", tc.x, tc.ndigits) + } + } +} + +// Captured with: +// +// python -c "print(round(float('inf'),2), round(float('-inf'),2), round(float('nan'),2))" +// -> inf -inf nan +func TestRound_NonFiniteIsReturnedUnchanged(t *testing.T) { + if got := Round(math.Inf(1), 2); !math.IsInf(got, 1) { + t.Fatalf("Round(+Inf, 2) = %v", got) + } + if got := Round(math.Inf(-1), 2); !math.IsInf(got, -1) { + t.Fatalf("Round(-Inf, 2) = %v", got) + } + if got := Round(math.NaN(), 2); !math.IsNaN(got) { + t.Fatalf("Round(NaN, 2) = %v", got) + } +} + +// --------------------------------------------------------------------------- +// FormatFloat +// --------------------------------------------------------------------------- + +// Captured with: +// +// python -c "print([str(f) for f in [0.0,1.0,100.0,0.1,1/3,1e15,1e16,1e17,1.5e16,0.0001,0.00001,1e-7,123456789.0,1234567.0,1e300,5e-324,2.675,3.14159,-2.5,1e21,9007199254740992.0,1.2345678901234567e19]])" +// -> ['0.0', '1.0', '100.0', '0.1', '0.3333333333333333', '1000000000000000.0', +// '1e+16', '1e+17', '1.5e+16', '0.0001', '1e-05', '1e-07', '123456789.0', +// '1234567.0', '1e+300', '5e-324', '2.675', '3.14159', '-2.5', '1e+21', +// '9007199254740992.0', '1.2345678901234567e+19'] +func TestFormatFloat_PythonGroundTruth(t *testing.T) { + cases := []struct { + f float64 + want string + }{ + {0.0, "0.0"}, + {1.0, "1.0"}, + {100.0, "100.0"}, + {0.1, "0.1"}, + {1.0 / 3.0, "0.3333333333333333"}, + // The 1e16 fixed/scientific cutoff CPython hard-codes. + {1e15, "1000000000000000.0"}, + {1e16, "1e+16"}, + {1e17, "1e+17"}, + {1.5e16, "1.5e+16"}, + // The -4 cutoff on the small side. + {0.0001, "0.0001"}, + {0.00001, "1e-05"}, + {1e-7, "1e-07"}, + // Go's %g would render these two as 1.23456789e+08 / 1.234567e+06. + {123456789.0, "123456789.0"}, + {1234567.0, "1234567.0"}, + {1e300, "1e+300"}, + {5e-324, "5e-324"}, + {2.675, "2.675"}, + {3.14159, "3.14159"}, + {-2.5, "-2.5"}, + {1e21, "1e+21"}, + {9007199254740992.0, "9007199254740992.0"}, + {1.2345678901234567e19, "1.2345678901234567e+19"}, + } + for _, tc := range cases { + if got := FormatFloat(tc.f); got != tc.want { + t.Errorf("FormatFloat(%v) = %q, want %q", tc.f, got, tc.want) + } + } +} + +// Captured with: +// +// python -c "print(str(-0.0), str(float('inf')), str(float('-inf')), str(float('nan')))" +// -> -0.0 inf -inf nan +func TestFormatFloat_SpecialValues(t *testing.T) { + cases := []struct { + f float64 + want string + }{ + {math.Copysign(0, -1), "-0.0"}, + {math.Inf(1), "inf"}, + {math.Inf(-1), "-inf"}, + {math.NaN(), "nan"}, + } + for _, tc := range cases { + if got := FormatFloat(tc.f); got != tc.want { + t.Errorf("FormatFloat(%v) = %q, want %q", tc.f, got, tc.want) + } + } +} + +// FormatFloat must be the inverse of Go's parser for every float it renders — +// that is the definition of "shortest round-tripping repr". +func TestFormatFloat_RoundTrips(t *testing.T) { + for _, f := range []float64{0.1, 1.0 / 3.0, 2.675, 1e16, 5e-324, 1e300, -1234.5678, 9007199254740993.0} { + s := FormatFloat(f) + back, err := strconv.ParseFloat(s, 64) + if err != nil { + t.Fatalf("re-parsing %q: %v", s, err) + } + if back != f { + t.Errorf("FormatFloat(%v) = %q did not round-trip (got %v)", f, s, back) + } + } +} diff --git a/go/internal/pyfmt/pyjson.go b/go/internal/pyfmt/pyjson.go new file mode 100644 index 0000000..52dd183 --- /dev/null +++ b/go/internal/pyfmt/pyjson.go @@ -0,0 +1,552 @@ +package pyfmt + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "math" + "reflect" + "sort" + "strconv" + "strings" +) + +// --------------------------------------------------------------------------- +// json.dumps() +// --------------------------------------------------------------------------- + +// Dumps reproduces CPython's json.dumps(v, indent=indent) applied to the dict a +// pydantic model_dump() produces (DESIGN.md §2b). +// +// This file is shared VERBATIM with the sec-af Go port +// (sec-af/go/internal/pyfmt/pyjson.go). The only permitted deltas are the two +// marked below: +// +// 1. this call-site list, which names cloudsecurity-af's own json.dumps sites; +// 2. `p.K` / `p.V` in pairs(), because this repo's pyfmt.KV spells its fields +// K and V where sec-af's spells them Key and Value. Everything else — +// encoder, escaping, float rendering, struct walk — is byte-identical. +// +// CloudSecurity AF embeds json.dumps output directly in prompt text, in +// checkpoint files and in output artifacts: +// +// src/cloudsecurity_af/output/sarif.py:59 json.dumps(sarif, indent=2) +// src/cloudsecurity_af/output/json_output.py:17,79 json.dumps(..., indent=2) +// src/cloudsecurity_af/orchestrator.py:223 json.dumps(body, indent=2) +// src/cloudsecurity_af/agents/recon/_terraform_parser.py:277 json.dump(inventory, f, indent=2, default=str) +// src/cloudsecurity_af/agents/chain/path_constructor.py:108 json.dumps(compact_findings, indent=2) +// src/cloudsecurity_af/agents/prove/static_prover.py:47 json.dumps(finding.model_dump(), indent=2) +// +// so the bytes matter. Go's encoding/json differs from CPython's json module in +// four ways that all show up in that text: +// +// 1. FLOATS. Go renders a float64 with the shortest round-tripping 'g'-ish +// form ("1", "1e+15"); Python renders repr(float) ("1.0", +// "1000000000000000.0"). Every float here goes through FormatFloat. +// 2. NON-ASCII. Python defaults to ensure_ascii=True, so an accented letter +// becomes a \uXXXX escape and an astral character becomes a UTF-16 +// surrogate pair. Go emits raw UTF-8. +// 3. HTML CHARACTERS. Go escapes the three characters < > & as \u003c, +// \u003e and \u0026 by default (SetEscapeHTML). Python escapes none of +// them. +// 4. SEPARATORS. Python's compact form uses ", " and ": " (json.dumps' +// default separators when indent is None); Go's Marshal uses "," and ":". +// +// Indent semantics: indent > 0 renders the multi-line form with that many +// spaces per level and the (",", ": ") separators Python switches to whenever +// indent is not None. indent <= 0 renders the single-line form with Python's +// default (", ", ": ") separators — i.e. Dumps(v, 0) == DumpsCompact(v). That +// is a deliberate simplification: Python's literal indent=0 emits newlines with +// zero-width indentation, a spelling no CloudSecurity AF call site uses (every +// call is either json.dumps(x) or json.dumps(x, indent=2)). +// +// Value mapping: +// +// nil, nil pointer/interface/map/slice -> null +// bool -> true / false +// int/uint kinds -> decimal integer +// float32/float64 -> FormatFloat (NaN/±Inf -> +// NaN/Infinity/-Infinity, exactly +// what Python emits with the default +// allow_nan=True) +// json.Number -> verbatim when integral, else +// FormatFloat of its value +// string -> ensure_ascii-escaped JSON string +// []byte -> base64 string (encoding/json +// parity; pydantic has no bytes field) +// slice/array -> array +// Ordered / KV -> object in INSERTION order +// map -> object with SORTED keys +// struct -> object, fields in DECLARATION +// order, json tags honored +// json.Marshaler -> its MarshalJSON output, re-rendered +// through this same encoder +// +// Two deliberate deviations, both documented in DESIGN.md §2b: +// +// - MAP KEY ORDER. A Python dict preserves insertion order and json.dumps +// renders it in that order; a Go map carries no order at all. Dumps sorts +// map keys so the output is deterministic and diffable. A call site whose +// bytes must match Python exactly — e.g. a Terraform resource's `config` +// dict, whose insertion order is the order the attributes appear in the +// .tf file — must build a pyfmt.Ordered instead of a map. Structs need no +// such care: Go declaration order is pydantic declaration order, which is +// model_dump()'s insertion order. +// - NIL SLICES AND MAPS render as null, matching encoding/json. Pydantic +// never produces None for a list field, and the port's schema structs seed +// `[]` defaults in their UnmarshalJSON, so a nil only appears where a Go +// caller left a struct at its zero value — in which case null is the +// honest rendering of "this was never populated". +func Dumps(v any, indent int) string { + e := &jsonEncoder{} + if indent > 0 { + e.indent = strings.Repeat(" ", indent) + } + e.value(reflect.ValueOf(v), 0) + return e.b.String() +} + +// DumpsCompact reproduces json.dumps(v) with no indent — Python's default +// separators, ", " between items and ": " between key and value. +func DumpsCompact(v any) string { return Dumps(v, 0) } + +// --------------------------------------------------------------------------- +// encoder +// --------------------------------------------------------------------------- + +type jsonEncoder struct { + b strings.Builder + indent string // "" means compact +} + +var ( + marshalerType = reflect.TypeOf((*json.Marshaler)(nil)).Elem() + numberType = reflect.TypeOf(json.Number("")) + orderedType = reflect.TypeOf(Ordered(nil)) + kvType = reflect.TypeOf(KV{}) +) + +// newline writes the line break plus one level of indentation, or nothing at +// all in compact mode. +func (e *jsonEncoder) newline(depth int) { + if e.indent == "" { + return + } + e.b.WriteByte('\n') + for i := 0; i < depth; i++ { + e.b.WriteString(e.indent) + } +} + +// itemSep writes the separator BETWEEN two items: ",\n" in indent mode +// (Python's separators default to (",", ": ") whenever indent is not None) and +// ", " in compact mode (Python's default separators when indent is None). +func (e *jsonEncoder) itemSep(depth int) { + e.b.WriteByte(',') + if e.indent == "" { + e.b.WriteByte(' ') + return + } + e.newline(depth) +} + +func (e *jsonEncoder) value(rv reflect.Value, depth int) { + // Unwrap interfaces and pointers, checking for a json.Marshaler at every + // level so that both `T` and `*T` marshalers are honored (encoding/json + // does the same). A nil pointer or interface is null and never reaches its + // MarshalJSON. + for { + if !rv.IsValid() { + e.b.WriteString("null") + return + } + if k := rv.Kind(); k == reflect.Interface || k == reflect.Pointer { + if rv.IsNil() { + e.b.WriteString("null") + return + } + } + if e.tryMarshaler(rv, depth) { + return + } + if k := rv.Kind(); k == reflect.Interface || k == reflect.Pointer { + rv = rv.Elem() + continue + } + break + } + + switch rv.Kind() { + case reflect.Bool: + if rv.Bool() { + e.b.WriteString("true") + } else { + e.b.WriteString("false") + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + e.b.WriteString(strconv.FormatInt(rv.Int(), 10)) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + e.b.WriteString(strconv.FormatUint(rv.Uint(), 10)) + case reflect.Float32, reflect.Float64: + e.b.WriteString(jsonFloat(rv.Float())) + case reflect.String: + if rv.Type() == numberType { + e.b.WriteString(jsonNumberLiteral(rv.String())) + return + } + e.writeString(rv.String()) + case reflect.Slice, reflect.Array: + e.sequence(rv, depth) + case reflect.Map: + e.mapping(rv, depth) + case reflect.Struct: + e.structure(rv, depth) + default: + // Channels, funcs and complex numbers have no JSON (or Python) form. + // encoding/json errors; a prompt builder must not, so emit null. + e.b.WriteString("null") + } +} + +// tryMarshaler renders rv through its MarshalJSON when it has one. The produced +// bytes are decoded and re-rendered through this encoder rather than pasted in +// verbatim, so a marshaler's own formatting choices (Go's HTML escaping, raw +// UTF-8, "1e+15" floats, ":"-without-space separators) are normalized to +// Python's spelling and the surrounding indentation stays consistent. +func (e *jsonEncoder) tryMarshaler(rv reflect.Value, depth int) bool { + var m json.Marshaler + switch { + case rv.Type().Implements(marshalerType) && rv.CanInterface(): + m, _ = rv.Interface().(json.Marshaler) + case rv.CanAddr() && reflect.PointerTo(rv.Type()).Implements(marshalerType) && rv.Addr().CanInterface(): + m, _ = rv.Addr().Interface().(json.Marshaler) + } + if m == nil { + return false + } + raw, err := m.MarshalJSON() + if err != nil { + // Python has no analogue; a broken marshaler yields null rather than + // aborting the prompt build. + e.b.WriteString("null") + return true + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() // keep 1 an int and 1.0 a float, exactly as Python would + var decoded any + if err := dec.Decode(&decoded); err != nil { + e.b.Write(raw) + return true + } + e.value(reflect.ValueOf(decoded), depth) + return true +} + +func (e *jsonEncoder) sequence(rv reflect.Value, depth int) { + // []byte is base64 in encoding/json. No pydantic model in CloudSecurity AF + // has a bytes field, so this branch exists only so a stray []byte cannot + // render as a list of integers. + if rv.Kind() == reflect.Slice && rv.Type().Elem().Kind() == reflect.Uint8 { + if rv.IsNil() { + e.b.WriteString("null") + return + } + e.writeString(base64.StdEncoding.EncodeToString(rv.Bytes())) + return + } + if rv.Type() == orderedType { + e.pairs(rv.Interface().(Ordered), depth) + return + } + if rv.Kind() == reflect.Slice && rv.IsNil() { + e.b.WriteString("null") + return + } + n := rv.Len() + if n == 0 { + // Python renders an empty list as "[]" with no inner newline, and so + // does this. + e.b.WriteString("[]") + return + } + e.b.WriteByte('[') + e.newline(depth + 1) + for i := 0; i < n; i++ { + if i > 0 { + e.itemSep(depth + 1) + } + e.value(rv.Index(i), depth+1) + } + e.newline(depth) + e.b.WriteByte(']') +} + +func (e *jsonEncoder) mapping(rv reflect.Value, depth int) { + if rv.IsNil() { + e.b.WriteString("null") + return + } + keys := rv.MapKeys() + if len(keys) == 0 { + e.b.WriteString("{}") + return + } + type entry struct { + key string + val reflect.Value + } + entries := make([]entry, 0, len(keys)) + for _, k := range keys { + entries = append(entries, entry{key: JSONMapKey(k), val: rv.MapIndex(k)}) + } + // Deterministic stand-in for Python's insertion order (see Dumps' doc). + sort.Slice(entries, func(i, j int) bool { return entries[i].key < entries[j].key }) + + e.b.WriteByte('{') + e.newline(depth + 1) + for i, en := range entries { + if i > 0 { + e.itemSep(depth + 1) + } + e.writeString(en.key) + e.b.WriteString(": ") + e.value(en.val, depth+1) + } + e.newline(depth) + e.b.WriteByte('}') +} + +// pairs renders an Ordered — the insertion-ordered stand-in for a Python dict. +func (e *jsonEncoder) pairs(o Ordered, depth int) { + if len(o) == 0 { + e.b.WriteString("{}") + return + } + e.b.WriteByte('{') + e.newline(depth + 1) + for i, p := range o { + if i > 0 { + e.itemSep(depth + 1) + } + e.writeString(p.K) + e.b.WriteString(": ") + e.value(reflect.ValueOf(p.V), depth+1) + } + e.newline(depth) + e.b.WriteByte('}') +} + +// structField is one emitted struct field: its JSON name and its value. +type structField struct { + name string + val reflect.Value +} + +func (e *jsonEncoder) structure(rv reflect.Value, depth int) { + if rv.Type() == kvType { + e.pairs(Ordered{rv.Interface().(KV)}, depth) + return + } + fields := collectStructFields(rv) + if len(fields) == 0 { + e.b.WriteString("{}") + return + } + e.b.WriteByte('{') + e.newline(depth + 1) + for i, f := range fields { + if i > 0 { + e.itemSep(depth + 1) + } + e.writeString(f.name) + e.b.WriteString(": ") + e.value(f.val, depth+1) + } + e.newline(depth) + e.b.WriteByte('}') +} + +// collectStructFields walks rv's exported fields in DECLARATION order — which +// is pydantic's field order, and therefore model_dump()'s insertion order — +// honoring the json tag name, `json:"-"`, `omitempty`, and encoding/json's +// flattening of an anonymous struct field that carries no json tag. +// +// One documented gap, shared with afx.ToMap: an embedded field whose TYPE is +// unexported is skipped rather than flattened. encoding/json promotes its +// exported fields, but reflect refuses to read through an unexported field +// (CanInterface is false all the way down), and no struct in the port has one. +func collectStructFields(rv reflect.Value) []structField { + rt := rv.Type() + out := make([]structField, 0, rt.NumField()) + for i := 0; i < rt.NumField(); i++ { + sf := rt.Field(i) + if !sf.IsExported() { + continue + } + tag := sf.Tag.Get("json") + name, opts, _ := strings.Cut(tag, ",") + if name == "-" && opts == "" { + continue + } + fv := rv.Field(i) + if name == "" && sf.Anonymous { + inner := fv + for inner.Kind() == reflect.Pointer && !inner.IsNil() { + inner = inner.Elem() + } + if inner.Kind() == reflect.Struct && !inner.Type().Implements(marshalerType) { + out = append(out, collectStructFields(inner)...) + continue + } + } + if name == "" { + name = sf.Name + } + if strings.Contains(","+opts+",", ",omitempty,") && IsEmptyValue(fv) { + continue + } + out = append(out, structField{name: name, val: fv}) + } + return out +} + +// IsEmptyValue is encoding/json's omitempty predicate, exported so the pydantic +// serializer in internal/output can share it instead of keeping a second copy. +func IsEmptyValue(v reflect.Value) bool { + switch v.Kind() { + case reflect.Array, reflect.Map, reflect.Slice, reflect.String: + return v.Len() == 0 + case reflect.Bool: + return !v.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return v.Uint() == 0 + case reflect.Float32, reflect.Float64: + return v.Float() == 0 + case reflect.Interface, reflect.Pointer: + return v.IsNil() + } + return false +} + +// JSONMapKey renders a map key the way Python's json.dumps coerces a non-string +// dict key: str for strings, decimal for ints, float repr for floats, and the +// LOWERCASE "true"/"false"/"null" spellings for bool/None (json.dumps({True: 1}) +// == '{"true": 1}'). encoding/json accepts the same key kinds. +// +// Exported so internal/output's pydantic serializer shares it — the coercion is +// encoding/json's, identical on both sides, and was duplicated verbatim. +func JSONMapKey(k reflect.Value) string { + for k.Kind() == reflect.Interface || k.Kind() == reflect.Pointer { + if k.IsNil() { + return "null" + } + k = k.Elem() + } + switch k.Kind() { + case reflect.String: + return k.String() + case reflect.Bool: + if k.Bool() { + return "true" + } + return "false" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return strconv.FormatInt(k.Int(), 10) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return strconv.FormatUint(k.Uint(), 10) + case reflect.Float32, reflect.Float64: + return FormatFloat(k.Float()) + } + return "" +} + +// jsonFloat renders a float the way Python's json encoder does: repr(f) for +// finite values, and the bare NaN/Infinity/-Infinity tokens for the rest +// (json.dumps' default allow_nan=True emits exactly those, which are NOT legal +// JSON — Python's own json.loads reads them back). +func jsonFloat(f float64) string { + switch { + case math.IsNaN(f): + return "NaN" + case math.IsInf(f, 1): + return "Infinity" + case math.IsInf(f, -1): + return "-Infinity" + } + return FormatFloat(f) +} + +// jsonNumberLiteral renders a json.Number the way Python would render the value +// json.loads produced from it: an integral literal stays an arbitrary-precision +// int and is emitted verbatim, anything with a fraction or an exponent became a +// float and is re-rendered as repr(float) ("1E2" -> "100.0", "0.00001" -> +// "1e-05"). +func jsonNumberLiteral(s string) string { + if s == "" { + return "null" + } + if !strings.ContainsAny(s, ".eE") { + return s + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return s + } + return jsonFloat(f) +} + +// --------------------------------------------------------------------------- +// ensure_ascii string escaping +// --------------------------------------------------------------------------- + +// writeString renders s as a JSON string using CPython's +// py_encode_basestring_ascii (Modules/_json.c ascii_escape_unicode): +// +// - a character is emitted literally iff it is printable ASCII, i.e. +// 0x20 <= c <= 0x7e and c is neither '"' nor '\\' (so DEL 0x7f IS escaped, +// and '<', '>', '&', '/' are NOT — Go's encoder escapes the first three); +// - '\\', '"', '\b', '\f', '\n', '\r' and '\t' take their short escapes; +// - everything else becomes \uXXXX with LOWERCASE hex, and a character +// outside the BMP becomes a UTF-16 surrogate pair \uD8xx\uDCxx. +func (e *jsonEncoder) writeString(s string) { + e.b.WriteByte('"') + for _, r := range s { + switch r { + case '"': + e.b.WriteString(`\"`) + continue + case '\\': + e.b.WriteString(`\\`) + continue + case '\b': + e.b.WriteString(`\b`) + continue + case '\f': + e.b.WriteString(`\f`) + continue + case '\n': + e.b.WriteString(`\n`) + continue + case '\r': + e.b.WriteString(`\r`) + continue + case '\t': + e.b.WriteString(`\t`) + continue + } + if r >= 0x20 && r <= 0x7e { + e.b.WriteByte(byte(r)) + continue + } + if r >= 0x10000 { + v := r - 0x10000 + writeHex(&e.b, `\u`, 0xd800|((v>>10)&0x3ff), 4) + writeHex(&e.b, `\u`, 0xdc00|(v&0x3ff), 4) + continue + } + writeHex(&e.b, `\u`, r, 4) + } + e.b.WriteByte('"') +} diff --git a/go/internal/pyfmt/pyjson_hex.go b/go/internal/pyfmt/pyjson_hex.go new file mode 100644 index 0000000..bf73ad9 --- /dev/null +++ b/go/internal/pyfmt/pyjson_hex.go @@ -0,0 +1,24 @@ +package pyfmt + +import "strings" + +// This file exists only to supply the one helper pyjson.go needs that this +// repo's pyfmt.go does not already export. +// +// pyjson.go is shared verbatim with the sec-af Go port, where `writeHex` lives +// in pyfmt.go alongside `hexDigits`. cloudsecurity-af's pyfmt.go grew a +// repr-shaped pair instead — `writeHexEscape` (which picks the \x / \u / \U +// width the way repr() does) and `writeHexDigits` (which takes no prefix) — so +// the fixed-width, explicit-prefix spelling json escaping needs is added here +// rather than by editing the shared file or the foundation one. + +// writeHex appends prefix followed by exactly width lowercase hex digits of r, +// most significant first. json's \uXXXX escape is always four digits wide, and +// a code point above the BMP is emitted as two of them (a UTF-16 surrogate +// pair), which is why the width is a parameter rather than derived from r. +func writeHex(b *strings.Builder, prefix string, r rune, width int) { + b.WriteString(prefix) + for shift := (width - 1) * 4; shift >= 0; shift -= 4 { + b.WriteByte(hexDigits[(r>>uint(shift))&0xf]) + } +} diff --git a/go/internal/pyfmt/pyjson_models_test.go b/go/internal/pyfmt/pyjson_models_test.go new file mode 100644 index 0000000..28f1724 --- /dev/null +++ b/go/internal/pyfmt/pyjson_models_test.go @@ -0,0 +1,188 @@ +package pyfmt_test + +// This is an EXTERNAL test package (pyfmt_test, not pyfmt) on purpose: it needs +// internal/schemas, and keeping the dependency out of package pyfmt guarantees +// pyfmt stays a leaf that schemas could import later without an import cycle. +// +// Every golden here is produced by go/scripts/gen_golden_output.py from the REAL +// pydantic model: +// +// dumped = Model(**fixture[name]).model_dump() +// json.dumps(dumped, indent=2) -> testdata/golden/dumps__indent2.txt +// json.dumps(dumped) -> testdata/golden/dumps__compact.txt +// +// The Go side decodes the SAME fixture sub-object into the identically named Go +// struct and renders it with pyfmt.Dumps. A byte difference means the Go port +// would put different text in a prompt, a checkpoint file or an output artifact +// than the Python node does. +// +// Regenerate with: +// +// PYTHONPATH=src ~/.agentfield/packages/cloudsecurity-af/venv/bin/python go/scripts/gen_golden_output.py + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +func loadModelsFixture(t *testing.T) map[string]json.RawMessage { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", "models_fixture.json")) + if err != nil { + t.Fatalf("read models_fixture.json: %v", err) + } + var fixture map[string]json.RawMessage + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatalf("parse models_fixture.json: %v", err) + } + return fixture +} + +func loadGolden(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_output.py)", name, err) + } + return string(raw) +} + +// decodeInto unmarshals raw into a fresh T and returns it as an any, so the +// table below can hold heterogeneous model types. +func decodeInto[T any](t *testing.T, raw json.RawMessage) any { + t.Helper() + var v T + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("unmarshal into %T: %v", v, err) + } + return v +} + +// TestDumpsMatchesPythonModelDump is the core parity assertion for pyfmt.Dumps: +// for four real CloudSecurity pydantic models built from one fixture, the Go +// rendering of the Go struct equals CPython's json.dumps of the model_dump() +// dict, in both the indent=2 and the compact spelling. +// +// The chosen models between them cover every shape the port has to render: +// nested lists of models (AttackPath.steps, VerifiedFinding.resources), a nested +// sub-model (Proof, BlastRadius), nullable models and scalars left at None +// (attack_path, drift, remediation, drop_reason, estimated_data_volume), empty +// lists, a str-Enum field (verdict, severity, method, combined_severity), a +// dict[str, float] (ScanMetrics.cost_breakdown), a bool, and the awkward float +// spellings (1e-05, 1e+16, 1000000000000000.0, -0.0, 10.0) plus unicode and +// control characters that ensure_ascii must escape. +func TestDumpsMatchesPythonModelDump(t *testing.T) { + fixture := loadModelsFixture(t) + + cases := []struct { + name string + decode func(*testing.T, json.RawMessage) any + }{ + {"VerifiedFinding", decodeInto[schemas.VerifiedFinding]}, + {"AttackPath", decodeInto[schemas.AttackPath]}, + {"ChainResult", decodeInto[schemas.ChainResult]}, + {"ScanMetrics", decodeInto[schemas.ScanMetrics]}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + raw, ok := fixture[tc.name] + if !ok { + t.Fatalf("models_fixture.json has no %q entry", tc.name) + } + value := tc.decode(t, raw) + + if got, want := pyfmt.Dumps(value, 2), loadGolden(t, "dumps_"+tc.name+"_indent2.txt"); got != want { + t.Errorf("Dumps(%s, 2) mismatch:\n%s", tc.name, diffLines(want, got)) + } + if got, want := pyfmt.DumpsCompact(value), loadGolden(t, "dumps_"+tc.name+"_compact.txt"); got != want { + t.Errorf("DumpsCompact(%s) mismatch:\n got %s\nwant %s", tc.name, got, want) + } + }) + } +} + +// TestDumpsMatchesPythonPlainDocument runs the same comparison over a plain +// JSON document rather than a model. It is decoded with UseNumber so integers +// stay integers, which is what Python's json.loads does. +// +// The golden is generated with sort_keys=True, because Dumps sorts Go map keys +// (its one documented ordering deviation) — the comparison is therefore about +// VALUE rendering, and the ordering deviation itself is pinned separately by +// TestDumpsMapKeysAreSorted. +func TestDumpsMatchesPythonPlainDocument(t *testing.T) { + fixture := loadModelsFixture(t) + + dec := json.NewDecoder(bytes.NewReader(fixture["edge_cases"])) + dec.UseNumber() + var doc any + if err := dec.Decode(&doc); err != nil { + t.Fatalf("decode edge_cases: %v", err) + } + + if got, want := pyfmt.Dumps(doc, 2), loadGolden(t, "dumps_edge_cases_indent2.txt"); got != want { + t.Errorf("Dumps(edge_cases, 2) mismatch:\n%s", diffLines(want, got)) + } + if got, want := pyfmt.DumpsCompact(doc), loadGolden(t, "dumps_edge_cases_compact.txt"); got != want { + t.Errorf("DumpsCompact(edge_cases) mismatch:\n got %s\nwant %s", got, want) + } +} + +// diffLines renders the first differing line of two multi-line strings, which +// makes a 200-line model dump diagnosable without printing both in full. +func diffLines(want, got string) string { + wantLines := splitLines(want) + gotLines := splitLines(got) + n := len(wantLines) + if len(gotLines) < n { + n = len(gotLines) + } + for i := 0; i < n; i++ { + if wantLines[i] != gotLines[i] { + return "first difference at line " + itoa(i+1) + + "\n want: " + quote(wantLines[i]) + + "\n got: " + quote(gotLines[i]) + } + } + 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 { + var 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 [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} + +func quote(s string) string { + b, _ := json.Marshal(s) + return string(b) +} diff --git a/go/internal/pyfmt/pyjson_test.go b/go/internal/pyfmt/pyjson_test.go new file mode 100644 index 0000000..743fade --- /dev/null +++ b/go/internal/pyfmt/pyjson_test.go @@ -0,0 +1,306 @@ +package pyfmt + +import ( + "encoding/json" + "math" + "strings" + "testing" +) + +// Every expectation in this file is CPython ground truth from +// ~/.agentfield/packages/cloudsecurity-af/venv/bin/python (CPython 3.11.12); the +// generator expression is quoted next to each table. The MODEL-level parity +// tests — the ones that run json.dumps over a real pydantic model_dump() — live +// in pyjson_models_test.go and compare against committed goldens. + +// TestDumpsScalars pins the leaf renderings. +// +// python -c 'import json; print(json.dumps(v))' +func TestDumpsScalars(t *testing.T) { + cases := []struct { + name string + in any + want string + }{ + {"nil", nil, "null"}, + {"true", true, "true"}, + {"false", false, "false"}, + {"int", 42, "42"}, + {"negative int", -7, "-7"}, + {"int64", int64(1234567890123456789), "1234567890123456789"}, + {"uint", uint(9), "9"}, + + // json.dumps uses repr(float): every float keeps a decimal point or an + // exponent, which Go's %v/strconv 'g' does not guarantee. + {"float integral", 1.0, "1.0"}, + {"float half", 0.5, "0.5"}, + {"float 1e-5", 1e-05, "1e-05"}, + {"float 1e-4", 1e-04, "0.0001"}, + {"float 1e15", 1e15, "1000000000000000.0"}, + {"float 1e16", 1e16, "1e+16"}, + {"float negative zero", math.Copysign(0, -1), "-0.0"}, + {"float pi", 3.141592653589793, "3.141592653589793"}, + {"float32", float32(0.5), "0.5"}, + + // allow_nan=True is json.dumps' default, and it emits these three bare + // tokens rather than raising. + {"NaN", math.NaN(), "NaN"}, + {"+Inf", math.Inf(1), "Infinity"}, + {"-Inf", math.Inf(-1), "-Infinity"}, + + {"string", "hello", `"hello"`}, + {"empty string", "", `""`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := DumpsCompact(tc.in); got != tc.want { + t.Fatalf("DumpsCompact(%#v) = %q, want %q", tc.in, got, tc.want) + } + if got := Dumps(tc.in, 2); got != tc.want { + t.Fatalf("Dumps(%#v, 2) = %q, want %q (scalars ignore indent)", tc.in, got, tc.want) + } + }) + } +} + +// TestDumpsStringEscaping pins py_encode_basestring_ascii. +// +// python -c 'import json; print(json.dumps(s))' +func TestDumpsStringEscaping(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"quote", "a\"b", `"a\"b"`}, + {"backslash", `a\b`, `"a\\b"`}, + {"short escapes", "\b\f\n\r\t", `"\b\f\n\r\t"`}, + {"other control", "\x00\x01\x1f", `"\u0000\u0001\u001f"`}, + {"DEL is escaped", "\x7f", `"\u007f"`}, + // Python escapes NONE of these; Go's encoding/json escapes the first + // three as \u003c, \u003e and \u0026. + {"html chars are literal", " /", `" /"`}, + // 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{{K: "a", V: 1}, {K: "b", V: []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{{K: "a", V: []any{}}, {K: "b", V: 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{{K: "zebra", V: 1}, {K: "Apple", V: 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. It has no state: +// MarshalJSON returns a constant, which is the point — the test asserts that +// the encoder finds and re-renders a POINTER receiver's marshaler. +type ptrMarshaler struct{} + +func (p *ptrMarshaler) MarshalJSON() ([]byte, error) { return []byte(`{"n": 1}`), nil } + +func TestDumpsHonorsJSONMarshaler(t *testing.T) { + value := Ordered{ + {K: "ts", V: marshalerStamp{text: "2026-01-02T03:04:05.123456+00:00"}}, + {K: "raw", V: 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 CloudSecurity 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/pyjson_valuemodel_test.go b/go/internal/pyfmt/pyjson_valuemodel_test.go new file mode 100644 index 0000000..405714f --- /dev/null +++ b/go/internal/pyfmt/pyjson_valuemodel_test.go @@ -0,0 +1,141 @@ +package pyfmt + +import "testing" + +// This file is the Python-ground-truth table internal/agents/recon carried for +// its own package-local json.dumps copy before Dumps landed. The copy was +// deleted at integration time and the table moved here verbatim, so the +// coverage it provided over the RECON value model +// +// nil | bool | string | int | float64 | []any | Ordered +// +// survives against the canonical encoder. +// +// ONE BEHAVIOUR WAS DELIBERATELY DROPPED with the copy: recon's writer had a +// `default=str` fallback that rendered an unknown Go type as a JSON string, +// matching `json.dump(..., default=str)`. Dumps instead walks an unknown struct +// into a JSON object. It is unreachable here — ctyToValue maps every Terraform +// value into the model above before it is written, and orderCloudConfig only +// ever holds JSON-decoded values — so no output byte changes; see +// internal/agents/recon/doc.go. + +func TestDumps_ReconValueModelGroundTruth(t *testing.T) { + cases := []struct { + name string + value any + indent int + want string + }{ + {"empty object", Ordered{}, 2, "{}"}, + {"empty array", []any{}, 2, "[]"}, + {"one key", Ordered{{K: "a", V: 1}}, 2, "{\n \"a\": 1\n}"}, + { + "nested containers", + Ordered{ + {K: "a", V: []any{1, 2}}, + {K: "b", V: Ordered{{K: "c", V: nil}}}, + }, + 2, + "{\n \"a\": [\n 1,\n 2\n ],\n \"b\": {\n \"c\": null\n }\n}", + }, + { + // The whole reason Ordered is used instead of a Go map: + // insertion order survives, it is NOT sorted. + "insertion order is preserved", + Ordered{{K: "z", V: 1}, {K: "a", V: 2}}, + 2, + "{\n \"z\": 1,\n \"a\": 2\n}", + }, + { + "string escapes", + Ordered{{K: "s", V: "a\"b\\c\td\ne"}}, + 2, + "{\n \"s\": \"a\\\"b\\\\c\\td\\ne\"\n}", + }, + { + // ensure_ascii=True, including a surrogate pair above the BMP. + "non-ascii is escaped", + Ordered{{K: "u", V: "café ☃ 😀"}}, + 2, + "{\n \"u\": \"caf\\u00e9 \\u2603 \\ud83d\\ude00\"\n}", + }, + { + // Go's encoding/json would emit \u003c \u003e \u0026 here. + "html characters are not escaped", + Ordered{{K: "html", V: `&`}}, + 2, + "{\n \"html\": \"&\"\n}", + }, + { + "floats use python repr", + Ordered{ + {K: "f", V: 1.0}, + {K: "g", V: 1.5}, + {K: "h", V: reconNegZero()}, + {K: "i", V: 1e16}, + {K: "j", V: 1e-5}, + }, + 2, + "{\n \"f\": 1.0,\n \"g\": 1.5,\n \"h\": -0.0,\n \"i\": 1e+16,\n \"j\": 1e-05\n}", + }, + { + "ints stay ints", + Ordered{{K: "i", V: 2}, {K: "neg", V: -3}}, + 2, + "{\n \"i\": 2,\n \"neg\": -3\n}", + }, + { + "literals", + Ordered{{K: "t", V: true}, {K: "f", V: false}, {K: "n", V: nil}}, + 2, + "{\n \"t\": true,\n \"f\": false,\n \"n\": null\n}", + }, + { + "deep nesting indents cumulatively", + Ordered{{K: "nested", V: []any{ + Ordered{{K: "a", V: 1}}, + Ordered{{K: "b", V: []any{2, []any{3}}}}, + }}}, + 2, + "{\n \"nested\": [\n {\n \"a\": 1\n },\n {\n \"b\": [\n 2,\n [\n 3\n ]\n ]\n }\n ]\n}", + }, + { + // indent<=0 selects json.dumps(x) with ", " / ": " separators. + "compact form", + Ordered{{K: "a", V: 1}, {K: "b", V: []any{1, 2}}}, + 0, + `{"a": 1, "b": [1, 2]}`, + }, + { + "control characters", + Ordered{{K: "del", V: "\x7f"}, {K: "ctl", V: "\x00\x1f"}}, + 2, + "{\n \"del\": \"\\u007f\",\n \"ctl\": \"\\u0000\\u001f\"\n}", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := Dumps(tc.value, tc.indent); got != tc.want { + t.Errorf("Dumps mismatch\n got: %q\nwant: %q", got, tc.want) + } + }) + } +} + +func reconNegZero() float64 { + z := 0.0 + return -z +} + +// pyDumps must render a Go map deterministically even though Python would have +// used insertion order — the documented divergence. +func TestDumps_ReconValueModelPlainMapKeysAreSorted(t *testing.T) { + m := map[string]any{"z": 1, "a": 2, "m": 3} + want := "{\n \"a\": 2,\n \"m\": 3,\n \"z\": 1\n}" + for i := 0; i < 20; i++ { + if got := Dumps(m, 2); got != want { + t.Fatalf("iteration %d: got %q want %q", i, got, want) + } + } +} diff --git a/go/internal/pyfmt/pyload.go b/go/internal/pyfmt/pyload.go new file mode 100644 index 0000000..281fac6 --- /dev/null +++ b/go/internal/pyfmt/pyload.go @@ -0,0 +1,165 @@ +package pyfmt + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strconv" + "strings" +) + +// --------------------------------------------------------------------------- +// json.load() / json.loads() +// --------------------------------------------------------------------------- + +// Load is the DECODING half of the json.dumps parity layer: it reproduces +// CPython's `json.load(f)` / `json.loads(s)` for the value model Dumps writes. +// +// JSON object -> Ordered, in DOCUMENT order (never sorted) +// JSON array -> []any +// JSON number -> int when the LITERAL has no "." and no exponent, else float64 +// JSON string -> string +// true/false -> bool +// null -> nil +// +// so the model is exactly +// +// nil | bool | string | int | float64 | []any | Ordered +// +// and Dumps(Load(b), 2) is the identity for any document CPython wrote with +// `json.dumps(x, indent=2)`. +// +// WHY THIS EXISTS AT ALL — encoding/json's `map[string]any` decode cannot be +// used anywhere the decoded bytes are re-emitted, because a Go map has no order +// and every renderer in this port therefore SORTS its keys (see the map-key +// deviation documented on Dumps). Python dicts keep the file's order across a +// read-modify-write, and that order is observable in three places in this port: +// +// - _graph_builder_fast reads inventory.json and copies a filtered view of +// each resource's `config` into the graph node's `config_summary`, whose +// order reaches graph.json (internal/agents/recon); +// - build_graph_context_for_hunter interpolates `config_summary` with an +// f-string, i.e. a CPython dict repr, straight into the hunter prompt +// (internal/agents/util); +// - _build_parent_prompt reads graph.json and re-emits a filtered view of it +// into the CHAIN parent prompt via json.dumps (internal/agents/chain). +// +// CONSOLIDATION NOTE: this function was written three times independently, once +// per consumer package (recon/pyjson.go, util/pyvalue.go, chain/pyload.go), +// while Dumps was still being landed and pyfmt was owned by a different agent. +// The three copies were byte-identical apart from their doc comments; they were +// folded into this one at integration time and their tests merged below. +// +// The int/float split is observable and is not an implementation detail: +// f"{2}" is "2" while f"{2.0}" is "2.0", and Dumps renders int 2 as `2` and +// float64 2.0 as `2.0`. +func Load(data []byte) (any, error) { + dec := json.NewDecoder(strings.NewReader(string(data))) + dec.UseNumber() + v, err := loadValue(dec) + if err != nil { + return nil, err + } + // json.load rejects trailing content with "Extra data"; so does this. + if _, err := dec.Token(); !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("extra data after top-level value") + } + return v, nil +} + +func loadValue(dec *json.Decoder) (any, error) { + tok, err := dec.Token() + if err != nil { + return nil, err + } + return loadFromToken(dec, tok) +} + +func loadFromToken(dec *json.Decoder, tok json.Token) (any, error) { + switch t := tok.(type) { + case json.Delim: + switch t { + case '{': + obj := Ordered{} + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return nil, err + } + key, ok := keyTok.(string) + if !ok { + return nil, fmt.Errorf("object key is not a string") + } + val, err := loadValue(dec) + if err != nil { + return nil, err + } + // Python parity: a repeated key overwrites in place, keeping + // the FIRST occurrence's position (dict assignment semantics). + replaced := false + for i := range obj { + if obj[i].K == key { + obj[i].V = val + replaced = true + break + } + } + if !replaced { + obj = append(obj, KV{K: key, V: val}) + } + } + if _, err := dec.Token(); err != nil { // consume '}' + return nil, err + } + return obj, nil + case '[': + arr := []any{} + for dec.More() { + val, err := loadValue(dec) + if err != nil { + return nil, err + } + arr = append(arr, val) + } + if _, err := dec.Token(); err != nil { // consume ']' + return nil, err + } + return arr, nil + } + return nil, fmt.Errorf("unexpected delimiter %q", t) + case json.Number: + return loadNumber(string(t)), nil + case string, bool, nil: + return t, nil + } + return nil, fmt.Errorf("unexpected token %T", tok) +} + +// loadNumber maps a JSON number literal onto Python's int/float split. +// +// Python ints are arbitrary precision and Go's int is not, so an integer +// literal that overflows `int` is returned as a json.Number holding the literal +// rather than degraded to a float64. That is not a theoretical case: the port's +// OWN writer produces such a literal — a Terraform `big_port = +// 12345678901234567890` is carried through recon.ctyToValue as a json.Number +// and written verbatim into inventory.json, which graph builder reads back +// through here on its way into graph.json's `config_summary` and the hunter +// prompt. Repr, KeyOf, pyTruthy and pyTypeName all treat an integral +// json.Number as the Python int it stands for, so the value behaves like one +// everywhere the loaded model is consumed. +func loadNumber(lit string) any { + if !strings.ContainsAny(lit, ".eE") { + if n, err := strconv.Atoi(lit); err == nil { + return n + } + // A JSON integer token is `-?\d+`, so the only way Atoi fails here is + // an overflow of Go's int — exactly the case Python keeps exact. + return json.Number(lit) + } + f, err := strconv.ParseFloat(lit, 64) + if err != nil { + return lit + } + return f +} diff --git a/go/internal/pyfmt/pyload_test.go b/go/internal/pyfmt/pyload_test.go new file mode 100644 index 0000000..fd16b5f --- /dev/null +++ b/go/internal/pyfmt/pyload_test.go @@ -0,0 +1,149 @@ +package pyfmt + +import ( + "reflect" + "testing" +) + +// Validation contract for Load (derived from what CPython's json.load does, not +// from the Go implementation). Every `want` in this file was captured from the +// real interpreter: +// +// ~/.agentfield/packages/cloudsecurity-af/venv/bin/python -c \ +// 'import json; print(repr(json.loads()))' +// +// These cases are the union of the three package-local decoders that were +// folded into Load at integration time (internal/agents/{recon,util,chain}). + +// Object key order is the whole point: config_summary's order reaches the +// hunter prompt through a dict repr and reaches graph.json through json.dumps. +// A map[string]any decode would sort it away. +func TestLoad_PreservesObjectKeyOrder(t *testing.T) { + v, err := Load([]byte(`{"zebra": 1, "apple": 2, "middle": 3}`)) + if err != nil { + t.Fatalf("Load: %v", err) + } + obj, ok := v.(Ordered) + if !ok { + t.Fatalf("expected an Ordered object, got %T", v) + } + got := make([]string, 0, len(obj)) + for _, kv := range obj { + got = append(got, kv.K) + } + if want := []string{"zebra", "apple", "middle"}; !reflect.DeepEqual(got, want) { + t.Errorf("key order = %v, want %v", got, want) + } + if s := Str(obj); s != "{'zebra': 1, 'apple': 2, 'middle': 3}" { + t.Errorf("repr = %s", s) + } +} + +// json.load gives back a Python int for an integral LITERAL and a float +// otherwise, and the two render differently: str(2) is "2", str(2.0) is "2.0". +func TestLoad_IntFloatSplitFollowsTheLiteral(t *testing.T) { + v, err := Load([]byte(`{"i": 7, "f": 7.0, "e": 7e0, "neg": -3, "big": 1.5}`)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got, want := Str(v), "{'i': 7, 'f': 7.0, 'e': 7.0, 'neg': -3, 'big': 1.5}"; got != want { + t.Errorf("repr = %s, want %s", got, want) + } +} + +// Python dict assignment: the last value wins, the FIRST position is kept. +func TestLoad_RepeatedKeyKeepsTheFirstPosition(t *testing.T) { + v, err := Load([]byte(`{"a": 1, "b": 2, "a": 3}`)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got, want := Str(v), "{'a': 3, 'b': 2}"; got != want { + t.Errorf("repr = %s, want %s", got, want) + } +} + +// json.load raises JSONDecodeError("Extra data") rather than ignoring the tail. +func TestLoad_RejectsTrailingContent(t *testing.T) { + for _, src := range []string{`{"a": 1} {"b": 2}`, `{"a":1} {"b":2}`, `[1] 2`} { + if _, err := Load([]byte(src)); err == nil { + t.Errorf("Load(%q) = nil error, want the json.load 'Extra data' rejection", src) + } + } +} + +func TestLoad_DecodesEveryScalarKind(t *testing.T) { + v, err := Load([]byte(`[null, true, false, "s", 1, 1.25, [], {}]`)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got, want := Repr(v), "[None, True, False, 's', 1, 1.25, [], {}]"; got != want { + t.Errorf("repr = %s, want %s", got, want) + } +} + +// The concrete Go kinds matter, not just the repr: Dumps renders int 1 as `1` +// and float64 1.0 as `1.0`. +func TestLoad_ProducesTheDocumentedValueModel(t *testing.T) { + v, err := Load([]byte(`{"z": 1, "a": 2.0, "m": [3, 4.5, "s", true, null], "n": {"inner": 1e2}}`)) + if err != nil { + t.Fatalf("Load: %v", err) + } + obj, ok := v.(Ordered) + if !ok { + t.Fatalf("want Ordered, got %T", v) + } + if got, want := Str(obj), "{'z': 1, 'a': 2.0, 'm': [3, 4.5, 's', True, None], 'n': {'inner': 100.0}}"; got != want { + t.Errorf("repr = %s, want %s", got, want) + } + if _, isInt := mustLoadGet(t, obj, "z").(int); !isInt { + t.Error(`"z" must decode as int: the literal has no "." and no exponent`) + } + if _, isFloat := mustLoadGet(t, obj, "a").(float64); !isFloat { + t.Error(`"a" must decode as float64: the literal has a "."`) + } + list, ok := mustLoadGet(t, obj, "m").([]any) + if !ok { + t.Fatalf(`"m" = %T, want []any`, mustLoadGet(t, obj, "m")) + } + if want := []any{3, 4.5, "s", true, nil}; !reflect.DeepEqual(list, want) { + t.Errorf(`"m" = %#v, want %#v`, list, want) + } + inner, ok := mustLoadGet(t, obj, "n").(Ordered) + if !ok { + t.Fatalf(`"n" = %T, want Ordered`, mustLoadGet(t, obj, "n")) + } + if v, _ := inner.Get("inner"); v != 100.0 { + t.Errorf(`"n.inner" = %#v, want float64 100 (the literal has an exponent)`, v) + } +} + +// Dumps(Load(x), 2) is the identity for anything CPython wrote with +// json.dumps(x, indent=2) — this is what makes the graph builder's +// read-modify-write byte-stable. Confirmed identical in the interpreter. +func TestLoad_DumpsRoundTripIsTheIdentity(t *testing.T) { + src := "{\n \"z\": 1,\n \"a\": [\n 1.5,\n true,\n null\n ],\n \"s\": \"caf\\u00e9\"\n}" + decoded, err := Load([]byte(src)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got := Dumps(decoded, 2); got != src { + t.Errorf("round-trip mismatch\n got: %q\nwant: %q", got, src) + } +} + +func TestLoad_RejectsMalformedInput(t *testing.T) { + for _, src := range []string{``, `{`, `{"a"}`, `[1,`, `nope`} { + if _, err := Load([]byte(src)); err == nil { + t.Errorf("Load(%q) = nil error, want a decode failure", src) + } + } +} + +func mustLoadGet(t *testing.T, o Ordered, key string) any { + t.Helper() + v, ok := o.Get(key) + if !ok { + t.Fatalf("key %q missing", key) + } + return v +} diff --git a/go/internal/pyfmt/pyset.go b/go/internal/pyfmt/pyset.go new file mode 100644 index 0000000..c998bcf --- /dev/null +++ b/go/internal/pyfmt/pyset.go @@ -0,0 +1,128 @@ +package pyfmt + +import ( + "encoding/json" + "math" +) + +// --------------------------------------------------------------------------- +// Python set / dict keys over the json.load value model +// --------------------------------------------------------------------------- + +// SetKey is a comparable stand-in for a Python dict/set key drawn from the +// Load value model (nil | bool | string | int | float64 | []any | Ordered). +// +// It exists because two ported functions key a dict and several sets by a +// value read straight out of a model-authored JSON file — `node.get("resource_id")` +// in build_graph_context_for_hunter (internal/agents/util) and +// `edge.get("source", "")` in _filter_graph_for_findings (internal/agents/chain). +// That value is a string in every file this port's own deterministic builders +// produce, but graph.json is also authored by the LLM when +// resource_graph_builder falls back to the harness, so `null`, a number or a +// bool can and do appear there. Python compares those BY VALUE, so a JSON null +// endpoint really does join the id set and really does keep its node in the +// filtered graph; a Go `map[string]bool` silently drops it. +// +// Equality reproduces CPython's: +// +// None distinct from "" and from the string "None" +// "5" distinct from 5 +// 5 == 5.0 the same key (Python hashes them into one slot) +// True == 1 the same key, and False == 0 +// +// DIVERGENCE (deliberate, unreachable from a real graph file): a list/dict id +// raises `TypeError: unhashable type` in Python; SetKey renders it with Repr +// and keeps going rather than crashing the reasoner. +type SetKey struct { + kind byte // 'N' none, 'S' string, 'I' integral number, 'F' non-integral float, 'O' repr-rendered + text string // the string itself for 'S', Repr(v) for 'O' + i int64 // 'I' + f float64 // 'F' +} + +// KeyOf maps a Load-model value onto its Python set key. +func KeyOf(v any) SetKey { + switch x := v.(type) { + case nil: + return SetKey{kind: 'N'} + case string: + return SetKey{kind: 'S', text: x} + case bool: + // Python: hash(True) == hash(1) and True == 1. + if x { + return SetKey{kind: 'I', i: 1} + } + return SetKey{kind: 'I', i: 0} + case int: + return SetKey{kind: 'I', i: int64(x)} + case int64: + return SetKey{kind: 'I', i: x} + case json.Number: + // An arbitrary-precision Python int (see loadNumber). Within int64 it + // shares the ordinary int slot; beyond it, equal literals still share a + // slot because Repr is the exact digits. + if n, err := x.Int64(); err == nil { + return SetKey{kind: 'I', i: n} + } + return SetKey{kind: 'O', text: Repr(x)} + case float64: + // Python: 5.0 == 5, so an integral float shares the int's slot. + if !math.IsNaN(x) && !math.IsInf(x, 0) && x == math.Trunc(x) && + x >= math.MinInt64 && x <= math.MaxInt64 { + return SetKey{kind: 'I', i: int64(x)} + } + return SetKey{kind: 'F', f: x} + default: + return SetKey{kind: 'O', text: Repr(x)} + } +} + +// KeySet is a Python set of Load-model values with a deterministic, +// first-insertion iteration order. +// +// Python parity NOTE — THE ORDER IS THE DIVERGENCE. Python iterates a `set`, +// whose order depends on hashing and is therefore randomized per process +// (PYTHONHASHSEED). The port contract requires determinism, so this type +// remembers insertion order. Membership semantics are unchanged, and callers +// that only test membership (e.g. _filter_graph_for_findings) are unaffected +// either way. +type KeySet struct { + index map[SetKey]struct{} + order []SetKey +} + +// NewKeySet returns an empty set. +func NewKeySet() *KeySet { + return &KeySet{index: map[SetKey]struct{}{}} +} + +// Add inserts v, remembering its first-insertion position. +func (s *KeySet) Add(v any) { + k := KeyOf(v) + if _, ok := s.index[k]; ok { + return + } + s.index[k] = struct{}{} + s.order = append(s.order, k) +} + +// Has ports `v in s`. +func (s *KeySet) Has(v any) bool { + _, ok := s.index[KeyOf(v)] + return ok +} + +// Keys returns the members in first-insertion order. +func (s *KeySet) Keys() []SetKey { return s.order } + +// Len is `len(s)`. +func (s *KeySet) Len() int { return len(s.order) } + +// Clone returns an independent copy, the way `a | b` and `set(a)` do in Python. +func (s *KeySet) Clone() *KeySet { + out := &KeySet{index: make(map[SetKey]struct{}, len(s.index)), order: append([]SetKey(nil), s.order...)} + for k := range s.index { + out.index[k] = struct{}{} + } + return out +} diff --git a/go/internal/pyfmt/pyset_test.go b/go/internal/pyfmt/pyset_test.go new file mode 100644 index 0000000..a9ccbd1 --- /dev/null +++ b/go/internal/pyfmt/pyset_test.go @@ -0,0 +1,80 @@ +package pyfmt + +import "testing" + +// TestKeySet_PythonEqualityRules pins the CPython set semantics SetKey exists +// to reproduce. Ground truth (repo venv, python3.11): +// +// >>> s = {None, "", "None", 5, "5"}; len(s) +// 5 +// >>> 5.0 in {5}, True in {1}, False in {0}, 1 in {True} +// (True, True, True, True) +// >>> "5" in {5} +// False +func TestKeySet_PythonEqualityRules(t *testing.T) { + s := NewKeySet() + for _, v := range []any{nil, "", "None", 5, "5"} { + s.Add(v) + } + if s.Len() != 5 { + t.Fatalf("len = %d, want 5 distinct keys", s.Len()) + } + s.Add(nil) + if s.Len() != 5 { + t.Fatalf("re-adding None changed len to %d", s.Len()) + } + + // 5 == 5.0 == (no bool here) — one slot. + if !s.Has(5.0) { + t.Error("5.0 not in {5}: Python hashes an integral float into the int's slot") + } + if s.Has("x") { + t.Error("membership invented a member") + } + + b := NewKeySet() + b.Add(true) + b.Add(false) + if b.Len() != 2 { + t.Fatalf("len({True, False}) = %d, want 2", b.Len()) + } + if !b.Has(1) || !b.Has(0) || !b.Has(1.0) { + t.Error("True/False must share a slot with 1/0 as Python does") + } + if b.Has("1") { + t.Error(`"1" must not match True`) + } +} + +// A non-integral float keeps its own slot, and Keys() is first-insertion order +// (the deliberate divergence from Python's randomized set iteration). +func TestKeySet_FloatsAndInsertionOrder(t *testing.T) { + s := NewKeySet() + for _, v := range []any{"b", 1.5, "a", 1.5} { + s.Add(v) + } + if s.Len() != 3 { + t.Fatalf("len = %d, want 3", s.Len()) + } + if !s.Has(1.5) || s.Has(1) { + t.Error("1.5 must match itself and not the int 1") + } + got := s.Keys() + if got[0] != KeyOf("b") || got[1] != KeyOf(1.5) || got[2] != KeyOf("a") { + t.Errorf("Keys() = %v, want first-insertion order b, 1.5, a", got) + } +} + +// Clone is `set(a)`: independent storage, same members, same order. +func TestKeySet_CloneIsIndependent(t *testing.T) { + a := NewKeySet() + a.Add("x") + b := a.Clone() + b.Add("y") + if a.Has("y") { + t.Error("Clone shares storage with the original") + } + if !b.Has("x") { + t.Error("Clone lost a member") + } +} diff --git a/go/internal/pyfmt/repr_string.go b/go/internal/pyfmt/repr_string.go new file mode 100644 index 0000000..fc651af --- /dev/null +++ b/go/internal/pyfmt/repr_string.go @@ -0,0 +1,150 @@ +package pyfmt + +import ( + "fmt" + "reflect" + "sort" + "strconv" + "strings" + "unicode" +) + +// reprString ports CPython's unicode_repr (Objects/unicodeobject.c). +// +// Quote selection: single quotes, UNLESS the string contains a single quote and +// no double quote — then double quotes are used so the apostrophe needs no +// escape. Verified: +// +// repr("it's") == "it's" (double-quoted, ' left bare) +// repr('say "hi"') == 'say "hi"' (single-quoted, " left bare) +// repr("""both ' and " """.strip()) -> 'both \' and "' (both present -> single quotes, ' escaped) +// +// Escapes, in CPython's order: the active quote and backslash get a backslash; +// \t \n \r get their short forms; anything below 0x20 or equal to 0x7f becomes +// \xNN; every other ASCII char is literal; and a non-ASCII rune is literal iff +// Py_UNICODE_ISPRINTABLE, otherwise \xNN / \uXXXX / \UXXXXXXXX by width. +// +// Go's unicode.IsPrint is the exact analogue of Py_UNICODE_ISPRINTABLE: both +// exclude categories Cc, Cf, Cs, Co, Cn, Zl, Zp and Zs-except-U+0020. Verified +// against CPython: +// +// repr("café") == "'café'" (printable, literal) +// repr("emoji 😀") == "'emoji 😀'" (So, literal) +// repr("nbsp\xa0x") == "'nbsp\\xa0x'" (Zs -> escaped) +// repr("zwsp​x") == "'zwsp\\u200bx'" (Cf -> escaped) +func reprString(s string) string { + quote := byte('\'') + if strings.ContainsRune(s, '\'') && !strings.ContainsRune(s, '"') { + quote = '"' + } + + var b strings.Builder + b.WriteByte(quote) + for _, r := range s { + switch { + case r == rune(quote) || r == '\\': + b.WriteByte('\\') + b.WriteRune(r) + case r == '\t': + b.WriteString(`\t`) + case r == '\n': + b.WriteString(`\n`) + case r == '\r': + b.WriteString(`\r`) + case r < 0x20 || r == 0x7f: + writeHexEscape(&b, r) + case r < 0x7f: + b.WriteRune(r) + case unicode.IsPrint(r): + b.WriteRune(r) + default: + writeHexEscape(&b, r) + } + } + b.WriteByte(quote) + return b.String() +} + +// writeHexEscape emits CPython's width-selected numeric escape: \xNN below +// U+0100, \uXXXX below U+10000, \UXXXXXXXX above. Hex digits are lowercase. +func writeHexEscape(b *strings.Builder, r rune) { + switch { + case r < 0x100: + b.WriteString(`\x`) + writeHexDigits(b, uint32(r), 2) + case r < 0x10000: + b.WriteString(`\u`) + writeHexDigits(b, uint32(r), 4) + default: + b.WriteString(`\U`) + writeHexDigits(b, uint32(r), 8) + } +} + +const hexDigits = "0123456789abcdef" + +func writeHexDigits(b *strings.Builder, v uint32, width int) { + for i := width - 1; i >= 0; i-- { + b.WriteByte(hexDigits[(v>>uint(4*i))&0xf]) + } +} + +// reprReflect is the fallback for value kinds Repr's type switch does not name +// explicitly: typed slices/arrays (rendered as a Python list), typed maps +// (rendered as a Python dict with SORTED keys — see the warning on Repr), +// pointers (the pointee, or None), and anything else (Go's own %v rendering, +// which no ported prompt should ever reach). +func reprReflect(v any) string { + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Invalid: + return "None" + case reflect.Pointer, reflect.Interface: + if rv.IsNil() { + return "None" + } + return Repr(rv.Elem().Interface()) + case reflect.Slice, reflect.Array: + if rv.Kind() == reflect.Slice && rv.IsNil() { + // Python parity: a Go nil slice stands in for an empty Python list. + // A Python None would have arrived as an untyped nil. + return "[]" + } + parts := make([]string, rv.Len()) + for i := 0; i < rv.Len(); i++ { + parts[i] = Repr(rv.Index(i).Interface()) + } + return "[" + strings.Join(parts, ", ") + "]" + case reflect.Map: + keys := rv.MapKeys() + strKeys := make([]string, len(keys)) + byStr := make(map[string]reflect.Value, len(keys)) + for i, k := range keys { + ks := Str(k.Interface()) + strKeys[i] = ks + byStr[ks] = k + } + sort.Strings(strKeys) + pairs := make([]KV, len(strKeys)) + for i, ks := range strKeys { + pairs[i] = KV{K: ks, V: rv.MapIndex(byStr[ks]).Interface()} + } + return reprPairs(pairs) + case reflect.String: + return reprString(rv.String()) + case reflect.Bool: + if rv.Bool() { + return "True" + } + return "False" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return strconv.FormatInt(rv.Int(), 10) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return strconv.FormatUint(rv.Uint(), 10) + case reflect.Float32, reflect.Float64: + return FormatFloat(rv.Float()) + } + // Unreachable for every value the port interpolates; keep it honest + // rather than panicking. + return fmt.Sprintf("%v", v) +} diff --git a/go/internal/pyfmt/repr_test.go b/go/internal/pyfmt/repr_test.go new file mode 100644 index 0000000..0a8a4d4 --- /dev/null +++ b/go/internal/pyfmt/repr_test.go @@ -0,0 +1,214 @@ +package pyfmt + +import "testing" + +// --------------------------------------------------------------------------- +// Repr — strings +// --------------------------------------------------------------------------- + +// Captured with (note the shell heredoc, so the quotes survive verbatim): +// +// python - <<'PY' +// for v in ["hello","it's",'say "hi"','both \' and "',"line\nbreak","tab\there","back\\slash","","café","\x00\x01\x1f\x7f","emoji \U0001F600","nbsp\xa0x","zwsp​x","\r","quote'in\"both"]: +// print(repr(v)) +// PY +// +// 'hello' +// "it's" +// 'say "hi"' +// 'both \' and "' +// 'line\nbreak' +// 'tab\there' +// 'back\\slash' +// '' +// 'café' +// '\x00\x01\x1f\x7f' +// 'emoji 😀' +// 'nbsp\xa0x' +// 'zwsp\u200bx' +// '\r' +// 'quote\'in"both' +func TestRepr_String_PythonGroundTruth(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"plain", "hello", `'hello'`}, + {"apostrophe only flips to double quotes", "it's", `"it's"`}, + {"double quote only stays single-quoted", `say "hi"`, `'say "hi"'`}, + {"both quotes: single quotes win, apostrophe escaped", `both ' and "`, `'both \' and "'`}, + {"newline", "line\nbreak", `'line\nbreak'`}, + {"tab", "tab\there", `'tab\there'`}, + {"backslash", `back\slash`, `'back\\slash'`}, + {"empty", "", `''`}, + {"printable non-ascii stays literal", "café", `'café'`}, + {"control chars use \\xNN", "\x00\x01\x1f\x7f", `'\x00\x01\x1f\x7f'`}, + {"astral printable stays literal", "emoji \U0001F600", `'emoji 😀'`}, + {"Zs non-breaking space is escaped", "nbsp x", `'nbsp\xa0x'`}, + {"Cf zero-width space uses \\uXXXX", "zwsp\u200bx", `'zwsp\u200bx'`}, + {"carriage return", "\r", `'\r'`}, + {"apostrophe plus double quote", "quote'in\"both", `'quote\'in"both'`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := Repr(tc.in); got != tc.want { + t.Fatalf("Repr(%q) = %s, want %s", tc.in, got, tc.want) + } + }) + } +} + +// Captured with: +// +// python -c "print(repr('\U000E0001'))" -> '\U000e0001' (Cf, above U+FFFF) +func TestRepr_String_AstralNonPrintableUsesCapitalU(t *testing.T) { + if got := Repr("\U000E0001"); got != `'\U000e0001'` { + t.Fatalf("Repr = %s", got) + } +} + +// --------------------------------------------------------------------------- +// Repr — scalars, lists, dicts +// --------------------------------------------------------------------------- + +// Captured with: +// +// python - <<'PY' +// for v in [True, False, None, 42, -7, 3.5, 1.0, ["a","b"], [1,2.5,None,True], [], +// ["it's",'q"'], {"k":"v"}, {}, {"b":1,"a":2}, {"n":None,"l":["x"],"d":{"i":1}}]: +// print(repr(v)) +// PY +// +// True +// False +// None +// 42 +// -7 +// 3.5 +// 1.0 +// ['a', 'b'] +// [1, 2.5, None, True] +// [] +// ["it's", 'q"'] +// {'k': 'v'} +// {} +// {'b': 1, 'a': 2} +// {'n': None, 'l': ['x'], 'd': {'i': 1}} +func TestRepr_Values_PythonGroundTruth(t *testing.T) { + cases := []struct { + name string + in any + want string + }{ + {"true", true, "True"}, + {"false", false, "False"}, + {"none", nil, "None"}, + {"int", 42, "42"}, + {"negative int", -7, "-7"}, + {"float", 3.5, "3.5"}, + {"integral float keeps .0", 1.0, "1.0"}, + {"list of str", []string{"a", "b"}, `['a', 'b']`}, + {"heterogeneous list", []any{1, 2.5, nil, true}, `[1, 2.5, None, True]`}, + {"empty list", []any{}, `[]`}, + {"list quoting is per element", []string{"it's", `q"`}, `["it's", 'q"']`}, + {"dict", Ordered{{K: "k", V: "v"}}, `{'k': 'v'}`}, + {"empty dict", Ordered{}, `{}`}, + // Insertion order is preserved by Ordered even when it is not sorted. + {"dict keeps insertion order", Ordered{{K: "b", V: 1}, {K: "a", V: 2}}, `{'b': 1, 'a': 2}`}, + {"nested", Ordered{ + {K: "n", V: nil}, + {K: "l", V: []string{"x"}}, + {K: "d", V: Ordered{{K: "i", V: 1}}}, + }, `{'n': None, 'l': ['x'], 'd': {'i': 1}}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := Repr(tc.in); got != tc.want { + t.Fatalf("Repr(%#v) = %s, want %s", tc.in, got, tc.want) + } + }) + } +} + +// Captured with: +// +// python -c "print(repr({'outer': [{'k': \"v'x\"}, 1.0, None]}))" +// -> {'outer': [{'k': "v'x"}, 1.0, None]} +func TestRepr_Nested(t *testing.T) { + in := Ordered{{K: "outer", V: []any{ + Ordered{{K: "k", V: "v'x"}}, + 1.0, + nil, + }}} + want := `{'outer': [{'k': "v'x"}, 1.0, None]}` + if got := Repr(in); got != want { + t.Fatalf("Repr = %s, want %s", got, want) + } +} + +// A plain Go map has no insertion order, so Repr sorts its keys. This is the +// documented divergence from Python; the test pins it so nobody "fixes" it into +// range order (which Go randomizes). +func TestRepr_PlainMapIsSortedAndDeterministic(t *testing.T) { + in := map[string]any{"b": 1, "a": 2, "c": nil} + want := `{'a': 2, 'b': 1, 'c': None}` + for i := 0; i < 20; i++ { + if got := Repr(in); got != want { + t.Fatalf("Repr = %s, want %s", got, want) + } + } +} + +func TestRepr_KVSliceIsAcceptedLikeOrdered(t *testing.T) { + if got := Repr([]KV{{K: "z", V: 1}, {K: "a", V: 2}}); got != `{'z': 1, 'a': 2}` { + t.Fatalf("Repr = %s", got) + } +} + +func TestRepr_PointerDerefAndNil(t *testing.T) { + s := "hi" + if got := Repr(&s); got != `'hi'` { + t.Fatalf("Repr(&s) = %s", got) + } + var np *string + if got := Repr(np); got != "None" { + t.Fatalf("Repr((*string)(nil)) = %s", got) + } +} + +// Captured with: +// +// python -c "print(str('raw'), repr('raw'), str(1.0), str(['a']), str(None))" +// -> raw 'raw' 1.0 ['a'] None +func TestStr_MatchesPythonStr(t *testing.T) { + if got := Str("raw"); got != "raw" { + t.Fatalf("Str(string) = %q", got) + } + if got := Str(1.0); got != "1.0" { + t.Fatalf("Str(1.0) = %q", got) + } + if got := Str([]string{"a"}); got != `['a']` { + t.Fatalf("Str(list) = %q", got) + } + if got := Str(nil); got != "None" { + t.Fatalf("Str(nil) = %q", got) + } + s := "p" + if got := Str(&s); got != "p" { + t.Fatalf("Str(*string) = %q", got) + } +} + +func TestOrdered_Get(t *testing.T) { + o := Ordered{{K: "a", V: 1}, {K: "b", V: nil}} + if v, ok := o.Get("a"); !ok || v != 1 { + t.Fatalf("Get(a) = %v, %v", v, ok) + } + if v, ok := o.Get("b"); !ok || v != nil { + t.Fatalf("Get(b) = %v, %v", v, ok) + } + if _, ok := o.Get("missing"); ok { + t.Fatalf("Get(missing) reported present") + } +} diff --git a/go/internal/pyfmt/testdata/golden/dumps_AttackPath_compact.txt b/go/internal/pyfmt/testdata/golden/dumps_AttackPath_compact.txt new file mode 100644 index 0000000..5419185 --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/dumps_AttackPath_compact.txt @@ -0,0 +1 @@ +{"id": "ap-1", "title": "Path", "description": "", "steps": [{"step_number": 1, "resource_id": "a", "resource_type": "t", "action": "act", "permission_used": "perm", "description": ""}, {"step_number": 2, "resource_id": "b", "resource_type": "t", "action": "act2", "permission_used": "perm2", "description": "d"}], "entry_point": "a", "target": "b", "findings_involved": ["f1", "f2"], "combined_severity": "critical", "blast_radius": {"data_stores_reachable": [], "compute_reachable": ["c1"], "estimated_data_volume": null, "services_affected": []}} \ No newline at end of file diff --git a/go/internal/pyfmt/testdata/golden/dumps_AttackPath_indent2.txt b/go/internal/pyfmt/testdata/golden/dumps_AttackPath_indent2.txt new file mode 100644 index 0000000..04903bf --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/dumps_AttackPath_indent2.txt @@ -0,0 +1,38 @@ +{ + "id": "ap-1", + "title": "Path", + "description": "", + "steps": [ + { + "step_number": 1, + "resource_id": "a", + "resource_type": "t", + "action": "act", + "permission_used": "perm", + "description": "" + }, + { + "step_number": 2, + "resource_id": "b", + "resource_type": "t", + "action": "act2", + "permission_used": "perm2", + "description": "d" + } + ], + "entry_point": "a", + "target": "b", + "findings_involved": [ + "f1", + "f2" + ], + "combined_severity": "critical", + "blast_radius": { + "data_stores_reachable": [], + "compute_reachable": [ + "c1" + ], + "estimated_data_volume": null, + "services_affected": [] + } +} \ No newline at end of file diff --git a/go/internal/pyfmt/testdata/golden/dumps_ChainResult_compact.txt b/go/internal/pyfmt/testdata/golden/dumps_ChainResult_compact.txt new file mode 100644 index 0000000..4c979cf --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/dumps_ChainResult_compact.txt @@ -0,0 +1 @@ +{"attack_paths": [], "total_paths_evaluated": 12, "viable_paths": 0, "chain_duration_seconds": 1e-05} \ No newline at end of file diff --git a/go/internal/pyfmt/testdata/golden/dumps_ChainResult_indent2.txt b/go/internal/pyfmt/testdata/golden/dumps_ChainResult_indent2.txt new file mode 100644 index 0000000..df6cd90 --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/dumps_ChainResult_indent2.txt @@ -0,0 +1,6 @@ +{ + "attack_paths": [], + "total_paths_evaluated": 12, + "viable_paths": 0, + "chain_duration_seconds": 1e-05 +} \ No newline at end of file diff --git a/go/internal/pyfmt/testdata/golden/dumps_ScanMetrics_compact.txt b/go/internal/pyfmt/testdata/golden/dumps_ScanMetrics_compact.txt new file mode 100644 index 0000000..3b4ca5d --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/dumps_ScanMetrics_compact.txt @@ -0,0 +1 @@ +{"duration_seconds": 1000000000000000.0, "agent_invocations": 41, "cost_usd": 1e+16, "cost_breakdown": {"a": 1.0, "b": 0.5, "c": -0.0, "z": 1e-05}, "budget_exhausted": true, "findings_not_verified": 0} \ No newline at end of file diff --git a/go/internal/pyfmt/testdata/golden/dumps_ScanMetrics_indent2.txt b/go/internal/pyfmt/testdata/golden/dumps_ScanMetrics_indent2.txt new file mode 100644 index 0000000..f74bee8 --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/dumps_ScanMetrics_indent2.txt @@ -0,0 +1,13 @@ +{ + "duration_seconds": 1000000000000000.0, + "agent_invocations": 41, + "cost_usd": 1e+16, + "cost_breakdown": { + "a": 1.0, + "b": 0.5, + "c": -0.0, + "z": 1e-05 + }, + "budget_exhausted": true, + "findings_not_verified": 0 +} \ No newline at end of file diff --git a/go/internal/pyfmt/testdata/golden/dumps_VerifiedFinding_compact.txt b/go/internal/pyfmt/testdata/golden/dumps_VerifiedFinding_compact.txt new file mode 100644 index 0000000..505899e --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/dumps_VerifiedFinding_compact.txt @@ -0,0 +1 @@ +{"id": "vf-1", "title": "Bucket \u00e9xposed \u2014 \ud83d\ude80", "verdict": "likely", "severity": "high", "category": "public_exposure", "resources": [{"resource_id": "aws_s3_bucket.b", "resource_type": "aws_s3_bucket", "attribute": "acl", "current_value": "public-read", "recommended_value": "private"}], "attack_path": null, "drift": null, "proof": {"method": "iam_simulation", "evidence": [" & \"quote\"", "line\twith tab"], "scripts_executed": [], "verification_tier": "live"}, "compliance_mappings": [], "risk_score": 7.25, "remediation": null, "sarif_rule_id": "cloudsecurity/data/public_exposure", "sarif_security_severity": 10.0, "iac_file": "s3.tf", "iac_line": 12, "config_snippet": "", "description": "", "fingerprint": "fp-1", "hunter_strategy": "data", "drop_reason": null} \ No newline at end of file diff --git a/go/internal/pyfmt/testdata/golden/dumps_VerifiedFinding_indent2.txt b/go/internal/pyfmt/testdata/golden/dumps_VerifiedFinding_indent2.txt new file mode 100644 index 0000000..fa10f93 --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/dumps_VerifiedFinding_indent2.txt @@ -0,0 +1,39 @@ +{ + "id": "vf-1", + "title": "Bucket \u00e9xposed \u2014 \ud83d\ude80", + "verdict": "likely", + "severity": "high", + "category": "public_exposure", + "resources": [ + { + "resource_id": "aws_s3_bucket.b", + "resource_type": "aws_s3_bucket", + "attribute": "acl", + "current_value": "public-read", + "recommended_value": "private" + } + ], + "attack_path": null, + "drift": null, + "proof": { + "method": "iam_simulation", + "evidence": [ + " & \"quote\"", + "line\twith tab" + ], + "scripts_executed": [], + "verification_tier": "live" + }, + "compliance_mappings": [], + "risk_score": 7.25, + "remediation": null, + "sarif_rule_id": "cloudsecurity/data/public_exposure", + "sarif_security_severity": 10.0, + "iac_file": "s3.tf", + "iac_line": 12, + "config_snippet": "", + "description": "", + "fingerprint": "fp-1", + "hunter_strategy": "data", + "drop_reason": null +} \ 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..bc3779c --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/dumps_edge_cases_compact.txt @@ -0,0 +1 @@ +{"big_int": 1234567890123456789, "control": "a\tb\nc\u0000d\u007f", "empty_list": [], "empty_obj": {}, "false": false, "float_huge": 1e+16, "float_integral": 1.0, "float_neg_zero": -0.0, "float_pi": 3.141592653589793, "float_tiny": 1e-05, "html": " & /", "int": 7, "nested": {"list_of_obj": [{"k": 1}, {"k": 2}]}, "null": null, "true": true, "unicode": "h\u00e9llo \u2014 \u4e16\u754c \ud83d\ude80"} \ 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..8a0aa7e --- /dev/null +++ b/go/internal/pyfmt/testdata/golden/dumps_edge_cases_indent2.txt @@ -0,0 +1,27 @@ +{ + "big_int": 1234567890123456789, + "control": "a\tb\nc\u0000d\u007f", + "empty_list": [], + "empty_obj": {}, + "false": false, + "float_huge": 1e+16, + "float_integral": 1.0, + "float_neg_zero": -0.0, + "float_pi": 3.141592653589793, + "float_tiny": 1e-05, + "html": " & /", + "int": 7, + "nested": { + "list_of_obj": [ + { + "k": 1 + }, + { + "k": 2 + } + ] + }, + "null": null, + "true": true, + "unicode": "h\u00e9llo \u2014 \u4e16\u754c \ud83d\ude80" +} \ No newline at end of file diff --git a/go/internal/pyfmt/testdata/models_fixture.json b/go/internal/pyfmt/testdata/models_fixture.json new file mode 100644 index 0000000..362db68 --- /dev/null +++ b/go/internal/pyfmt/testdata/models_fixture.json @@ -0,0 +1,125 @@ +{ + "AttackPath": { + "blast_radius": { + "compute_reachable": [ + "c1" + ], + "data_stores_reachable": [], + "estimated_data_volume": null, + "services_affected": [] + }, + "combined_severity": "critical", + "description": "", + "entry_point": "a", + "findings_involved": [ + "f1", + "f2" + ], + "id": "ap-1", + "steps": [ + { + "action": "act", + "description": "", + "permission_used": "perm", + "resource_id": "a", + "resource_type": "t", + "step_number": 1 + }, + { + "action": "act2", + "description": "d", + "permission_used": "perm2", + "resource_id": "b", + "resource_type": "t", + "step_number": 2 + } + ], + "target": "b", + "title": "Path" + }, + "ChainResult": { + "attack_paths": [], + "chain_duration_seconds": 1e-05, + "total_paths_evaluated": 12, + "viable_paths": 0 + }, + "ScanMetrics": { + "agent_invocations": 41, + "budget_exhausted": true, + "cost_breakdown": { + "a": 1.0, + "b": 0.5, + "c": -0.0, + "z": 1e-05 + }, + "cost_usd": 1e+16, + "duration_seconds": 1000000000000000.0, + "findings_not_verified": 0 + }, + "VerifiedFinding": { + "attack_path": null, + "category": "public_exposure", + "compliance_mappings": [], + "config_snippet": "", + "description": "", + "drift": null, + "drop_reason": null, + "fingerprint": "fp-1", + "hunter_strategy": "data", + "iac_file": "s3.tf", + "iac_line": 12, + "id": "vf-1", + "proof": { + "evidence": [ + " & \"quote\"", + "line\twith tab" + ], + "method": "iam_simulation", + "scripts_executed": [], + "verification_tier": "live" + }, + "remediation": null, + "resources": [ + { + "attribute": "acl", + "current_value": "public-read", + "recommended_value": "private", + "resource_id": "aws_s3_bucket.b", + "resource_type": "aws_s3_bucket" + } + ], + "risk_score": 7.25, + "sarif_rule_id": "cloudsecurity/data/public_exposure", + "sarif_security_severity": 10.0, + "severity": "high", + "title": "Bucket \u00e9xposed \u2014 \ud83d\ude80", + "verdict": "likely" + }, + "edge_cases": { + "big_int": 1234567890123456789, + "control": "a\tb\nc\u0000d\u007f", + "empty_list": [], + "empty_obj": {}, + "false": false, + "float_huge": 1e+16, + "float_integral": 1.0, + "float_neg_zero": -0.0, + "float_pi": 3.141592653589793, + "float_tiny": 1e-05, + "html": " & /", + "int": 7, + "nested": { + "list_of_obj": [ + { + "k": 1 + }, + { + "k": 2 + } + ] + }, + "null": null, + "true": true, + "unicode": "h\u00e9llo \u2014 \u4e16\u754c \ud83d\ude80" + } +} diff --git a/go/internal/schemas/chain.go b/go/internal/schemas/chain.go new file mode 100644 index 0000000..299f7bd --- /dev/null +++ b/go/internal/schemas/chain.go @@ -0,0 +1,53 @@ +package schemas + +import "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" + +// This file ports src/cloudsecurity_af/schemas/chain.py — the CHAIN phase +// schemas. The CHAIN phase is CloudSecurity's key differentiator: it constructs +// multi-resource attack paths via meta-prompting. + +// AttackStep ports chain.py AttackStep: one step in a multi-resource attack path. +type AttackStep struct { + StepNumber int `json:"step_number"` + ResourceID string `json:"resource_id"` + ResourceType string `json:"resource_type"` + // Action is what the attacker does at this step. + Action string `json:"action"` + // PermissionUsed is the specific permission or config that enables this step. + PermissionUsed string `json:"permission_used"` + Description string `json:"description"` +} + +// BlastRadius ports chain.py BlastRadius: the impact assessment for a confirmed +// attack path. +type BlastRadius struct { + DataStoresReachable []string `json:"data_stores_reachable"` + ComputeReachable []string `json:"compute_reachable"` + EstimatedDataVolume *string `json:"estimated_data_volume"` + ServicesAffected []string `json:"services_affected"` +} + +// AttackPath ports chain.py AttackPath: a multi-resource attack path assembled +// from individual findings. +type AttackPath struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Steps []AttackStep `json:"steps"` + // EntryPoint is the public-facing resource where the attack begins. + EntryPoint string `json:"entry_point"` + // Target is what the attacker ultimately reaches. + Target string `json:"target"` + // FindingsInvolved holds the IDs of HUNT findings that compose this path. + FindingsInvolved []string `json:"findings_involved"` + CombinedSeverity scoring.Severity `json:"combined_severity"` + BlastRadius BlastRadius `json:"blast_radius"` +} + +// ChainResult ports chain.py ChainResult: the complete CHAIN phase output. +type ChainResult struct { + AttackPaths []AttackPath `json:"attack_paths"` + TotalPathsEvaluated int `json:"total_paths_evaluated"` + ViablePaths int `json:"viable_paths"` + ChainDurationSeconds float64 `json:"chain_duration_seconds"` +} diff --git a/go/internal/schemas/defaults.go b/go/internal/schemas/defaults.go new file mode 100644 index 0000000..8922509 --- /dev/null +++ b/go/internal/schemas/defaults.go @@ -0,0 +1,449 @@ +package schemas + +import ( + "bytes" + "encoding/json" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// This file implements the default-seeding pattern documented in doc.go. +// +// Every model gets an exported New() returning the value a Python +// `Model(**required_only)` produces — non-zero scalar defaults filled in, +// default_factory=list fields seeded to non-nil empty slices (so they marshal as +// `[]`, never `null`), default_factory=dict fields seeded to non-nil empty maps, +// and uuid4 default_factory fields seeded with a fresh NewUUID4(). +// +// Models whose defaults are all Go zero values still get a constructor (so +// callers never have to know which is which) but no UnmarshalJSON — there is +// nothing to seed and the extra method would only cost an allocation. +// +// Where a model DOES need seeding, its UnmarshalJSON assigns the constructor's +// value before decoding. The `type alias X` trick strips X's methods (including +// UnmarshalJSON) so the inner json.Unmarshal does not recurse; nested field +// types keep their own UnmarshalJSON and re-seed themselves. +// +// Ordering below follows the Python modules: recon, hunt, chain, prove, input, +// output, views, pathplan. + +// decodeSeeded is the inner decode every default-seeding UnmarshalJSON runs +// after it has assigned the constructor's value. +// +// It is a UseNumber decode, not json.Unmarshal, because several of these models +// carry free-form `Any`-typed fields — DriftedResource.iac_config / +// live_config are `dict[str, Any]` and ConfigDiff.iac_value / live_value are +// `Any` — and plain encoding/json decodes every JSON number landing in such a +// field as float64. Python keeps `{"port": 5432}` an INT all the way to +// json.dumps, so a float64 would re-render as `5432.0` in the CHAIN parent +// prompt ({{DRIFT_REPORT_JSON}}), in the fix-generator prompt +// ({{FINDING_JSON}}), in .cloudsecurity/checkpoint-recon.json and in the final +// scan result. json.Number keeps the literal, and both encoding/json and +// pyfmt.Dumps re-emit it verbatim. +// +// Typed fields are unaffected: UseNumber only changes decoding into `any`. +func decodeSeeded(b []byte, dest any) error { + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + return dec.Decode(dest) +} + +// --- recon.go --------------------------------------------------------------- + +// NewVariable returns Variable's pydantic defaults (all Go zero / nil). +func NewVariable() Variable { return Variable{} } + +// NewOutput returns Output's pydantic defaults (all Go zero / nil). +func NewOutput() Output { return Output{} } + +// NewProviderConfig returns ProviderConfig's pydantic defaults (all Go zero / nil). +func NewProviderConfig() ProviderConfig { return ProviderConfig{} } + +// NewModule returns Module's pydantic defaults (all Go zero / nil). +func NewModule() Module { return Module{} } + +// NewResource seeds Resource's config={} / references=[] / referenced_by=[]. +func NewResource() Resource { + return Resource{ + Config: map[string]any{}, + References: []string{}, + ReferencedBy: []string{}, + } +} + +// UnmarshalJSON seeds Resource's dict/list defaults. +func (r *Resource) UnmarshalJSON(b []byte) error { + *r = NewResource() + type alias Resource + return decodeSeeded(b, (*alias)(r)) +} + +// NewResourceInventory seeds ResourceInventory.iac_type="terraform". +func NewResourceInventory() ResourceInventory { + return ResourceInventory{IaCType: "terraform"} +} + +// UnmarshalJSON seeds ResourceInventory.iac_type="terraform". +func (i *ResourceInventory) UnmarshalJSON(b []byte) error { + *i = NewResourceInventory() + type alias ResourceInventory + return decodeSeeded(b, (*alias)(i)) +} + +// NewResourceGraph returns ResourceGraph's pydantic defaults (all Go zero). +func NewResourceGraph() ResourceGraph { return ResourceGraph{} } + +// NewConfigDiff returns ConfigDiff's pydantic defaults (Any fields are nil, +// which marshals as null exactly as Python's None does). +func NewConfigDiff() ConfigDiff { return ConfigDiff{} } + +// NewDriftedResource seeds iac_config={} / live_config={} / diffs=[] and +// significance="medium". +func NewDriftedResource() DriftedResource { + return DriftedResource{ + IaCConfig: map[string]any{}, + LiveConfig: map[string]any{}, + Diffs: []ConfigDiff{}, + Significance: "medium", + } +} + +// UnmarshalJSON seeds DriftedResource's dict/list defaults and significance. +func (d *DriftedResource) UnmarshalJSON(b []byte) error { + *d = NewDriftedResource() + type alias DriftedResource + return decodeSeeded(b, (*alias)(d)) +} + +// NewDriftReport seeds all three list fields to empty slices. +func NewDriftReport() DriftReport { + return DriftReport{ + DriftedResources: []DriftedResource{}, + IaCOnlyResources: []string{}, + CloudOnlyResources: []string{}, + } +} + +// UnmarshalJSON seeds DriftReport's list defaults. +func (d *DriftReport) UnmarshalJSON(b []byte) error { + *d = NewDriftReport() + type alias DriftReport + return decodeSeeded(b, (*alias)(d)) +} + +// NewReconResult seeds iac_type="terraform", providers_detected=[] and the two +// nested pointers-to-model defaults. +// +// Python parity caveat (doc.go): ReconResult's `default_factory=ResourceInventory` +// / `ResourceGraph` RAISE, because both inner models have a required field. Go +// cannot raise from a constructor, so the sub-models are seeded with their own +// constructors — producing the JSON the fixture pins, i.e. what +// `ReconResult(inventory=ResourceInventory(inventory_saved_path=""), +// resource_graph=ResourceGraph(graph_saved_path=""))` dumps. +func NewReconResult() ReconResult { + return ReconResult{ + Inventory: NewResourceInventory(), + ResourceGraph: NewResourceGraph(), + IaCType: "terraform", + ProvidersDetected: []string{}, + } +} + +// UnmarshalJSON seeds ReconResult's nested models, iac_type and list default. +func (r *ReconResult) UnmarshalJSON(b []byte) error { + *r = NewReconResult() + type alias ReconResult + return decodeSeeded(b, (*alias)(r)) +} + +// --- hunt.go ---------------------------------------------------------------- + +// NewAffectedResource returns AffectedResource's pydantic defaults (all Go zero). +func NewAffectedResource() AffectedResource { return AffectedResource{} } + +// NewRawFinding seeds resources=[], estimated_severity="medium", +// confidence="medium", and a fresh uuid4 for both id and fingerprint. +func NewRawFinding() RawFinding { + return RawFinding{ + ID: NewUUID4(), + Resources: []AffectedResource{}, + EstimatedSeverity: scoring.SeverityMedium, + Confidence: ConfidenceMedium, + Fingerprint: NewUUID4(), + } +} + +// UnmarshalJSON seeds RawFinding's defaults, including fresh uuid4s for id and +// fingerprint — pydantic calls the default_factory on every model_validate that +// omits the key, so two decodes of the same id-less payload yield distinct ids. +func (f *RawFinding) UnmarshalJSON(b []byte) error { + *f = NewRawFinding() + type alias RawFinding + return decodeSeeded(b, (*alias)(f)) +} + +// NewHuntResult seeds findings=[] and strategies_run=[]. +func NewHuntResult() HuntResult { + return HuntResult{ + Findings: []RawFinding{}, + StrategiesRun: []string{}, + } +} + +// UnmarshalJSON seeds HuntResult's list defaults. +func (h *HuntResult) UnmarshalJSON(b []byte) error { + *h = NewHuntResult() + type alias HuntResult + return decodeSeeded(b, (*alias)(h)) +} + +// --- chain.go --------------------------------------------------------------- + +// NewAttackStep returns AttackStep's pydantic defaults (all Go zero). +func NewAttackStep() AttackStep { return AttackStep{} } + +// NewBlastRadius seeds the three list fields to empty slices. +func NewBlastRadius() BlastRadius { + return BlastRadius{ + DataStoresReachable: []string{}, + ComputeReachable: []string{}, + ServicesAffected: []string{}, + } +} + +// UnmarshalJSON seeds BlastRadius's list defaults. +func (b2 *BlastRadius) UnmarshalJSON(b []byte) error { + *b2 = NewBlastRadius() + type alias BlastRadius + return decodeSeeded(b, (*alias)(b2)) +} + +// NewAttackPath seeds a fresh uuid4 id, steps=[], findings_involved=[], +// combined_severity="high" and a defaulted BlastRadius. +func NewAttackPath() AttackPath { + return AttackPath{ + ID: NewUUID4(), + Steps: []AttackStep{}, + FindingsInvolved: []string{}, + CombinedSeverity: scoring.SeverityHigh, + BlastRadius: NewBlastRadius(), + } +} + +// UnmarshalJSON seeds AttackPath's defaults (including a fresh uuid4 id). +func (a *AttackPath) UnmarshalJSON(b []byte) error { + *a = NewAttackPath() + type alias AttackPath + return decodeSeeded(b, (*alias)(a)) +} + +// NewChainResult seeds attack_paths=[]. +func NewChainResult() ChainResult { + return ChainResult{AttackPaths: []AttackPath{}} +} + +// UnmarshalJSON seeds ChainResult's list default. +func (c *ChainResult) UnmarshalJSON(b []byte) error { + *c = NewChainResult() + type alias ChainResult + return decodeSeeded(b, (*alias)(c)) +} + +// --- prove.go --------------------------------------------------------------- + +// NewProof seeds method="static_analysis", evidence=[], scripts_executed=[] and +// verification_tier="static". +func NewProof() Proof { + return Proof{ + Method: ProofMethodStaticAnalysis, + Evidence: []string{}, + ScriptsExecuted: []string{}, + VerificationTier: "static", + } +} + +// UnmarshalJSON seeds Proof's method, list defaults and verification_tier. +func (p *Proof) UnmarshalJSON(b []byte) error { + *p = NewProof() + type alias Proof + return decodeSeeded(b, (*alias)(p)) +} + +// NewIaCDiff returns IaCDiff's pydantic defaults (all Go zero). +func NewIaCDiff() IaCDiff { return IaCDiff{} } + +// NewRemediationSuggestion seeds diffs=[], effort="moderate" and +// alternative_approaches=[]. +func NewRemediationSuggestion() RemediationSuggestion { + return RemediationSuggestion{ + Diffs: []IaCDiff{}, + Effort: "moderate", + AlternativeApproaches: []string{}, + } +} + +// UnmarshalJSON seeds RemediationSuggestion's list defaults and effort. +func (r *RemediationSuggestion) UnmarshalJSON(b []byte) error { + *r = NewRemediationSuggestion() + type alias RemediationSuggestion + return decodeSeeded(b, (*alias)(r)) +} + +// NewVerifiedFinding seeds fresh uuid4s for id and fingerprint, resources=[], +// a defaulted Proof, and compliance_mappings=[]. +// +// Python parity: verdict and severity are REQUIRED with no default, so they stay +// at the Go zero value ("") here — a caller must set them, exactly as pydantic +// forces. The fixture pins the dump of +// `VerifiedFinding(title="", verdict=CONFIRMED, severity=MEDIUM, category="")`, +// so parity_test.go supplies those two explicitly. +func NewVerifiedFinding() VerifiedFinding { + return VerifiedFinding{ + ID: NewUUID4(), + Resources: []AffectedResource{}, + Proof: NewProof(), + ComplianceMappings: []string{}, + Fingerprint: NewUUID4(), + } +} + +// UnmarshalJSON seeds VerifiedFinding's defaults (including fresh uuid4s). +func (v *VerifiedFinding) UnmarshalJSON(b []byte) error { + *v = NewVerifiedFinding() + type alias VerifiedFinding + return decodeSeeded(b, (*alias)(v)) +} + +// --- input.go --------------------------------------------------------------- + +// NewCloudConfig seeds provider="aws" and regions=["us-east-1"]. +func NewCloudConfig() CloudConfig { + return CloudConfig{ + Provider: "aws", + Regions: []string{"us-east-1"}, + } +} + +// UnmarshalJSON seeds CloudConfig.provider="aws" and regions=["us-east-1"]. +// +// Parity note: a payload that sets "regions": [] overrides the default with an +// empty list (pydantic does the same); only an ABSENT key keeps ["us-east-1"]. +func (c *CloudConfig) UnmarshalJSON(b []byte) error { + *c = NewCloudConfig() + type alias CloudConfig + return decodeSeeded(b, (*alias)(c)) +} + +// NewCloudSecurityInput seeds branch="main", depth="standard", +// severity_threshold="low", output_formats=["json"], +// compliance_frameworks=[] and exclude_paths=["tests/",".git/","examples/",".terraform/"]. +// +// include_paths stays nil (Python's None). +func NewCloudSecurityInput() CloudSecurityInput { + return CloudSecurityInput{ + Branch: "main", + Depth: "standard", + SeverityThreshold: "low", + OutputFormats: []string{"json"}, + ComplianceFrameworks: []string{}, + ExcludePaths: []string{"tests/", ".git/", "examples/", ".terraform/"}, + } +} + +// UnmarshalJSON seeds CloudSecurityInput's scalar and list defaults. +func (in *CloudSecurityInput) UnmarshalJSON(b []byte) error { + *in = NewCloudSecurityInput() + type alias CloudSecurityInput + return decodeSeeded(b, (*alias)(in)) +} + +// --- output.go -------------------------------------------------------------- + +// NewCloudSecurityScanResult seeds every default_factory=list field to an empty +// slice and every default_factory=dict field to an empty map. +// +// Timestamp is REQUIRED in Python with no default, so it stays the zero +// Timestamp here; orchestrator.py always passes datetime.now(UTC) (NowUTC()). +func NewCloudSecurityScanResult() CloudSecurityScanResult { + return CloudSecurityScanResult{ + ProvidersDetected: []string{}, + Findings: []VerifiedFinding{}, + AttackPaths: []AttackPath{}, + BySeverity: map[string]int{}, + ComplianceFrameworksChecked: []string{}, + ComplianceGaps: []string{}, + StrategiesUsed: []string{}, + CostBreakdown: map[string]float64{}, + Metadata: map[string]any{}, + } +} + +// UnmarshalJSON seeds CloudSecurityScanResult's list and dict defaults. +func (r *CloudSecurityScanResult) UnmarshalJSON(b []byte) error { + *r = NewCloudSecurityScanResult() + type alias CloudSecurityScanResult + return decodeSeeded(b, (*alias)(r)) +} + +// NewScanProgress returns ScanProgress's defaults (every field is required in +// Python, so all Go zero). +func NewScanProgress() ScanProgress { return ScanProgress{} } + +// NewScanMetrics seeds cost_breakdown={}. +func NewScanMetrics() ScanMetrics { + return ScanMetrics{CostBreakdown: map[string]float64{}} +} + +// UnmarshalJSON seeds ScanMetrics.cost_breakdown={}. +func (m *ScanMetrics) UnmarshalJSON(b []byte) error { + *m = NewScanMetrics() + type alias ScanMetrics + return decodeSeeded(b, (*alias)(m)) +} + +// --- views.go --------------------------------------------------------------- + +// NewFindingForDedup returns FindingForDedup's defaults (every field is required +// in Python, so all Go zero). +func NewFindingForDedup() FindingForDedup { return FindingForDedup{} } + +// NewFindingForProver returns FindingForProver's defaults (all Go zero / nil). +func NewFindingForProver() FindingForProver { return FindingForProver{} } + +// NewFindingForChain seeds resources=[]. +func NewFindingForChain() FindingForChain { + return FindingForChain{Resources: []string{}} +} + +// UnmarshalJSON seeds FindingForChain.resources=[]. +func (f *FindingForChain) UnmarshalJSON(b []byte) error { + *f = NewFindingForChain() + type alias FindingForChain + return decodeSeeded(b, (*alias)(f)) +} + +// --- pathplan.go ------------------------------------------------------------ + +// NewChildInvestigation seeds findings_involved=[]. +func NewChildInvestigation() ChildInvestigation { + return ChildInvestigation{FindingsInvolved: []string{}} +} + +// UnmarshalJSON seeds ChildInvestigation.findings_involved=[]. +func (c *ChildInvestigation) UnmarshalJSON(b []byte) error { + *c = NewChildInvestigation() + type alias ChildInvestigation + return decodeSeeded(b, (*alias)(c)) +} + +// NewPathInvestigationPlan seeds investigations=[]. +func NewPathInvestigationPlan() PathInvestigationPlan { + return PathInvestigationPlan{Investigations: []ChildInvestigation{}} +} + +// UnmarshalJSON seeds PathInvestigationPlan.investigations=[]. +func (p *PathInvestigationPlan) UnmarshalJSON(b []byte) error { + *p = NewPathInvestigationPlan() + type alias PathInvestigationPlan + return decodeSeeded(b, (*alias)(p)) +} diff --git a/go/internal/schemas/defaults_test.go b/go/internal/schemas/defaults_test.go new file mode 100644 index 0000000..dc7beae --- /dev/null +++ b/go/internal/schemas/defaults_test.go @@ -0,0 +1,318 @@ +package schemas + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// This file pins the default-seeding contract itself (doc.go): an ABSENT key +// keeps the pydantic default, while a PRESENT key overrides it even when the +// value is the Go zero value. Every expectation below was verified against +// ~/.agentfield/packages/cloudsecurity-af/venv/bin/python. + +// TestSeeding_AbsentKeyKeepsDefault_PresentKeyOverrides walks the models whose +// non-zero defaults would silently vanish under a plain json.Unmarshal. +func TestSeeding_AbsentKeyKeepsDefault_PresentKeyOverrides(t *testing.T) { + t.Run("CloudConfig", func(t *testing.T) { + // Python: CloudConfig.model_validate({"regions": []}) -> + // {'provider': 'aws', 'regions': [], ...} + var c CloudConfig + if err := json.Unmarshal([]byte(`{"regions":[]}`), &c); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if c.Provider != "aws" { + t.Errorf("absent provider = %q, want aws", c.Provider) + } + if c.Regions == nil || len(c.Regions) != 0 { + t.Errorf("explicit empty regions = %v, want [] (not the default)", c.Regions) + } + // Python: CloudConfig.model_validate({"provider": ""}) keeps regions. + if err := json.Unmarshal([]byte(`{"provider":""}`), &c); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if c.Provider != "" { + t.Errorf("explicit empty provider = %q, want \"\"", c.Provider) + } + if !reflect.DeepEqual(c.Regions, []string{"us-east-1"}) { + t.Errorf("regions = %v, want the default [us-east-1]", c.Regions) + } + }) + + t.Run("CloudSecurityInput", func(t *testing.T) { + var in CloudSecurityInput + if err := json.Unmarshal([]byte(`{"repo_url":"x","exclude_paths":[],"branch":"","include_paths":[]}`), &in); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if in.Branch != "" { + t.Errorf("explicit empty branch = %q", in.Branch) + } + if in.ExcludePaths == nil || len(in.ExcludePaths) != 0 { + t.Errorf("explicit empty exclude_paths = %v, want []", in.ExcludePaths) + } + // Python: include_paths=[] dumps as [], include_paths absent dumps as null. + if in.IncludePaths == nil || len(in.IncludePaths) != 0 { + t.Errorf("explicit empty include_paths = %v, want []", in.IncludePaths) + } + var bare CloudSecurityInput + if err := json.Unmarshal([]byte(`{"repo_url":"x"}`), &bare); err != nil { + t.Fatalf("unmarshal: %v", err) + } + got := mustJSONMap(t, bare) + if got["include_paths"] != nil { + t.Errorf("absent include_paths marshals as %v, want null", got["include_paths"]) + } + if in.SeverityThreshold != "low" || in.Depth != "standard" { + t.Errorf("threshold/depth = %q/%q, want low/standard", in.SeverityThreshold, in.Depth) + } + if !reflect.DeepEqual(bare.OutputFormats, []string{"json"}) { + t.Errorf("output_formats = %v, want [json]", bare.OutputFormats) + } + }) + + t.Run("Proof", func(t *testing.T) { + // Python: Proof.model_validate({"verification_tier": ""}) -> + // {'method': 'static_analysis', ..., 'verification_tier': ''} + var p Proof + if err := json.Unmarshal([]byte(`{"verification_tier":""}`), &p); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if p.Method != ProofMethodStaticAnalysis { + t.Errorf("method = %q, want static_analysis", p.Method) + } + if p.VerificationTier != "" { + t.Errorf("explicit empty verification_tier = %q", p.VerificationTier) + } + if p.Evidence == nil || p.ScriptsExecuted == nil { + t.Error("evidence/scripts_executed must be non-nil empty slices") + } + }) + + t.Run("RemediationSuggestion", func(t *testing.T) { + // Python: RemediationSuggestion.model_validate({"description":"d","effort":""}) + // -> effort '' (explicit empty overrides "moderate"). + var r RemediationSuggestion + if err := json.Unmarshal([]byte(`{"description":"d","effort":""}`), &r); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if r.Effort != "" { + t.Errorf("explicit empty effort = %q", r.Effort) + } + if err := json.Unmarshal([]byte(`{"description":"d"}`), &r); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if r.Effort != "moderate" { + t.Errorf("absent effort = %q, want moderate", r.Effort) + } + }) + + t.Run("DriftedResource", func(t *testing.T) { + // Python: significance '' when explicitly empty, 'medium' when absent. + var d DriftedResource + if err := json.Unmarshal([]byte(`{"resource_id":"r","resource_type":"t","significance":""}`), &d); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if d.Significance != "" { + t.Errorf("explicit empty significance = %q", d.Significance) + } + if err := json.Unmarshal([]byte(`{"resource_id":"r","resource_type":"t"}`), &d); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if d.Significance != "medium" { + t.Errorf("absent significance = %q, want medium", d.Significance) + } + got := mustJSONMap(t, d) + if !reflect.DeepEqual(got["iac_config"], map[string]any{}) { + t.Errorf("iac_config = %v, want {}", got["iac_config"]) + } + }) + + t.Run("AttackPath", func(t *testing.T) { + var a AttackPath + if err := json.Unmarshal([]byte(`{"title":"t","description":"d","entry_point":"e","target":"g"}`), &a); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if a.CombinedSeverity != scoring.SeverityHigh { + t.Errorf("absent combined_severity = %q, want high", a.CombinedSeverity) + } + if a.ID == "" { + t.Error("absent id should be filled by the uuid4 default_factory") + } + if a.BlastRadius.DataStoresReachable == nil { + t.Error("nested BlastRadius must re-seed its own list defaults") + } + if err := json.Unmarshal([]byte(`{"title":"t","combined_severity":"low","id":"fixed"}`), &a); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if a.CombinedSeverity != scoring.SeverityLow || a.ID != "fixed" { + t.Errorf("explicit values not applied: %q / %q", a.CombinedSeverity, a.ID) + } + }) + + t.Run("RawFinding", func(t *testing.T) { + // Python ground truth (venv): + // RawFinding.model_validate({... , "estimated_severity":"critical", + // "confidence":"low", "iac_line":7, "id":"fixed", "fingerprint":"fp"}) + payload := `{"hunter_strategy":"iam","title":"t","description":"d","category":"c",` + + `"id":"fixed","fingerprint":"fp","estimated_severity":"critical","confidence":"low","iac_line":7}` + var f RawFinding + if err := json.Unmarshal([]byte(payload), &f); err != nil { + t.Fatalf("unmarshal: %v", err) + } + want := map[string]any{ + "benchmark_id": nil, + "category": "c", + "confidence": "low", + "config_snippet": "", + "description": "d", + "estimated_severity": "critical", + "fingerprint": "fp", + "hunter_strategy": "iam", + "iac_file": "", + "iac_line": float64(7), + "id": "fixed", + "resources": []any{}, + "title": "t", + } + if got := mustJSONMap(t, f); !reflect.DeepEqual(got, want) { + gj, _ := json.MarshalIndent(got, "", " ") + wj, _ := json.MarshalIndent(want, "", " ") + t.Errorf("dump mismatch\n go: %s\n python: %s", gj, wj) + } + }) + + t.Run("ReconResult", func(t *testing.T) { + // Python ground truth (venv), nested defaults re-seeded by the sub-models. + payload := `{"inventory":{"inventory_saved_path":"/a"},"resource_graph":{"graph_saved_path":"/b"},"providers_detected":["aws"]}` + var r ReconResult + if err := json.Unmarshal([]byte(payload), &r); err != nil { + t.Fatalf("unmarshal: %v", err) + } + want := map[string]any{ + "drift_report": nil, + "iac_type": "terraform", + "inventory": map[string]any{ + "iac_type": "terraform", "iac_version": nil, + "inventory_saved_path": "/a", "total_resources": float64(0), + }, + "live_inventory": nil, + "providers_detected": []any{"aws"}, + "recon_duration_seconds": float64(0), + "resource_graph": map[string]any{ + "graph_saved_path": "/b", "total_edges": float64(0), "total_nodes": float64(0), + }, + "total_edges": float64(0), + "total_resources": float64(0), + } + if got := mustJSONMap(t, r); !reflect.DeepEqual(got, want) { + gj, _ := json.MarshalIndent(got, "", " ") + wj, _ := json.MarshalIndent(want, "", " ") + t.Errorf("dump mismatch\n go: %s\n python: %s", gj, wj) + } + }) +} + +// TestSeeding_UUIDDefaultsAreFreshPerDecode mirrors pydantic: the +// default_factory runs on every model_validate that omits the key, so two +// decodes of the same id-less payload produce different ids. +func TestSeeding_UUIDDefaultsAreFreshPerDecode(t *testing.T) { + payload := []byte(`{"hunter_strategy":"iam","title":"t","description":"d","category":"c"}`) + var a, b RawFinding + if err := json.Unmarshal(payload, &a); err != nil { + t.Fatalf("unmarshal a: %v", err) + } + if err := json.Unmarshal(payload, &b); err != nil { + t.Fatalf("unmarshal b: %v", err) + } + if a.ID == b.ID { + t.Errorf("two decodes produced the same id %q; the uuid4 default_factory must run per decode", a.ID) + } + if a.ID == a.Fingerprint { + t.Error("id and fingerprint use separate default_factory calls and must differ") + } +} + +// TestSeeding_EmptySlicesNeverMarshalAsNull sweeps every constructor and asserts +// no default_factory=list / dict field marshals as null. +func TestSeeding_EmptySlicesNeverMarshalAsNull(t *testing.T) { + python := loadPythonModels(t) + for name, value := range goDefaults() { + want, ok := python[name] + if !ok { + continue + } + var wantMap map[string]any + if err := json.Unmarshal(want.Dump, &wantMap); err != nil { + t.Fatalf("%s: decode fixture: %v", name, err) + } + got := mustJSONMap(t, value) + for key, wv := range wantMap { + switch wv.(type) { + case []any, map[string]any: + if got[key] == nil { + t.Errorf("%s.%s marshals as null but Python emits %T", name, key, wv) + } + } + } + } +} + +// TestStrictEnumDecoding pins the pydantic-parity rejection of unknown enum +// members and nulls inside a model payload. Verified against the interpreter: +// both `"estimated_severity": "bogus"` and `"estimated_severity": None` raise +// ValidationError. +func TestStrictEnumDecoding(t *testing.T) { + base := `{"hunter_strategy":"iam","title":"t","description":"d","category":"c"` + cases := []struct { + name string + payload string + wantErr bool + }{ + {"valid severity", base + `,"estimated_severity":"critical"}`, false}, + {"unknown severity", base + `,"estimated_severity":"bogus"}`, true}, + {"null severity", base + `,"estimated_severity":null}`, true}, + {"uppercase confidence", base + `,"confidence":"HIGH"}`, true}, + {"valid confidence", base + `,"confidence":"low"}`, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var f RawFinding + err := json.Unmarshal([]byte(c.payload), &f) + if c.wantErr && err == nil { + t.Error("want a decode error (pydantic ValidationError parity), got nil") + } + if !c.wantErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } + + t.Run("unknown verdict", func(t *testing.T) { + var v VerifiedFinding + if err := json.Unmarshal([]byte(`{"title":"t","verdict":"maybe","severity":"high","category":"c"}`), &v); err == nil { + t.Error("want a decode error for an unknown Verdict") + } + }) + t.Run("unknown proof method", func(t *testing.T) { + var p Proof + if err := json.Unmarshal([]byte(`{"method":"vibes"}`), &p); err == nil { + t.Error("want a decode error for an unknown ProofMethod") + } + }) +} + +// TestUnknownKeysAreIgnored mirrors pydantic's default `extra='ignore'`: +// verified that RawFinding.model_validate({..., "zzz": 1}) succeeds and "zzz" +// is absent from model_dump(). +func TestUnknownKeysAreIgnored(t *testing.T) { + var f RawFinding + payload := `{"hunter_strategy":"iam","title":"t","description":"d","category":"c","zzz":1}` + if err := json.Unmarshal([]byte(payload), &f); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, ok := mustJSONMap(t, f)["zzz"]; ok { + t.Error("unknown key leaked into the dump") + } +} diff --git a/go/internal/schemas/doc.go b/go/internal/schemas/doc.go new file mode 100644 index 0000000..17cb53d --- /dev/null +++ b/go/internal/schemas/doc.go @@ -0,0 +1,101 @@ +// Package schemas ports every pydantic model CloudSecurity AF passes across a +// JSON boundary: src/cloudsecurity_af/schemas/{recon,hunt,chain,prove,input, +// output,views}.py, plus the two BaseModels declared inside +// src/cloudsecurity_af/agents/chain/path_constructor.py (ChildInvestigation, +// PathInvestigationPlan — see pathplan.go for why they live here). +// +// One Go file per Python module, same file name. Go struct name == pydantic +// class name, json tag == pydantic field name. +// +// # Import direction +// +// Python's schemas/*.py do `from ..scoring import Severity`, so this package +// imports internal/scoring and internal/scoring NEVER imports this package. The +// Severity / EvidenceMethod / Exposure enums stay in internal/scoring exactly as +// they stay in scoring.py; the enums that Python declares inside schemas/ +// (Confidence, HunterStrategy, FindingCategory, Verdict, ProofMethod) live here. +// +// # Parity rules +// +// - NO `omitempty` anywhere. Python reasoners return `model_dump()`, which +// emits every declared field; the control plane sees all of them. Where a +// call site needs `exclude_none=True` (the prove/remediation phases do), +// that is applied by afx.DropNulls on the marshaled map, never by a struct +// tag. +// - `X | None` (Optional[X]) maps to a Go pointer so an unset value marshals +// to JSON `null` exactly as Python does. The one exception is +// `list[str] | None` (CloudSecurityInput.IncludePaths): a nil Go slice +// already marshals as `null`, so a plain []string carries both states and +// no extra indirection is needed. +// - `dict[str, X]` with default_factory=dict maps to a Go map seeded to a +// non-nil empty map, so it marshals as `{}` and never `null`. +// - `Any` maps to Go `any` (marshals as `null` when nil, matching Python's +// `None` default). +// +// # Default seeding (the pattern) +// +// Go's json.Unmarshal leaves an absent key at the Go zero value, whereas +// pydantic fills the declared default. Every struct that has at least one field +// whose pydantic default is not the Go zero value therefore gets BOTH: +// +// 1. an exported constructor `New()` returning the fully-defaulted value +// — so a struct built in Go code (not decoded from JSON) serializes exactly +// like `Model(**required_only)` does in Python; and +// 2. an `UnmarshalJSON` that assigns `New()` before decoding, so an +// absent key keeps the pydantic default while a present key (even +// `false`/`0`/`""`) overrides it. +// +// Both live in defaults.go, mirroring the pr-af port +// (pr-af/go/internal/schemas/defaults.go). The `type alias X` trick inside +// UnmarshalJSON strips X's methods so the inner json.Unmarshal does not recurse; +// nested field types keep their own UnmarshalJSON and re-seed themselves. +// +// A `New()` is provided for EVERY model, including the ones whose +// pydantic defaults are all Go zero values, so callers never have to know which +// is which. `default_factory=list` fields are always seeded to a non-nil empty +// slice, so an empty list marshals as `[]` and never `null`. +// +// `default_factory=lambda: str(uuid4())` fields (AttackPath.id, RawFinding.id, +// RawFinding.fingerprint, VerifiedFinding.id, VerifiedFinding.fingerprint) are +// seeded with a fresh RFC 4122 v4 string by NewUUID4 (uuid.go, crypto/rand — no +// third-party dependency). Both the constructor and UnmarshalJSON generate one, +// matching pydantic, which calls the default_factory on every model_validate +// that omits the key. +// +// # Strict enums +// +// Every enum type here (and in internal/scoring) has a strict UnmarshalJSON: +// an unknown member, a null, or a non-string is a decode error, exactly as +// pydantic raises ValidationError. Verified against the interpreter: +// `RawFinding.model_validate({..., "estimated_severity": "bogus"})` and +// `{"estimated_severity": None}` both raise. +// +// # Known Python bug reproduced, not fixed: ReconResult +// +// `ReconResult.inventory` and `.resource_graph` declare +// `default_factory=ResourceInventory` / `ResourceGraph`, but both of those +// models have a REQUIRED field (`inventory_saved_path` / `graph_saved_path`), so +// the factories raise ValidationError — `ReconResult()` with no arguments is +// unconstructible in Python today. Go cannot raise from a struct literal, so +// NewReconResult() seeds the two sub-models with their own constructors (giving +// `inventory_saved_path: ""`, `iac_type: "terraform"`, …). The observable JSON +// matches `ReconResult(inventory=ResourceInventory(inventory_saved_path=""), +// resource_graph=ResourceGraph(graph_saved_path=""))`, which is what +// scripts/gen_model_keys.py pins. reasoners/phases.py always passes both +// explicitly, so the live path is unaffected. +// +// # Validation +// +// The Python schemas declare no numeric (ge/le/gt/lt) or length constraints and +// no @field_validator / @model_validator, so there is nothing for a Validate() +// method to enforce beyond required-field presence, which the Go type system +// cannot express. No Validate() methods are defined; the strict enum +// UnmarshalJSON is the only runtime validation this package performs. +// +// # Ground truth +// +// testdata/model_keys.json is generated by go/scripts/gen_model_keys.py against +// the venv interpreter and holds, for every model, the exact key list and the +// exact `jsonable_encoder(model_dump())` default vector. parity_test.go asserts +// the Go constructors reproduce both. +package schemas diff --git a/go/internal/schemas/harness_schema_test.go b/go/internal/schemas/harness_schema_test.go new file mode 100644 index 0000000..30c863d --- /dev/null +++ b/go/internal/schemas/harness_schema_test.go @@ -0,0 +1,173 @@ +package schemas + +import ( + "reflect" + "sort" + "strings" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/harnessx" +) + +// TestEmbeddedSchemasMatchGoStructTags closes the TODO left on +// harnessx.TestEmbeddedSchemas_DecodeAndDescribeAnObject: the JSON schema the +// harness SENDS to the model and the Go struct that RECEIVES the reply have to +// describe the same object. +// +// Why it matters, concretely: harnessx.Run[T] passes the committed fixture as +// the `schema=` argument and decodes the reply into T. If the fixture names a +// property the Go struct has no json tag for, the model is told to produce a +// field encoding/json then silently drops — a whole field of the ported +// pydantic model vanishing with no error anywhere. The reverse (a tag with no +// property) means the Go struct expects something the model was never asked +// for, so the field stays at its zero value on every run. +// +// The check runs over the top-level object of every fixture AND over every +// `$defs` sub-model, so a drift in a nested type (RawFinding inside HuntResult, +// AttackStep inside AttackPath, …) fails here too. +// +// This test lives in internal/schemas rather than internal/harnessx because it +// needs the destination types; harnessx has no dependency on this package and +// must not grow one. +func TestEmbeddedSchemasMatchGoStructTags(t *testing.T) { + registry := goDefaults() + + names := harnessx.EmbeddedSchemaNames() + if len(names) == 0 { + t.Fatal("no embedded schema fixtures found") + } + + checked := map[string]bool{} + for _, fixture := range names { + schema, err := harnessx.LoadEmbeddedSchema(fixture) + if err != nil { + t.Fatalf("LoadEmbeddedSchema(%s): %v", fixture, err) + } + + t.Run(fixture, func(t *testing.T) { + assertSchemaMatchesStruct(t, registry, fixture, schema, checked) + + defs, _ := schema["$defs"].(map[string]any) + for defName, raw := range defs { + sub, ok := raw.(map[string]any) + if !ok { + t.Errorf("$defs/%s is %T, want an object", defName, raw) + continue + } + // Enum and scalar $defs describe a value, not a model. + if _, hasProps := sub["properties"]; !hasProps { + continue + } + t.Run("$defs/"+defName, func(t *testing.T) { + assertSchemaMatchesStruct(t, registry, defName, sub, checked) + }) + } + }) + } + + // The fixtures are the harness contract; if one of them stops covering a + // model the port sends, that is worth knowing. + if len(checked) < len(names) { + t.Errorf("cross-checked %d models from %d fixtures — every fixture must contribute at least its own top-level model", len(checked), len(names)) + } +} + +// assertSchemaMatchesStruct compares one JSON-schema object node against the Go +// model registered under the same name. +func assertSchemaMatchesStruct(t *testing.T, registry map[string]any, name string, node map[string]any, checked map[string]bool) { + t.Helper() + + model, ok := registry[name] + if !ok { + t.Fatalf("schema %q has no Go model registered in goDefaults() — every pydantic model the harness sends must have a Go destination", name) + } + checked[name] = true + + props, ok := node["properties"].(map[string]any) + if !ok { + t.Fatalf("schema %q has no properties object", name) + } + want := make([]string, 0, len(props)) + for k := range props { + want = append(want, k) + } + sort.Strings(want) + + got := jsonFieldNames(reflect.TypeOf(model)) + sort.Strings(got) + + if !reflect.DeepEqual(got, want) { + t.Errorf("model %s: json tags and schema properties differ\n go tags: %v\n schema : %v\n only in go: %v\n only in schema: %v", + name, got, want, missing(got, want), missing(want, got)) + } + + // Every `required` name must be a real field. pydantic emits `required` + // only for fields with no default, so an absent key is normal. + tagSet := map[string]bool{} + for _, g := range got { + tagSet[g] = true + } + req, _ := node["required"].([]any) + for _, r := range req { + s, ok := r.(string) + if !ok { + t.Errorf("model %s: required entry %#v is not a string", name, r) + continue + } + if !tagSet[s] { + t.Errorf("model %s: schema requires %q but the Go struct has no such json tag", name, s) + } + } +} + +// jsonFieldNames returns the json names encoding/json would emit for t's +// exported fields, flattening anonymous embedded structs the way it does. +func jsonFieldNames(t reflect.Type) []string { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return nil + } + out := []string{} + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + if !sf.IsExported() { + continue + } + name, _, _ := strings.Cut(sf.Tag.Get("json"), ",") + if name == "-" { + continue + } + if name == "" && sf.Anonymous { + inner := sf.Type + for inner.Kind() == reflect.Pointer { + inner = inner.Elem() + } + if inner.Kind() == reflect.Struct { + out = append(out, jsonFieldNames(inner)...) + continue + } + } + if name == "" { + name = sf.Name + } + out = append(out, name) + } + return out +} + +// missing returns the entries of a that are not in b. +func missing(a, b []string) []string { + set := map[string]bool{} + for _, s := range b { + set[s] = true + } + out := []string{} + for _, s := range a { + if !set[s] { + out = append(out, s) + } + } + return out +} diff --git a/go/internal/schemas/hunt.go b/go/internal/schemas/hunt.go new file mode 100644 index 0000000..2f1b180 --- /dev/null +++ b/go/internal/schemas/hunt.go @@ -0,0 +1,265 @@ +package schemas + +import ( + "encoding/json" + "fmt" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// This file ports src/cloudsecurity_af/schemas/hunt.py — the HUNT phase enums +// and models. + +// Confidence ports hunt.py `class Confidence(str, Enum)`: the confidence level +// for provisional findings. +type Confidence string + +// The three confidence levels, values exactly as Python. +const ( + ConfidenceHigh Confidence = "high" + ConfidenceMedium Confidence = "medium" + ConfidenceLow Confidence = "low" +) + +// AllConfidences lists every Confidence member 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 +} + +// String returns the raw enum value. +func (c Confidence) String() string { return string(c) } + +// ParseConfidence ports `Confidence(value)`: an unknown value is an error. +func ParseConfidence(v string) (Confidence, error) { + c := Confidence(v) + if !c.Valid() { + return "", fmt.Errorf("schemas: %q is not a valid Confidence", v) + } + return c, nil +} + +// UnmarshalJSON is strict, mirroring pydantic (verified: `confidence: "HIGH"` +// raises ValidationError — the enum is case-sensitive). +func (c *Confidence) UnmarshalJSON(b []byte) error { + var raw string + if err := json.Unmarshal(b, &raw); err != nil { + return fmt.Errorf("schemas: Confidence must be a string: %w", err) + } + parsed, err := ParseConfidence(raw) + if err != nil { + return err + } + *c = parsed + return nil +} + +// HunterStrategy ports hunt.py `class HunterStrategy(str, Enum)`: the hunter +// specialization catalog. +// +// Python parity: this enum is a catalog only — RawFinding.hunter_strategy is +// typed `str`, not HunterStrategy, so no field validates against it. +type HunterStrategy string + +// The seven hunter strategies, values exactly as Python. +const ( + HunterStrategyIAM HunterStrategy = "iam" + HunterStrategyNetwork HunterStrategy = "network" + HunterStrategyData HunterStrategy = "data" + HunterStrategySecrets HunterStrategy = "secrets" + HunterStrategyCompute HunterStrategy = "compute" + HunterStrategyLogging HunterStrategy = "logging" + HunterStrategyCompliance HunterStrategy = "compliance" +) + +// AllHunterStrategies lists every HunterStrategy member in Python declaration order. +var AllHunterStrategies = []HunterStrategy{ + HunterStrategyIAM, + HunterStrategyNetwork, + HunterStrategyData, + HunterStrategySecrets, + HunterStrategyCompute, + HunterStrategyLogging, + HunterStrategyCompliance, +} + +// Valid reports whether s is one of the declared members. +func (s HunterStrategy) Valid() bool { + for _, v := range AllHunterStrategies { + if s == v { + return true + } + } + return false +} + +// String returns the raw enum value. +func (s HunterStrategy) String() string { return string(s) } + +// ParseHunterStrategy ports `HunterStrategy(value)`. +func ParseHunterStrategy(v string) (HunterStrategy, error) { + s := HunterStrategy(v) + if !s.Valid() { + return "", fmt.Errorf("schemas: %q is not a valid HunterStrategy", v) + } + return s, nil +} + +// UnmarshalJSON is strict, mirroring pydantic. +func (s *HunterStrategy) UnmarshalJSON(b []byte) error { + var raw string + if err := json.Unmarshal(b, &raw); err != nil { + return fmt.Errorf("schemas: HunterStrategy must be a string: %w", err) + } + parsed, err := ParseHunterStrategy(raw) + if err != nil { + return err + } + *s = parsed + return nil +} + +// FindingCategory ports hunt.py `class FindingCategory(str, Enum)`: the +// high-level finding category. +// +// Python parity: catalog only — RawFinding.category is typed `str`. +type FindingCategory string + +// The thirteen finding categories, values exactly as Python. +const ( + FindingCategoryOverprivilege FindingCategory = "overprivilege" + FindingCategoryPublicExposure FindingCategory = "public_exposure" + FindingCategoryMissingEncryption FindingCategory = "missing_encryption" + FindingCategoryMissingLogging FindingCategory = "missing_logging" + FindingCategoryHardcodedSecret FindingCategory = "hardcoded_secret" + FindingCategoryInsecureDefault FindingCategory = "insecure_default" + FindingCategoryMissingMFA FindingCategory = "missing_mfa" + FindingCategoryDriftIntroduced FindingCategory = "drift_introduced" + FindingCategoryComplianceGap FindingCategory = "compliance_gap" + FindingCategoryDangerousTrust FindingCategory = "dangerous_trust" + FindingCategoryMissingBackup FindingCategory = "missing_backup" + FindingCategoryPrivilegedContainer FindingCategory = "privileged_container" + FindingCategoryOutdatedRuntime FindingCategory = "outdated_runtime" +) + +// AllFindingCategories lists every FindingCategory member in Python declaration order. +var AllFindingCategories = []FindingCategory{ + FindingCategoryOverprivilege, + FindingCategoryPublicExposure, + FindingCategoryMissingEncryption, + FindingCategoryMissingLogging, + FindingCategoryHardcodedSecret, + FindingCategoryInsecureDefault, + FindingCategoryMissingMFA, + FindingCategoryDriftIntroduced, + FindingCategoryComplianceGap, + FindingCategoryDangerousTrust, + FindingCategoryMissingBackup, + FindingCategoryPrivilegedContainer, + FindingCategoryOutdatedRuntime, +} + +// Valid reports whether c is one of the declared members. +func (c FindingCategory) Valid() bool { + for _, v := range AllFindingCategories { + if c == v { + return true + } + } + return false +} + +// String returns the raw enum value. +func (c FindingCategory) String() string { return string(c) } + +// ParseFindingCategory ports `FindingCategory(value)`. +func ParseFindingCategory(v string) (FindingCategory, error) { + c := FindingCategory(v) + if !c.Valid() { + return "", fmt.Errorf("schemas: %q is not a valid FindingCategory", v) + } + return c, nil +} + +// UnmarshalJSON is strict, mirroring pydantic. +func (c *FindingCategory) UnmarshalJSON(b []byte) error { + var raw string + if err := json.Unmarshal(b, &raw); err != nil { + return fmt.Errorf("schemas: FindingCategory must be a string: %w", err) + } + parsed, err := ParseFindingCategory(raw) + if err != nil { + return err + } + *c = parsed + return nil +} + +// AffectedResource ports hunt.py AffectedResource: a specific resource attribute +// that is misconfigured. +type AffectedResource struct { + ResourceID string `json:"resource_id"` + ResourceType string `json:"resource_type"` + // Attribute is the specific attribute that is misconfigured. + Attribute string `json:"attribute"` + CurrentValue string `json:"current_value"` + RecommendedValue string `json:"recommended_value"` +} + +// RawFinding ports hunt.py RawFinding: a potential misconfiguration or policy +// violation from a hunter. +type RawFinding struct { + ID string `json:"id"` + // HunterStrategy is one of iam | network | data | secrets | compute | + // logging | compliance. Python types it `str`, not HunterStrategy. + HunterStrategy string `json:"hunter_strategy"` + Title string `json:"title"` + Description string `json:"description"` + // Category is a finding category from the FindingCategory enum. Python + // types it `str`, not FindingCategory. + Category string `json:"category"` + Resources []AffectedResource `json:"resources"` + EstimatedSeverity scoring.Severity `json:"estimated_severity"` + Confidence Confidence `json:"confidence"` + IaCFile string `json:"iac_file"` + IaCLine int `json:"iac_line"` + ConfigSnippet string `json:"config_snippet"` + // BenchmarkID is a CIS control ID, SOC2 control, etc. + BenchmarkID *string `json:"benchmark_id"` + Fingerprint string `json:"fingerprint"` +} + +// ForDedup ports RawFinding.for_dedup(): project the minimal fields needed for +// deduplication. +// +// Python parity: `estimated_severity=self.estimated_severity.value` — the view's +// field is a plain str, so the enum is unwrapped to its value. +func (f RawFinding) ForDedup() FindingForDedup { + return FindingForDedup{ + ID: f.ID, + Fingerprint: f.Fingerprint, + Title: f.Title, + IaCFile: f.IaCFile, + IaCLine: f.IaCLine, + Category: f.Category, + HunterStrategy: f.HunterStrategy, + EstimatedSeverity: f.EstimatedSeverity.String(), + } +} + +// HuntResult ports hunt.py HuntResult: the deduplicated and correlated HUNT +// phase output. +type HuntResult struct { + Findings []RawFinding `json:"findings"` + TotalRaw int `json:"total_raw"` + DeduplicatedCount int `json:"deduplicated_count"` + StrategiesRun []string `json:"strategies_run"` + HuntDurationSeconds float64 `json:"hunt_duration_seconds"` +} diff --git a/go/internal/schemas/input.go b/go/internal/schemas/input.go new file mode 100644 index 0000000..2b89d83 --- /dev/null +++ b/go/internal/schemas/input.go @@ -0,0 +1,78 @@ +package schemas + +// This file ports src/cloudsecurity_af/schemas/input.py — the REST API input +// contract for cloudsecurity.scan and cloudsecurity.prove. + +// CloudConfig ports input.py CloudConfig: cloud provider credentials and +// targeting configuration. +// +// Credentials are resolved from environment variables (AWS_ACCESS_KEY_ID, etc.), +// never passed in the API payload. +type CloudConfig struct { + // Provider is one of aws | gcp | azure. + Provider string `json:"provider"` + // Regions are the cloud regions to scan. + Regions []string `json:"regions"` + // AccountID is the cloud account/project ID (optional, auto-detected if omitted). + AccountID *string `json:"account_id"` + // AssumeRoleARN is the AWS IAM role ARN to assume for scanning (OIDC-compatible). + AssumeRoleARN *string `json:"assume_role_arn"` +} + +// CloudSecurityInput ports input.py CloudSecurityInput: the top-level input for +// the cloudsecurity.scan and cloudsecurity.prove skills. +type CloudSecurityInput struct { + // RepoURL is a git repository URL or local path containing IaC files. + // Python parity: required (`Field(...)`). + RepoURL string `json:"repo_url"` + // Branch to scan. + Branch string `json:"branch"` + // CommitSHA is a specific commit SHA to scan. + CommitSHA *string `json:"commit_sha"` + // BaseCommitSHA is the base commit SHA 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"` + OutputFormats []string `json:"output_formats"` + // ComplianceFrameworks to check: cis_aws | soc2 | hipaa | pci_dss. + ComplianceFrameworks []string `json:"compliance_frameworks"` + + // Cloud configuration (Tier 2+ — omit for static-only scans). + Cloud *CloudConfig `json:"cloud"` + + // Budget controls. + MaxCostUSD *float64 `json:"max_cost_usd"` + MaxDurationSeconds *int `json:"max_duration_seconds"` + MaxConcurrentHunters *int `json:"max_concurrent_hunters"` + MaxConcurrentProvers *int `json:"max_concurrent_provers"` + + // Path filtering. + // + // Python parity: include_paths is `list[str] | None = None`. A nil Go slice + // already marshals as `null`, so no extra pointer indirection is needed — + // nil means None, non-nil (including empty) means an explicit list. + IncludePaths []string `json:"include_paths"` + ExcludePaths []string `json:"exclude_paths"` + + // CI/CD integration. + IsPR bool `json:"is_pr"` + // PRID is the pull request identifier. + PRID *string `json:"pr_id"` + // FailOnFindings returns a non-zero exit status for CI gating. + FailOnFindings bool `json:"fail_on_findings"` +} + +// Tier ports the CloudSecurityInput.tier property: 1 when no cloud config is +// supplied (static-only), 2 otherwise. +// +// Python parity: it is a plain @property, NOT a pydantic field, so it does not +// appear in model_dump() and must not carry a json tag. +func (in CloudSecurityInput) Tier() int { + if in.Cloud == nil { + return 1 + } + return 2 +} diff --git a/go/internal/schemas/model.go b/go/internal/schemas/model.go new file mode 100644 index 0000000..078d526 --- /dev/null +++ b/go/internal/schemas/model.go @@ -0,0 +1,138 @@ +package schemas + +// model.go declares which of this package's structs are ports of a pydantic +// BaseModel. +// +// afx.Bind stands in for `Model.model_validate(payload)`, and pydantic's +// validation differs from a JSON round-trip in ways that only make sense for a +// MODEL: it coerces scalars in lax mode ("12" is a valid int) and it rejects an +// explicit `null` for a field that is not `X | None`. afx applies those rules +// to a struct that declares PydanticModel and to everything reachable from one +// — never to the reasoner INPUT structs, whose Python counterpart is a function +// signature validated by `Agent._validate_handler_input` (ported separately in +// afx/handlerinput.go, with a different and deliberately looser ladder). +// +// The list is exhaustive over the models in this package; model_test.go fails +// if a new exported model struct is added without a line here. Timestamp is +// NOT a model — it is the `datetime` scalar wrapper, an opaque leaf with its +// own UnmarshalJSON. + +// --- recon.py --------------------------------------------------------------- + +// PydanticModel marks Variable as a ported pydantic model. +func (Variable) PydanticModel() {} + +// PydanticModel marks Output as a ported pydantic model. +func (Output) PydanticModel() {} + +// PydanticModel marks ProviderConfig as a ported pydantic model. +func (ProviderConfig) PydanticModel() {} + +// PydanticModel marks Module as a ported pydantic model. +func (Module) PydanticModel() {} + +// PydanticModel marks Resource as a ported pydantic model. +func (Resource) PydanticModel() {} + +// PydanticModel marks ResourceInventory as a ported pydantic model. +func (ResourceInventory) PydanticModel() {} + +// PydanticModel marks ResourceGraph as a ported pydantic model. +func (ResourceGraph) PydanticModel() {} + +// PydanticModel marks ConfigDiff as a ported pydantic model. +func (ConfigDiff) PydanticModel() {} + +// PydanticModel marks DriftedResource as a ported pydantic model. +func (DriftedResource) PydanticModel() {} + +// PydanticModel marks DriftReport as a ported pydantic model. +func (DriftReport) PydanticModel() {} + +// PydanticModel marks ReconResult as a ported pydantic model. +func (ReconResult) PydanticModel() {} + +// --- hunt.py ---------------------------------------------------------------- + +// PydanticModel marks AffectedResource as a ported pydantic model. +func (AffectedResource) PydanticModel() {} + +// PydanticModel marks RawFinding as a ported pydantic model. +func (RawFinding) PydanticModel() {} + +// PydanticModel marks HuntResult as a ported pydantic model. +func (HuntResult) PydanticModel() {} + +// --- chain.py --------------------------------------------------------------- + +// PydanticModel marks AttackStep as a ported pydantic model. +func (AttackStep) PydanticModel() {} + +// PydanticModel marks BlastRadius as a ported pydantic model. +func (BlastRadius) PydanticModel() {} + +// PydanticModel marks AttackPath as a ported pydantic model. +func (AttackPath) PydanticModel() {} + +// PydanticModel marks ChainResult as a ported pydantic model. +func (ChainResult) PydanticModel() {} + +// --- prove.py --------------------------------------------------------------- + +// PydanticModel marks Proof as a ported pydantic model. +func (Proof) PydanticModel() {} + +// PydanticModel marks IaCDiff as a ported pydantic model. +func (IaCDiff) PydanticModel() {} + +// PydanticModel marks RemediationSuggestion as a ported pydantic model. +func (RemediationSuggestion) PydanticModel() {} + +// PydanticModel marks VerifiedFinding as a ported pydantic model. +func (VerifiedFinding) PydanticModel() {} + +// --- input.py --------------------------------------------------------------- + +// PydanticModel marks CloudConfig as a ported pydantic model. +func (CloudConfig) PydanticModel() {} + +// PydanticModel marks CloudSecurityInput as a ported pydantic model. +func (CloudSecurityInput) PydanticModel() {} + +// NullableFields is CloudSecurityInput's `include_paths`, the port's ONE +// `X | None` field that is not a Go pointer: Python declares +// `include_paths: list[str] | None = None` (input.py:73) and treats None and [] +// identically everywhere it reads it, so the port keeps a plain []string. +// pydantic accepts `{"include_paths": null}`; without this declaration the null +// rule would reject it. +func (CloudSecurityInput) NullableFields() []string { return []string{"include_paths"} } + +// --- output.py -------------------------------------------------------------- + +// PydanticModel marks CloudSecurityScanResult as a ported pydantic model. +func (CloudSecurityScanResult) PydanticModel() {} + +// PydanticModel marks ScanProgress as a ported pydantic model. +func (ScanProgress) PydanticModel() {} + +// PydanticModel marks ScanMetrics as a ported pydantic model. +func (ScanMetrics) PydanticModel() {} + +// --- views.py --------------------------------------------------------------- + +// PydanticModel marks FindingForDedup as a ported pydantic model. +func (FindingForDedup) PydanticModel() {} + +// PydanticModel marks FindingForProver as a ported pydantic model. +func (FindingForProver) PydanticModel() {} + +// PydanticModel marks FindingForChain as a ported pydantic model. +func (FindingForChain) PydanticModel() {} + +// --- agents/chain/path_constructor.py --------------------------------------- + +// PydanticModel marks ChildInvestigation as a ported pydantic model. +func (ChildInvestigation) PydanticModel() {} + +// PydanticModel marks PathInvestigationPlan as a ported pydantic model. +func (PathInvestigationPlan) PydanticModel() {} diff --git a/go/internal/schemas/model_test.go b/go/internal/schemas/model_test.go new file mode 100644 index 0000000..757cf66 --- /dev/null +++ b/go/internal/schemas/model_test.go @@ -0,0 +1,108 @@ +package schemas + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/afx" +) + +// declaredModels is every struct in this package that ports a pydantic +// BaseModel. afx.Bind applies pydantic's lax scalar coercion and its +// null-rejection to these and to nothing else, so the list IS the contract. +var declaredModels = []any{ + // recon.py + Variable{}, Output{}, ProviderConfig{}, Module{}, Resource{}, + ResourceInventory{}, ResourceGraph{}, ConfigDiff{}, DriftedResource{}, + DriftReport{}, ReconResult{}, + // hunt.py + AffectedResource{}, RawFinding{}, HuntResult{}, + // chain.py + AttackStep{}, BlastRadius{}, AttackPath{}, ChainResult{}, + // prove.py + Proof{}, IaCDiff{}, RemediationSuggestion{}, VerifiedFinding{}, + // input.py + CloudConfig{}, CloudSecurityInput{}, + // output.py + CloudSecurityScanResult{}, ScanProgress{}, ScanMetrics{}, + // views.py + FindingForDedup{}, FindingForProver{}, FindingForChain{}, + // agents/chain/path_constructor.py + ChildInvestigation{}, PathInvestigationPlan{}, +} + +// notAModel is every exported struct here that is deliberately NOT a pydantic +// model. Timestamp is the `datetime` scalar wrapper — an opaque leaf with its +// own UnmarshalJSON, not a BaseModel. +var notAModel = map[string]bool{"Timestamp": true} + +func TestPydanticModel_EveryDeclaredModelIsMarked(t *testing.T) { + for _, m := range declaredModels { + if _, ok := m.(afx.PydanticModel); !ok { + t.Errorf("%T does not implement afx.PydanticModel; add it to model.go", m) + } + } +} + +// COVERAGE GUARD: a new exported model struct added to this package without a +// PydanticModel() line in model.go would silently bind with encoding/json's +// rules instead of pydantic's. Parsing the package sources is the only way to +// see a type Go reflection cannot enumerate. +func TestPydanticModel_NoExportedModelStructIsUnmarked(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + fset := token.NewFileSet() + found := map[string]bool{} + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + file, err := parser.ParseFile(fset, filepath.Join(".", name), nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + ast.Inspect(file, func(n ast.Node) bool { + ts, ok := n.(*ast.TypeSpec) + if !ok || !ts.Name.IsExported() { + return true + } + if _, isStruct := ts.Type.(*ast.StructType); isStruct { + found[ts.Name.Name] = true + } + return true + }) + } + + declared := map[string]bool{} + for _, m := range declaredModels { + declared[reflect.TypeOf(m).Name()] = true + } + var unmarked []string + for name := range found { + if !declared[name] && !notAModel[name] { + unmarked = append(unmarked, name) + } + } + sort.Strings(unmarked) + if len(unmarked) > 0 { + t.Fatalf("exported struct(s) %v are neither declared models nor listed in notAModel:\n"+ + "add a PydanticModel() line in model.go (and a row in declaredModels), or\n"+ + "record why the type is not a pydantic model", unmarked) + } + // The reverse direction: a model that was deleted must leave the list. + for name := range declared { + if !found[name] { + t.Errorf("declaredModels names %s, which this package no longer declares", name) + } + } +} diff --git a/go/internal/schemas/output.go b/go/internal/schemas/output.go new file mode 100644 index 0000000..dfe4fe6 --- /dev/null +++ b/go/internal/schemas/output.go @@ -0,0 +1,134 @@ +package schemas + +import "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" + +// This file ports src/cloudsecurity_af/schemas/output.py — the scan output and +// orchestration progress/metrics models. + +// CloudSecurityScanResult ports output.py CloudSecurityScanResult: the top-level +// CloudSecurity scan output. +type CloudSecurityScanResult struct { + Repository string `json:"repository"` + CommitSHA string `json:"commit_sha"` + Branch *string `json:"branch"` + // Timestamp is a pydantic `datetime` — see timestamp.go for the exact wire + // format (`datetime.isoformat()` via FastAPI's jsonable_encoder). + Timestamp Timestamp `json:"timestamp"` + DepthProfile string `json:"depth_profile"` + // Tier is 1 = static, 2 = live, 3 = deep. + Tier int `json:"tier"` + ProvidersDetected []string `json:"providers_detected"` + + // Findings. + Findings []VerifiedFinding `json:"findings"` + AttackPaths []AttackPath `json:"attack_paths"` + + // Counts. + TotalResourcesScanned int `json:"total_resources_scanned"` + 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"` + + // Drift (Tier 2+). + DriftResources int `json:"drift_resources"` + ShadowITResources int `json:"shadow_it_resources"` + + // Compliance. + ComplianceFrameworksChecked []string `json:"compliance_frameworks_checked"` + ComplianceGaps []string `json:"compliance_gaps"` + + // Strategies. + StrategiesUsed []string `json:"strategies_used"` + + // Performance. + DurationSeconds float64 `json:"duration_seconds"` + AgentInvocations int `json:"agent_invocations"` + CostUSD float64 `json:"cost_usd"` + CostBreakdown map[string]float64 `json:"cost_breakdown"` + + // Metadata. + Metadata map[string]any `json:"metadata"` + SARIF string `json:"sarif"` +} + +// DictFieldOrder implements afx.DictFieldOrder: it names the two map-typed +// fields whose Python dict insertion order is fixed by orchestrator.py's +// seeding, so the `scan` / `prove` reply keeps Python's key order instead of +// the alphabetical order a Go map renders with. +// +// Python (src/cloudsecurity_af/app.py:234 `return result.model_dump()`) puts +// +// "by_severity": {"critical": 1, "high": 0, "medium": 0, "low": 0, "info": 0} +// "cost_breakdown": {"recon": 0.0, "hunt": 0.0, "chain": 0.0, "prove": 0.0, "remediate": 0.0} +// +// on the wire; sorting them instead yields critical/high/info/low/medium and +// chain/hunt/prove/recon/remediate. +func (CloudSecurityScanResult) DictFieldOrder() map[string][]string { + return map[string][]string{ + "by_severity": BySeverityOrder(), + "cost_breakdown": CostBreakdownOrder, + } +} + +// ScanProgress ports output.py ScanProgress: an orchestrator phase progress +// event. +// +// Python parity: every field is required (no defaults), and orchestrator.py +// builds a ScanProgress in _emit_progress but never emits it as a note — the +// Go orch port keeps that a no-op builder too. +type ScanProgress 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"` +} + +// ScanMetrics ports output.py ScanMetrics: run-level performance and budget +// metrics. +type ScanMetrics 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"` +} + +// CostBreakdownOrder is the INSERTION order of the `cost_breakdown` dict. +// +// src/cloudsecurity_af/orchestrator.py:54,67: +// +// _PHASE_ORDER = ("recon", "hunt", "chain", "prove", "remediate") +// self.cost_breakdown = {phase: 0.0 for phase in self._PHASE_ORDER} +// +// The dict is seeded from that tuple and never gains a key on the live path +// (_register_cost only mutates existing entries), so a Python dict — and every +// artifact that walks it with `.items()` — always reads recon, hunt, chain, +// prove, remediate. A Go map has no order, so every renderer of this field +// takes it from here instead of sorting. +var CostBreakdownOrder = []string{"recon", "hunt", "chain", "prove", "remediate"} + +// BySeverityOrder is the INSERTION order of the `by_severity` dict. +// +// src/cloudsecurity_af/orchestrator.py:165: +// +// severity_counts = {s.value: 0 for s in Severity} +// +// i.e. the Severity enum's declaration order — critical, high, medium, low, +// info — never the alphabetical critical, high, info, low, medium. +func BySeverityOrder() []string { + out := make([]string, 0, len(scoring.AllSeverities)) + for _, s := range scoring.AllSeverities { + out = append(out, s.String()) + } + return out +} diff --git a/go/internal/schemas/parity_test.go b/go/internal/schemas/parity_test.go new file mode 100644 index 0000000..17be9c4 --- /dev/null +++ b/go/internal/schemas/parity_test.go @@ -0,0 +1,251 @@ +package schemas + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "regexp" + "sort" + "testing" + "time" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// This file is the exhaustive model-parity test the port contract requires: +// for EVERY pydantic model, the Go constructor's marshaled JSON must have the +// same key set AND the same default values as +// `Model(**minimal_required).model_dump()` in Python. +// +// The ground truth lives in testdata/model_keys.json, generated by +// go/scripts/gen_model_keys.py against +// ~/.agentfield/packages/cloudsecurity-af/venv/bin/python. Regenerate it +// whenever a Python schema changes; a Go/Python drift fails here first. + +// pythonModel is one entry of testdata/model_keys.json. +type pythonModel struct { + Module string `json:"module"` + Keys []string `json:"keys"` + Dump json.RawMessage `json:"dump"` + Nondeterministic []string `json:"nondeterministic"` +} + +// fixedTimestamp mirrors gen_model_keys.py FIXED_TS — the timestamp the fixture +// pins for CloudSecurityScanResult, so that key can be compared by value. +var fixedTimestamp = NewTimestamp(time.Date(2026, 1, 2, 3, 4, 5, 123456000, time.UTC)) + +// goDefaults returns, for every model name in the fixture, the Go value that +// must reproduce the Python default vector. +// +// The two entries that pass extra arguments do so because gen_model_keys.py +// passes the same ones: VerifiedFinding's verdict/severity are REQUIRED with no +// pydantic default, and CloudSecurityScanResult's timestamp likewise. +func goDefaults() map[string]any { + verified := NewVerifiedFinding() + verified.Verdict = VerdictConfirmed + verified.Severity = scoring.SeverityMedium + + scanResult := NewCloudSecurityScanResult() + scanResult.Timestamp = fixedTimestamp + + return map[string]any{ + // schemas/recon.py + "Variable": NewVariable(), + "Output": NewOutput(), + "ProviderConfig": NewProviderConfig(), + "Module": NewModule(), + "Resource": NewResource(), + "ResourceInventory": NewResourceInventory(), + "ResourceGraph": NewResourceGraph(), + "ConfigDiff": NewConfigDiff(), + "DriftedResource": NewDriftedResource(), + "DriftReport": NewDriftReport(), + "ReconResult": NewReconResult(), + // schemas/hunt.py + "AffectedResource": NewAffectedResource(), + "RawFinding": NewRawFinding(), + "HuntResult": NewHuntResult(), + // schemas/chain.py + "AttackStep": NewAttackStep(), + "BlastRadius": NewBlastRadius(), + "AttackPath": NewAttackPath(), + "ChainResult": NewChainResult(), + // schemas/prove.py + "Proof": NewProof(), + "IaCDiff": NewIaCDiff(), + "RemediationSuggestion": NewRemediationSuggestion(), + "VerifiedFinding": verified, + // schemas/input.py + "CloudConfig": NewCloudConfig(), + "CloudSecurityInput": NewCloudSecurityInput(), + // schemas/output.py + "CloudSecurityScanResult": scanResult, + "ScanProgress": NewScanProgress(), + "ScanMetrics": NewScanMetrics(), + // schemas/views.py + "FindingForDedup": NewFindingForDedup(), + "FindingForProver": NewFindingForProver(), + "FindingForChain": NewFindingForChain(), + // agents/chain/path_constructor.py + "ChildInvestigation": NewChildInvestigation(), + "PathInvestigationPlan": NewPathInvestigationPlan(), + } +} + +func loadPythonModels(t *testing.T) map[string]pythonModel { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", "model_keys.json")) + if err != nil { + t.Fatalf("read testdata/model_keys.json: %v (regenerate with scripts/gen_model_keys.py)", err) + } + var models map[string]pythonModel + if err := json.Unmarshal(raw, &models); err != nil { + t.Fatalf("decode testdata/model_keys.json: %v", err) + } + return models +} + +// TestModelCoverage guards that the Go side has an entry for every pydantic +// model and no stale extras. It is the test that fails when someone adds a +// model to Python (or to this package) and forgets the other side. +func TestModelCoverage(t *testing.T) { + python := loadPythonModels(t) + golang := goDefaults() + + for name := range python { + if _, ok := golang[name]; !ok { + t.Errorf("pydantic model %q (%s) has no Go constructor registered in goDefaults()", name, python[name].Module) + } + } + for name := range golang { + if _, ok := python[name]; !ok { + t.Errorf("Go model %q is not in testdata/model_keys.json — add it to scripts/gen_model_keys.py MODELS", name) + } + } + if len(python) != 32 { + t.Errorf("fixture holds %d models, expected 32 (7 schemas/*.py modules + path_constructor.py)", len(python)) + } +} + +// TestModelKeySets asserts the marshaled key SET of every Go constructor equals +// Python's model_dump().keys() exactly. This is what enforces "no omitempty +// anywhere": a single omitempty tag would drop a zero-valued key and fail here. +func TestModelKeySets(t *testing.T) { + python := loadPythonModels(t) + for name, value := range goDefaults() { + want, ok := python[name] + if !ok { + continue // reported by TestModelCoverage + } + t.Run(name, func(t *testing.T) { + raw, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got map[string]json.RawMessage + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("re-decode: %v", err) + } + gotKeys := make([]string, 0, len(got)) + for k := range got { + gotKeys = append(gotKeys, k) + } + wantKeys := append([]string(nil), want.Keys...) + sort.Strings(gotKeys) + sort.Strings(wantKeys) + if !reflect.DeepEqual(gotKeys, wantKeys) { + t.Errorf("json key set mismatch\n go: %v\n python: %v", gotKeys, wantKeys) + } + }) + } +} + +// TestModelDefaultValues asserts every default VALUE matches Python's, key by +// key. This is what pins "terraform", ["us-east-1"], "moderate", "medium", +// "static", Severity.HIGH, the empty-slice-vs-null rule, and the empty maps. +func TestModelDefaultValues(t *testing.T) { + python := loadPythonModels(t) + for name, value := range goDefaults() { + want, ok := python[name] + if !ok { + continue + } + t.Run(name, func(t *testing.T) { + gotRaw, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var gotMap, wantMap map[string]any + if err := json.Unmarshal(gotRaw, &gotMap); err != nil { + t.Fatalf("decode go json: %v", err) + } + if err := json.Unmarshal(want.Dump, &wantMap); err != nil { + t.Fatalf("decode python dump: %v", err) + } + skip := make(map[string]bool, len(want.Nondeterministic)) + for _, k := range want.Nondeterministic { + skip[k] = true + } + for key, wantVal := range wantMap { + if skip[key] { + continue + } + gotVal, present := gotMap[key] + if !present { + t.Errorf("key %q missing from Go output", key) + continue + } + if !reflect.DeepEqual(gotVal, wantVal) { + gj, _ := json.Marshal(gotVal) + wj, _ := json.Marshal(wantVal) + t.Errorf("key %q: go=%s python=%s", key, gj, wj) + } + } + }) + } +} + +var uuid4Pattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + +// TestUUIDDefaultedFields checks that every field the fixture marks +// nondeterministic is seeded with a well-formed, unique RFC 4122 v4 string — +// the Go equivalent of `default_factory=lambda: str(uuid4())`. +func TestUUIDDefaultedFields(t *testing.T) { + python := loadPythonModels(t) + seen := map[string]bool{} + total := 0 + for name, value := range goDefaults() { + want, ok := python[name] + if !ok || len(want.Nondeterministic) == 0 { + continue + } + raw, err := json.Marshal(value) + if err != nil { + t.Fatalf("%s: marshal: %v", name, err) + } + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("%s: decode: %v", name, err) + } + for _, key := range want.Nondeterministic { + s, isStr := got[key].(string) + if !isStr { + t.Errorf("%s.%s = %v, want a uuid4 string", name, key, got[key]) + continue + } + if !uuid4Pattern.MatchString(s) { + t.Errorf("%s.%s = %q, not an RFC 4122 v4 uuid", name, key, s) + } + if seen[s] { + t.Errorf("%s.%s = %q collides with another defaulted field", name, key, s) + } + seen[s] = true + total++ + } + } + // AttackPath.id, RawFinding.{id,fingerprint}, VerifiedFinding.{id,fingerprint} + if total != 5 { + t.Errorf("checked %d uuid-defaulted fields, expected 5", total) + } +} diff --git a/go/internal/schemas/pathplan.go b/go/internal/schemas/pathplan.go new file mode 100644 index 0000000..9eaf823 --- /dev/null +++ b/go/internal/schemas/pathplan.go @@ -0,0 +1,36 @@ +package schemas + +// This file ports the two pydantic BaseModels that +// src/cloudsecurity_af/agents/chain/path_constructor.py declares OUTSIDE the +// schemas/ package. +// +// They live here rather than in internal/agents/chain because they cross a JSON +// boundary: PathInvestigationPlan is the `schema=` argument of the CHAIN parent +// harness call (`await app.harness(prompt=parent_prompt, +// schema=PathInvestigationPlan, cwd=harness_cwd)`), so harnessx resolves its +// embedded pydantic schema fixture by the Go type NAME +// (testdata/schemas/PathInvestigationPlan.json) — the cross-agent contract in +// the shared preamble. The Go class names match Python exactly. +// +// NOTE for the internal/agents/chain owner: import these from internal/schemas; +// do not redeclare them. +// +// The three BaseModels in src/cloudsecurity_af/config.py (BudgetConfig, +// ScanConfig, AIIntegrationConfig) are deliberately NOT here — design §3 maps +// config.py to internal/config, and none of them is ever serialized across a +// reasoner boundary. + +// ChildInvestigation ports path_constructor.py ChildInvestigation: one +// meta-prompted child investigation the parent CHAIN pass plans. +type ChildInvestigation struct { + Title string `json:"title"` + Rationale string `json:"rationale"` + FindingsInvolved []string `json:"findings_involved"` + ChildPrompt string `json:"child_prompt"` +} + +// PathInvestigationPlan ports path_constructor.py PathInvestigationPlan: the +// parent harness call's structured output. +type PathInvestigationPlan struct { + Investigations []ChildInvestigation `json:"investigations"` +} diff --git a/go/internal/schemas/prove.go b/go/internal/schemas/prove.go new file mode 100644 index 0000000..be59f08 --- /dev/null +++ b/go/internal/schemas/prove.go @@ -0,0 +1,183 @@ +package schemas + +import ( + "encoding/json" + "fmt" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// This file ports src/cloudsecurity_af/schemas/prove.py — the PROVE phase enums +// and models. + +// Verdict ports prove.py `class Verdict(str, Enum)`: exploitability verdict +// semantics. +type Verdict string + +// The four verdicts, values exactly as Python. +const ( + VerdictConfirmed Verdict = "confirmed" + VerdictLikely Verdict = "likely" + VerdictInconclusive Verdict = "inconclusive" + VerdictNotExploitable Verdict = "not_exploitable" +) + +// AllVerdicts lists every Verdict member 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 +} + +// String returns the raw enum value. +func (v Verdict) String() string { return string(v) } + +// ParseVerdict ports `Verdict(value)`. +func ParseVerdict(v string) (Verdict, error) { + m := Verdict(v) + if !m.Valid() { + return "", fmt.Errorf("schemas: %q is not a valid Verdict", v) + } + return m, nil +} + +// UnmarshalJSON is strict, mirroring pydantic. +func (v *Verdict) UnmarshalJSON(b []byte) error { + var raw string + if err := json.Unmarshal(b, &raw); err != nil { + return fmt.Errorf("schemas: Verdict must be a string: %w", err) + } + parsed, err := ParseVerdict(raw) + if err != nil { + return err + } + *v = parsed + return nil +} + +// ProofMethod ports prove.py `class ProofMethod(str, Enum)`: the verification +// method used to reach the verdict. +type ProofMethod string + +// The four proof methods, values exactly as Python. +const ( + ProofMethodStaticAnalysis ProofMethod = "static_analysis" + ProofMethodLiveAPIVerification ProofMethod = "live_api_verification" + ProofMethodIAMSimulation ProofMethod = "iam_simulation" + ProofMethodDriftComparison ProofMethod = "drift_comparison" +) + +// AllProofMethods lists every ProofMethod member in Python declaration order. +var AllProofMethods = []ProofMethod{ + ProofMethodStaticAnalysis, + ProofMethodLiveAPIVerification, + ProofMethodIAMSimulation, + ProofMethodDriftComparison, +} + +// Valid reports whether m is one of the declared members. +func (m ProofMethod) Valid() bool { + for _, v := range AllProofMethods { + if m == v { + return true + } + } + return false +} + +// String returns the raw enum value. +func (m ProofMethod) String() string { return string(m) } + +// ParseProofMethod ports `ProofMethod(value)`. +func ParseProofMethod(v string) (ProofMethod, error) { + m := ProofMethod(v) + if !m.Valid() { + return "", fmt.Errorf("schemas: %q is not a valid ProofMethod", v) + } + return m, nil +} + +// UnmarshalJSON is strict, mirroring pydantic. +func (m *ProofMethod) UnmarshalJSON(b []byte) error { + var raw string + if err := json.Unmarshal(b, &raw); err != nil { + return fmt.Errorf("schemas: ProofMethod must be a string: %w", err) + } + parsed, err := ParseProofMethod(raw) + if err != nil { + return err + } + *m = parsed + return nil +} + +// Proof ports prove.py Proof: the evidence supporting a verdict. +type Proof struct { + Method ProofMethod `json:"method"` + Evidence []string `json:"evidence"` + // ScriptsExecuted holds the actual commands/scripts the harness ran. + ScriptsExecuted []string `json:"scripts_executed"` + // VerificationTier is one of static | live. + VerificationTier string `json:"verification_tier"` +} + +// IaCDiff ports prove.py IaCDiff: a unified diff patch for remediation. +type IaCDiff struct { + FilePath string `json:"file_path"` + OriginalLines string `json:"original_lines"` + PatchedLines string `json:"patched_lines"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` +} + +// RemediationSuggestion ports prove.py RemediationSuggestion: an actionable IaC +// fix for a finding. +type RemediationSuggestion struct { + FindingID string `json:"finding_id"` + Description string `json:"description"` + Diffs []IaCDiff `json:"diffs"` + BreakingChange bool `json:"breaking_change"` + // DowntimeEstimate is one of none | seconds | minutes | + // requires_maintenance_window. + DowntimeEstimate *string `json:"downtime_estimate"` + // Effort is one of trivial | moderate | significant. + Effort string `json:"effort"` + AlternativeApproaches []string `json:"alternative_approaches"` +} + +// VerifiedFinding ports prove.py VerifiedFinding: a finding fully assessed by +// the PROVE phase. +type VerifiedFinding struct { + ID string `json:"id"` + Title string `json:"title"` + Verdict Verdict `json:"verdict"` + Severity scoring.Severity `json:"severity"` + Category string `json:"category"` + Resources []AffectedResource `json:"resources"` + AttackPath *AttackPath `json:"attack_path"` + Drift *DriftedResource `json:"drift"` + Proof Proof `json:"proof"` + // ComplianceMappings holds CIS control IDs, SOC2 controls, etc. + ComplianceMappings []string `json:"compliance_mappings"` + RiskScore float64 `json:"risk_score"` + Remediation *RemediationSuggestion `json:"remediation"` + + // SARIF integration. + SARIFRuleID string `json:"sarif_rule_id"` + SARIFSecuritySeverity float64 `json:"sarif_security_severity"` + + // Traceability. + IaCFile string `json:"iac_file"` + IaCLine int `json:"iac_line"` + ConfigSnippet string `json:"config_snippet"` + Description string `json:"description"` + Fingerprint string `json:"fingerprint"` + HunterStrategy string `json:"hunter_strategy"` + DropReason *string `json:"drop_reason"` +} diff --git a/go/internal/schemas/recon.go b/go/internal/schemas/recon.go new file mode 100644 index 0000000..cfc6a28 --- /dev/null +++ b/go/internal/schemas/recon.go @@ -0,0 +1,153 @@ +package schemas + +// This file ports src/cloudsecurity_af/schemas/recon.py — the RECON phase +// schemas (IaC reader output, resource-graph pointer, drift report, and the +// aggregated ReconResult). + +// --------------------------------------------------------------------------- +// IaC Reader output +// --------------------------------------------------------------------------- + +// Variable ports recon.py Variable: a Terraform variable or CloudFormation +// parameter. +type Variable struct { + Name string `json:"name"` + Type *string `json:"type"` + Default *string `json:"default"` + Description *string `json:"description"` + FilePath *string `json:"file_path"` +} + +// Output ports recon.py Output: a Terraform or CloudFormation output. +type Output struct { + Name string `json:"name"` + Value *string `json:"value"` + Description *string `json:"description"` + FilePath *string `json:"file_path"` +} + +// ProviderConfig ports recon.py ProviderConfig: a cloud provider block +// (e.g. aws, google, azurerm). +type ProviderConfig struct { + Name string `json:"name"` + Region *string `json:"region"` + Alias *string `json:"alias"` + Version *string `json:"version"` +} + +// Module ports recon.py Module: a Terraform module reference. +type Module struct { + Name string `json:"name"` + Source string `json:"source"` + Version *string `json:"version"` + FilePath *string `json:"file_path"` +} + +// Resource ports recon.py Resource: an individual IaC resource (Terraform +// resource, CloudFormation resource, K8s object). +type Resource struct { + // ID is e.g. "aws_s3_bucket.data_lake". + ID string `json:"id"` + // Type is e.g. "aws_s3_bucket". + Type string `json:"type"` + // Name is e.g. "data_lake". + Name string `json:"name"` + // Provider is one of aws | gcp | azure | kubernetes. + Provider string `json:"provider"` + FilePath string `json:"file_path"` + LineNumber int `json:"line_number"` + // Config is the raw resource configuration. + Config map[string]any `json:"config"` + // References holds IDs of resources this depends on. + References []string `json:"references"` + // ReferencedBy holds IDs of resources that depend on this. + ReferencedBy []string `json:"referenced_by"` +} + +// ResourceInventory ports recon.py ResourceInventory: the inventory pointer the +// IaC reader harness returns. +// +// Python parity: InventorySavedPath is REQUIRED (no default), which is what +// makes ReconResult's default_factory unusable — see doc.go. +type ResourceInventory struct { + // InventorySavedPath is the absolute path to the generated inventory.json. + InventorySavedPath string `json:"inventory_saved_path"` + TotalResources int `json:"total_resources"` + // IaCType is one of terraform | cloudformation | kubernetes. + IaCType string `json:"iac_type"` + IaCVersion *string `json:"iac_version"` +} + +// --------------------------------------------------------------------------- +// Resource Graph Builder output +// --------------------------------------------------------------------------- + +// ResourceGraph ports recon.py ResourceGraph: the graph pointer the Resource +// Graph Builder harness returns. +// +// Python parity: GraphSavedPath is REQUIRED (no default). +type ResourceGraph struct { + // GraphSavedPath is the absolute path to the generated graph.json. + GraphSavedPath string `json:"graph_saved_path"` + TotalNodes int `json:"total_nodes"` + TotalEdges int `json:"total_edges"` +} + +// --------------------------------------------------------------------------- +// Drift Detection output (Tier 2+) +// --------------------------------------------------------------------------- + +// ConfigDiff ports recon.py ConfigDiff: a single attribute difference between +// IaC and live state. IaCValue/LiveValue are `Any = None` in Python. +type ConfigDiff struct { + Attribute string `json:"attribute"` + IaCValue any `json:"iac_value"` + LiveValue any `json:"live_value"` + SecurityImpact *string `json:"security_impact"` +} + +// DriftedResource ports recon.py DriftedResource: a resource that has drifted +// from its IaC declaration. +type DriftedResource struct { + ResourceID string `json:"resource_id"` + ResourceType string `json:"resource_type"` + IaCConfig map[string]any `json:"iac_config"` + LiveConfig map[string]any `json:"live_config"` + Diffs []ConfigDiff `json:"diffs"` + SecurityRelevant bool `json:"security_relevant"` + // Significance is one of critical | high | medium | low. + Significance string `json:"significance"` +} + +// DriftReport ports recon.py DriftReport: the complete drift analysis between +// IaC and live cloud. +type DriftReport struct { + DriftedResources []DriftedResource `json:"drifted_resources"` + // IaCOnlyResources are declared in IaC but not deployed. + IaCOnlyResources []string `json:"iac_only_resources"` + // CloudOnlyResources are deployed but not in IaC (shadow IT). + CloudOnlyResources []string `json:"cloud_only_resources"` +} + +// --------------------------------------------------------------------------- +// Aggregated RECON result +// --------------------------------------------------------------------------- + +// ReconResult ports recon.py ReconResult: the complete RECON phase output. +// +// Python parity: DriftReport and LiveInventory are only populated for Tier 2+ +// scans. See doc.go for why Python's `ReconResult()` (no arguments) raises and +// how NewReconResult() reproduces the intended default vector instead. +type ReconResult struct { + Inventory ResourceInventory `json:"inventory"` + ResourceGraph ResourceGraph `json:"resource_graph"` + // DriftReport is only populated for Tier 2+ scans. + DriftReport *DriftReport `json:"drift_report"` + // LiveInventory is the live cloud state (Tier 2+ only). + LiveInventory *ResourceInventory `json:"live_inventory"` + IaCType string `json:"iac_type"` + ProvidersDetected []string `json:"providers_detected"` + TotalResources int `json:"total_resources"` + TotalEdges int `json:"total_edges"` + ReconDurationSeconds float64 `json:"recon_duration_seconds"` +} diff --git a/go/internal/schemas/required.go b/go/internal/schemas/required.go new file mode 100644 index 0000000..632eb82 --- /dev/null +++ b/go/internal/schemas/required.go @@ -0,0 +1,145 @@ +package schemas + +// required.go transcribes, per model, +// +// [name for name, f in Model.model_fields.items() if f.is_required()] +// +// i.e. the fields a pydantic model has NO default for, which +// `Model.model_validate(payload)` raises `ValidationError(type=missing)` on +// when the payload omits them. afx.Bind consults these lists so that every +// ported `model_validate` call raises where Python raises instead of quietly +// producing a zero-valued model — see internal/afx/required.go for why that +// matters on the recon_phase and prove_phase boundaries. +// +// The lists were read off the live models under the repo venv, and +// required_test.go re-checks the eight models that have a committed pydantic +// schema fixture (internal/harnessx/testdata/schemas/*.json) against that +// fixture's `required` array — so a schema change in Python that regenerates +// the fixtures fails the build here rather than silently loosening validation. +// +// Models whose every field has a default (DriftReport, HuntResult, ChainResult, +// ReconResult, Proof, BlastRadius, CloudConfig, …) declare no method: pydantic +// accepts `Model.model_validate({})` for them, and so must the port. + +// --- input.py --------------------------------------------------------------- + +// RequiredFields is CloudSecurityInput's `repo_url`. +func (CloudSecurityInput) RequiredFields() []string { return []string{"repo_url"} } + +// --- recon.py --------------------------------------------------------------- + +// RequiredFields is Variable's `name`. +func (Variable) RequiredFields() []string { return []string{"name"} } + +// RequiredFields is Output's `name`. +func (Output) RequiredFields() []string { return []string{"name"} } + +// RequiredFields is ProviderConfig's `name`. +func (ProviderConfig) RequiredFields() []string { return []string{"name"} } + +// RequiredFields is Module's `name` and `source`. +func (Module) RequiredFields() []string { return []string{"name", "source"} } + +// RequiredFields is Resource's five required fields. +func (Resource) RequiredFields() []string { + return []string{"id", "type", "name", "provider", "file_path"} +} + +// RequiredFields is ResourceInventory's `inventory_saved_path` — the field +// recon_phase's first model_validate raises on. +func (ResourceInventory) RequiredFields() []string { return []string{"inventory_saved_path"} } + +// RequiredFields is ResourceGraph's `graph_saved_path`. +func (ResourceGraph) RequiredFields() []string { return []string{"graph_saved_path"} } + +// RequiredFields is ConfigDiff's `attribute`. +func (ConfigDiff) RequiredFields() []string { return []string{"attribute"} } + +// RequiredFields is DriftedResource's `resource_id` and `resource_type`. +func (DriftedResource) RequiredFields() []string { return []string{"resource_id", "resource_type"} } + +// --- hunt.py ---------------------------------------------------------------- + +// RequiredFields is AffectedResource's three required fields. +func (AffectedResource) RequiredFields() []string { + return []string{"resource_id", "resource_type", "attribute"} +} + +// RequiredFields is RawFinding's four required fields. +func (RawFinding) RequiredFields() []string { + return []string{"hunter_strategy", "title", "description", "category"} +} + +// --- chain.py --------------------------------------------------------------- + +// RequiredFields is AttackStep's five required fields. +func (AttackStep) RequiredFields() []string { + return []string{"step_number", "resource_id", "resource_type", "action", "permission_used"} +} + +// RequiredFields is AttackPath's four required fields. +func (AttackPath) RequiredFields() []string { + return []string{"title", "description", "entry_point", "target"} +} + +// --- prove.py --------------------------------------------------------------- + +// RequiredFields is IaCDiff's three required fields. +func (IaCDiff) RequiredFields() []string { + return []string{"file_path", "original_lines", "patched_lines"} +} + +// RequiredFields is RemediationSuggestion's `description`. +func (RemediationSuggestion) RequiredFields() []string { return []string{"description"} } + +// RequiredFields is VerifiedFinding's four required fields — the ones +// prove_phase's `_fallback_verified(finding, "Schema parse failed: ...")` +// branch depends on being raised for. +func (VerifiedFinding) RequiredFields() []string { + return []string{"title", "verdict", "severity", "category"} +} + +// --- output.py -------------------------------------------------------------- + +// RequiredFields is CloudSecurityScanResult's five required fields. +func (CloudSecurityScanResult) RequiredFields() []string { + return []string{"repository", "commit_sha", "timestamp", "depth_profile", "tier"} +} + +// RequiredFields is ScanProgress's nine required fields. +func (ScanProgress) RequiredFields() []string { + return []string{ + "phase", "phase_progress", "agents_total", "agents_completed", "agents_running", + "findings_so_far", "elapsed_seconds", "estimated_remaining_seconds", "cost_so_far_usd", + } +} + +// RequiredFields is ScanMetrics's three required fields. +func (ScanMetrics) RequiredFields() []string { + return []string{"duration_seconds", "agent_invocations", "cost_usd"} +} + +// --- views.py --------------------------------------------------------------- + +// RequiredFields is FindingForDedup's eight required fields. +func (FindingForDedup) RequiredFields() []string { + return []string{"id", "fingerprint", "title", "iac_file", "iac_line", "category", "hunter_strategy", "estimated_severity"} +} + +// RequiredFields is FindingForProver's eight required fields. +func (FindingForProver) RequiredFields() []string { + return []string{"id", "title", "description", "category", "hunter_strategy", "iac_file", "iac_line", "config_snippet"} +} + +// RequiredFields is FindingForChain's four required fields. +func (FindingForChain) RequiredFields() []string { + return []string{"id", "title", "description", "category"} +} + +// --- agents/chain/path_constructor.py --------------------------------------- +// +// PathInvestigationPlan and ChildInvestigation are declared next to the CHAIN +// parent agent in Python; the port keeps them in schemas (DESIGN §2c). + +// RequiredFields is ChildInvestigation's `title` and `child_prompt`. +func (ChildInvestigation) RequiredFields() []string { return []string{"title", "child_prompt"} } diff --git a/go/internal/schemas/required_test.go b/go/internal/schemas/required_test.go new file mode 100644 index 0000000..54c5efc --- /dev/null +++ b/go/internal/schemas/required_test.go @@ -0,0 +1,198 @@ +package schemas + +import ( + "reflect" + "sort" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/afx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/harnessx" +) + +// VALIDATION CONTRACT for required.go: for every ported model, +// +// Model.RequiredFields() == [n for n, f in Model.model_fields.items() +// if f.is_required()] +// +// which is exactly the `required` array pydantic writes into +// `Model.model_json_schema()` — i.e. into the committed fixtures under +// internal/harnessx/testdata/schemas/, which scripts/gen_schemas.py generates +// from the live Python models. Cross-checking against those fixtures means a +// Python schema change that regenerates them fails HERE instead of silently +// loosening every `Model.model_validate(payload)` in the port. +// +// The lists that no fixture covers were read off the live models under the repo +// venv and are pinned by TestRequiredFields_LiveModelTranscription below. + +func TestRequiredFields_MatchTheCommittedPydanticSchemas(t *testing.T) { + registry := goDefaults() + + names := harnessx.EmbeddedSchemaNames() + if len(names) == 0 { + t.Fatal("no embedded schema fixtures found") + } + + checked := 0 + for _, fixture := range names { + schema, err := harnessx.LoadEmbeddedSchema(fixture) + if err != nil { + t.Fatalf("LoadEmbeddedSchema(%s): %v", fixture, err) + } + checkRequiredAgainstSchema(t, registry, fixture, schema) + checked++ + + defs, _ := schema["$defs"].(map[string]any) + for defName, raw := range defs { + sub, ok := raw.(map[string]any) + if !ok { + continue + } + if _, hasProps := sub["properties"]; !hasProps { + continue // an enum or scalar $def, not a model + } + checkRequiredAgainstSchema(t, registry, defName, sub) + checked++ + } + } + if checked < len(names) { + t.Errorf("cross-checked %d models from %d fixtures", checked, len(names)) + } +} + +// checkRequiredAgainstSchema compares one schema node's `required` array with +// the Go model's RequiredFields. +func checkRequiredAgainstSchema(t *testing.T, registry map[string]any, name string, node map[string]any) { + t.Helper() + + model, present := registry[name] + if !present { + t.Errorf("no Go model registered for schema %q", name) + return + } + + want := make([]string, 0) + if raw, ok := node["required"].([]any); ok { + for _, v := range raw { + s, ok := v.(string) + if !ok { + t.Errorf("%s: required entry %#v is not a string", name, v) + continue + } + want = append(want, s) + } + } + + var got []string + if rf, ok := model.(afx.RequiredFielder); ok { + got = append(got, rf.RequiredFields()...) + } + + sortedWant := append([]string(nil), want...) + sortedGot := append([]string(nil), got...) + sort.Strings(sortedWant) + sort.Strings(sortedGot) + if !reflect.DeepEqual(sortedWant, sortedGot) { + t.Errorf("%s.RequiredFields() = %v, want %v (the fixture's \"required\" array)", name, got, want) + } +} + +// TestRequiredFields_LiveModelTranscription pins the lists for the models that +// no harness fixture covers. The expectations were produced under the repo venv +// with +// +// [n for n, f in Model.model_fields.items() if f.is_required()] +// +// over every class in src/cloudsecurity_af/schemas/*.py. +func TestRequiredFields_LiveModelTranscription(t *testing.T) { + want := map[string][]string{ + "Variable": {"name"}, + "Output": {"name"}, + "ProviderConfig": {"name"}, + "Module": {"name", "source"}, + "Resource": {"id", "type", "name", "provider", "file_path"}, + "ResourceInventory": {"inventory_saved_path"}, + "ResourceGraph": {"graph_saved_path"}, + "ConfigDiff": {"attribute"}, + "DriftedResource": {"resource_id", "resource_type"}, + "DriftReport": nil, + "ReconResult": nil, + "AffectedResource": {"resource_id", "resource_type", "attribute"}, + "RawFinding": {"hunter_strategy", "title", "description", "category"}, + "HuntResult": nil, + "AttackStep": {"step_number", "resource_id", "resource_type", "action", "permission_used"}, + "BlastRadius": nil, + "AttackPath": {"title", "description", "entry_point", "target"}, + "ChainResult": nil, + "Proof": nil, + "IaCDiff": {"file_path", "original_lines", "patched_lines"}, + "RemediationSuggestion": {"description"}, + "VerifiedFinding": {"title", "verdict", "severity", "category"}, + "CloudConfig": nil, + "CloudSecurityInput": {"repo_url"}, + "CloudSecurityScanResult": {"repository", "commit_sha", "timestamp", "depth_profile", "tier"}, + "ScanProgress": {"phase", "phase_progress", "agents_total", "agents_completed", "agents_running", + "findings_so_far", "elapsed_seconds", "estimated_remaining_seconds", "cost_so_far_usd"}, + "ScanMetrics": {"duration_seconds", "agent_invocations", "cost_usd"}, + "FindingForDedup": {"id", "fingerprint", "title", "iac_file", "iac_line", "category", "hunter_strategy", "estimated_severity"}, + "FindingForProver": {"id", "title", "description", "category", "hunter_strategy", "iac_file", "iac_line", "config_snippet"}, + "FindingForChain": {"id", "title", "description", "category"}, + // agents/chain/path_constructor.py — kept in schemas per DESIGN §2c. + "ChildInvestigation": {"title", "child_prompt"}, + "PathInvestigationPlan": nil, + } + + registry := goDefaults() + for name, model := range registry { + expected, known := want[name] + if !known { + t.Errorf("model %q has no transcription in this test; add it (and its RequiredFields, if any)", name) + continue + } + var got []string + if rf, ok := model.(afx.RequiredFielder); ok { + got = rf.RequiredFields() + } + if len(expected) == 0 && len(got) != 0 { + t.Errorf("%s.RequiredFields() = %v, want none (every field has a pydantic default)", name, got) + continue + } + if len(expected) != 0 && !reflect.DeepEqual(got, expected) { + t.Errorf("%s.RequiredFields() = %v, want %v", name, got, expected) + } + } + for name := range want { + if _, present := registry[name]; !present { + t.Errorf("goDefaults has no entry for %q", name) + } + } +} + +// The behaviour required.go exists for: `Model.model_validate(payload)` must +// RAISE for a payload missing a required field, not return a zero-valued model. +func TestRequiredFields_BindRaisesLikeModelValidate(t *testing.T) { + // recon_phase: ResourceInventory.model_validate on the iac-reader reply. + if _, err := afx.Bind[ResourceInventory](map[string]any{"total_resources": 3}); err == nil { + t.Error("Bind[ResourceInventory] accepted a payload with no inventory_saved_path; pydantic raises 'Field required'") + } + if _, err := afx.Bind[ResourceInventory](map[string]any{"inventory_saved_path": "/tmp/i.json"}); err != nil { + t.Errorf("Bind[ResourceInventory] rejected a valid payload: %v", err) + } + + // prove_phase: VerifiedFinding.model_validate on the prover reply. + if _, err := afx.Bind[VerifiedFinding](map[string]any{"id": "f1"}); err == nil { + t.Error("Bind[VerifiedFinding] accepted a payload with no title/verdict/severity/category") + } + + // Nested models are validated too, exactly as pydantic does. + if _, err := afx.Bind[VerifiedFinding](map[string]any{ + "title": "t", "verdict": "confirmed", "severity": "high", "category": "c", + "resources": []any{map[string]any{"resource_id": "r"}}, + }); err == nil { + t.Error("Bind[VerifiedFinding] accepted a nested AffectedResource missing resource_type/attribute") + } + + // A model whose fields ALL have defaults still binds from {}. + if _, err := afx.Bind[DriftReport](map[string]any{}); err != nil { + t.Errorf("Bind[DriftReport]({}) must succeed — every field has a default: %v", err) + } +} diff --git a/go/internal/schemas/schemas_test.go b/go/internal/schemas/schemas_test.go new file mode 100644 index 0000000..825c9be --- /dev/null +++ b/go/internal/schemas/schemas_test.go @@ -0,0 +1,593 @@ +package schemas + +import ( + "encoding/json" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// This file ports tests/test_schemas.py. +// +// STALE-TEST NOTE (verified against the venv interpreter, 2026-08-19): +// tests/test_schemas.py does not currently import. It asks for +// `ResourceNode`, `ResourceEdge` and `ResourceCluster` from +// cloudsecurity_af.schemas.recon, and none of the three exists any more — +// schemas/recon.py was reworked so ResourceInventory/ResourceGraph are *pointers* +// to files on disk (`inventory_saved_path` / `graph_saved_path`) rather than +// inline node/edge containers. The import error means the WHOLE module fails to +// collect, so every test in it is dead in Python today. +// +// Consequently: +// - Tests whose subject still exists are ported verbatim, named after the +// Python class::test. +// - Five TestReconSchemas tests (test_resource_node_defaults, +// test_resource_edge, test_resource_graph_empty, +// test_resource_inventory_empty, test_resource_inventory_populated) target +// removed classes or removed fields. They are replaced by tests of the +// CURRENT shape, each carrying a comment naming the Python test it stands +// in for. Restoring them verbatim would mean re-adding schema classes the +// Python node no longer has, which would break parity. +// +// tests/test_graph_context.py is stale for the same reason AND targets +// cloudsecurity_af.agents._utils.build_graph_context_for_hunter, which now takes +// two file PATHS instead of the two models the test constructs. It belongs to +// the internal/agents/util owner, not to internal/schemas. + +func mustJSONMap(t *testing.T, v any) map[string]any { + t.Helper() + raw, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("decode: %v", err) + } + return m +} + +// --------------------------------------------------------------------------- +// Input schemas — tests/test_schemas.py::TestCloudConfig +// --------------------------------------------------------------------------- + +// test_defaults +func TestCloudConfig_Defaults(t *testing.T) { + cfg := NewCloudConfig() + if cfg.Provider != "aws" { + t.Errorf("provider = %q, want %q", cfg.Provider, "aws") + } + if !reflect.DeepEqual(cfg.Regions, []string{"us-east-1"}) { + t.Errorf("regions = %v, want [us-east-1]", cfg.Regions) + } + if cfg.AccountID != nil { + t.Errorf("account_id = %v, want nil", *cfg.AccountID) + } + if cfg.AssumeRoleARN != nil { + t.Errorf("assume_role_arn = %v, want nil", *cfg.AssumeRoleARN) + } +} + +// test_custom +func TestCloudConfig_Custom(t *testing.T) { + var cfg CloudConfig + if err := json.Unmarshal([]byte(`{"provider":"gcp","regions":["us-central1"],"account_id":"my-project"}`), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if cfg.Provider != "gcp" { + t.Errorf("provider = %q, want gcp", cfg.Provider) + } + if !reflect.DeepEqual(cfg.Regions, []string{"us-central1"}) { + t.Errorf("regions = %v, want [us-central1]", cfg.Regions) + } + if cfg.AccountID == nil || *cfg.AccountID != "my-project" { + t.Errorf("account_id = %v, want my-project", cfg.AccountID) + } +} + +// --------------------------------------------------------------------------- +// tests/test_schemas.py::TestCloudSecurityInput +// --------------------------------------------------------------------------- + +// test_tier1_no_cloud +func TestCloudSecurityInput_Tier1NoCloud(t *testing.T) { + var in CloudSecurityInput + if err := json.Unmarshal([]byte(`{"repo_url":"/tmp/my-repo"}`), &in); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if in.Tier() != 1 { + t.Errorf("tier = %d, want 1", in.Tier()) + } + if in.Cloud != nil { + t.Errorf("cloud = %+v, want nil", in.Cloud) + } + if in.Depth != "standard" { + t.Errorf("depth = %q, want standard", in.Depth) + } +} + +// test_tier2_with_cloud +func TestCloudSecurityInput_Tier2WithCloud(t *testing.T) { + var in CloudSecurityInput + if err := json.Unmarshal([]byte(`{"repo_url":"/tmp/my-repo","cloud":{"provider":"aws"}}`), &in); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if in.Tier() != 2 { + t.Errorf("tier = %d, want 2", in.Tier()) + } + // The nested CloudConfig re-seeds its own defaults. + if !reflect.DeepEqual(in.Cloud.Regions, []string{"us-east-1"}) { + t.Errorf("nested cloud.regions = %v, want [us-east-1]", in.Cloud.Regions) + } +} + +// test_depth_options +func TestCloudSecurityInput_DepthOptions(t *testing.T) { + for _, depth := range []string{"quick", "standard", "thorough"} { + var in CloudSecurityInput + payload := `{"repo_url":".","depth":"` + depth + `"}` + if err := json.Unmarshal([]byte(payload), &in); err != nil { + t.Fatalf("unmarshal %s: %v", depth, err) + } + if in.Depth != depth { + t.Errorf("depth = %q, want %q", in.Depth, depth) + } + } +} + +// test_default_exclude_paths +func TestCloudSecurityInput_DefaultExcludePaths(t *testing.T) { + var in CloudSecurityInput + if err := json.Unmarshal([]byte(`{"repo_url":"."}`), &in); err != nil { + t.Fatalf("unmarshal: %v", err) + } + want := []string{"tests/", ".git/", "examples/", ".terraform/"} + if !reflect.DeepEqual(in.ExcludePaths, want) { + t.Fatalf("exclude_paths = %v, want %v", in.ExcludePaths, want) + } +} + +// --------------------------------------------------------------------------- +// Recon schemas — tests/test_schemas.py::TestReconSchemas +// --------------------------------------------------------------------------- + +// Stands in for test_resource_node_defaults / test_resource_edge, whose +// ResourceNode / ResourceEdge classes no longer exist. Resource is the model +// that survived the rework; this pins its defaults. +func TestReconSchemas_ResourceDefaults(t *testing.T) { + r := NewResource() + r.ID = "aws_s3_bucket.data" + r.Type = "aws_s3_bucket" + r.Name = "data" + r.Provider = "aws" + r.FilePath = "main.tf" + if r.LineNumber != 0 { + t.Errorf("line_number = %d, want 0", r.LineNumber) + } + got := mustJSONMap(t, r) + // default_factory=dict / list must marshal as {} / [], never null. + if !reflect.DeepEqual(got["config"], map[string]any{}) { + t.Errorf("config = %v, want {}", got["config"]) + } + if !reflect.DeepEqual(got["references"], []any{}) { + t.Errorf("references = %v, want []", got["references"]) + } + if !reflect.DeepEqual(got["referenced_by"], []any{}) { + t.Errorf("referenced_by = %v, want []", got["referenced_by"]) + } +} + +// Stands in for test_resource_graph_empty. ResourceGraph is now a pointer to a +// graph.json file; `graph_saved_path` is REQUIRED, so `ResourceGraph()` raises +// in Python and the empty nodes/edges/clusters lists the old test asserted are +// gone. +func TestReconSchemas_ResourceGraphIsAPointer(t *testing.T) { + g := NewResourceGraph() + if g.TotalNodes != 0 || g.TotalEdges != 0 { + t.Errorf("counts = %d/%d, want 0/0", g.TotalNodes, g.TotalEdges) + } + got := mustJSONMap(t, g) + wantKeys := []string{"graph_saved_path", "total_nodes", "total_edges"} + if len(got) != len(wantKeys) { + t.Fatalf("keys = %v, want exactly %v", got, wantKeys) + } + for _, k := range wantKeys { + if _, ok := got[k]; !ok { + t.Errorf("missing key %q", k) + } + } +} + +// Stands in for test_resource_inventory_empty / test_resource_inventory_populated. +// ResourceInventory is now a pointer to inventory.json; it has no `resources`, +// `modules`, `variables`, `outputs` or `provider_configs` fields any more. The +// one default that survived is iac_type="terraform". +func TestReconSchemas_ResourceInventoryDefaults(t *testing.T) { + inv := NewResourceInventory() + if inv.IaCType != "terraform" { + t.Errorf("iac_type = %q, want terraform", inv.IaCType) + } + if inv.TotalResources != 0 { + t.Errorf("total_resources = %d, want 0", inv.TotalResources) + } + if inv.IaCVersion != nil { + t.Errorf("iac_version = %v, want nil", *inv.IaCVersion) + } + // Decoding a payload without iac_type keeps the default; an explicit value + // overrides it (pydantic parity). + var decoded ResourceInventory + if err := json.Unmarshal([]byte(`{"inventory_saved_path":"/tmp/inventory.json"}`), &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.IaCType != "terraform" { + t.Errorf("absent iac_type = %q, want terraform", decoded.IaCType) + } + if err := json.Unmarshal([]byte(`{"inventory_saved_path":"/x","iac_type":"kubernetes"}`), &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.IaCType != "kubernetes" { + t.Errorf("explicit iac_type = %q, want kubernetes", decoded.IaCType) + } +} + +// test_drift_report_empty +func TestReconSchemas_DriftReportEmpty(t *testing.T) { + d := NewDriftReport() + if len(d.DriftedResources) != 0 || len(d.IaCOnlyResources) != 0 || len(d.CloudOnlyResources) != 0 { + t.Fatalf("expected all three lists empty, got %+v", d) + } + got := mustJSONMap(t, d) + for _, k := range []string{"drifted_resources", "iac_only_resources", "cloud_only_resources"} { + if !reflect.DeepEqual(got[k], []any{}) { + t.Errorf("%s = %v, want [] (never null)", k, got[k]) + } + } +} + +// test_recon_result_defaults — Python's ReconResult() raises today (see doc.go), +// so this asserts NewReconResult()'s intended default vector instead. +func TestReconSchemas_ReconResultDefaults(t *testing.T) { + r := NewReconResult() + if r.TotalResources != 0 { + t.Errorf("total_resources = %d, want 0", r.TotalResources) + } + if r.IaCType != "terraform" { + t.Errorf("iac_type = %q, want terraform", r.IaCType) + } + if r.DriftReport != nil { + t.Errorf("drift_report = %+v, want nil", r.DriftReport) + } + if r.LiveInventory != nil { + t.Errorf("live_inventory = %+v, want nil", r.LiveInventory) + } + if r.Inventory.IaCType != "terraform" { + t.Errorf("nested inventory.iac_type = %q, want terraform", r.Inventory.IaCType) + } +} + +// --------------------------------------------------------------------------- +// Hunt schemas — tests/test_schemas.py::TestHuntSchemas +// --------------------------------------------------------------------------- + +// test_raw_finding_defaults +func TestHuntSchemas_RawFindingDefaults(t *testing.T) { + f := NewRawFinding() + f.HunterStrategy = "iam" + f.Title = "Over-permissioned role" + f.Description = "Role has wildcard access" + f.Category = "overprivilege" + + if f.EstimatedSeverity != scoring.SeverityMedium { + t.Errorf("estimated_severity = %q, want medium", f.EstimatedSeverity) + } + if f.Confidence != ConfidenceMedium { + t.Errorf("confidence = %q, want medium", f.Confidence) + } + if f.ID == "" { + t.Error("id should be a generated uuid") + } + if f.IaCFile != "" { + t.Errorf("iac_file = %q, want empty", f.IaCFile) + } +} + +// test_finding_for_dedup +func TestHuntSchemas_FindingForDedup(t *testing.T) { + f := NewRawFinding() + f.HunterStrategy = "network" + f.Title = "Open security group" + f.Description = "SG allows 0.0.0.0/0" + f.Category = "public_exposure" + f.EstimatedSeverity = scoring.SeverityCritical + f.IaCFile = "network.tf" + f.IaCLine = 7 + + dedup := f.ForDedup() + if dedup.ID != f.ID { + t.Errorf("id = %q, want %q", dedup.ID, f.ID) + } + if dedup.Title != f.Title { + t.Errorf("title = %q, want %q", dedup.Title, f.Title) + } + if dedup.Fingerprint != f.Fingerprint { + t.Errorf("fingerprint = %q, want %q", dedup.Fingerprint, f.Fingerprint) + } + if dedup.Category != "public_exposure" || dedup.HunterStrategy != "network" { + t.Errorf("category/strategy = %q/%q", dedup.Category, dedup.HunterStrategy) + } + if dedup.IaCFile != "network.tf" || dedup.IaCLine != 7 { + t.Errorf("iac_file/line = %q/%d", dedup.IaCFile, dedup.IaCLine) + } + // Python parity: for_dedup() passes `self.estimated_severity.value`, so the + // view's field is the plain string, not the enum. + if dedup.EstimatedSeverity != "critical" { + t.Errorf("estimated_severity = %q, want the raw value %q", dedup.EstimatedSeverity, "critical") + } +} + +// test_hunt_result_empty +func TestHuntSchemas_HuntResultEmpty(t *testing.T) { + r := NewHuntResult() + if len(r.Findings) != 0 { + t.Errorf("findings = %v, want empty", r.Findings) + } + if r.TotalRaw != 0 { + t.Errorf("total_raw = %d, want 0", r.TotalRaw) + } + got := mustJSONMap(t, r) + if !reflect.DeepEqual(got["findings"], []any{}) { + t.Errorf("findings marshals as %v, want []", got["findings"]) + } +} + +// test_hunter_strategy_enum +func TestHuntSchemas_HunterStrategyEnum(t *testing.T) { + if HunterStrategyIAM.String() != "iam" { + t.Errorf("IAM = %q, want iam", HunterStrategyIAM) + } + if HunterStrategyCompliance.String() != "compliance" { + t.Errorf("COMPLIANCE = %q, want compliance", HunterStrategyCompliance) + } + if len(AllHunterStrategies) != 7 { + t.Errorf("AllHunterStrategies has %d members, want 7", len(AllHunterStrategies)) + } +} + +// test_finding_category_enum +func TestHuntSchemas_FindingCategoryEnum(t *testing.T) { + if FindingCategoryOverprivilege.String() != "overprivilege" { + t.Errorf("OVERPRIVILEGE = %q", FindingCategoryOverprivilege) + } + if FindingCategoryPrivilegedContainer.String() != "privileged_container" { + t.Errorf("PRIVILEGED_CONTAINER = %q", FindingCategoryPrivilegedContainer) + } + if len(AllFindingCategories) != 13 { + t.Errorf("AllFindingCategories has %d members, want 13", len(AllFindingCategories)) + } +} + +// Not in the Python suite: the Confidence enum's members and strictness. +func TestHuntSchemas_ConfidenceEnum(t *testing.T) { + want := []string{"high", "medium", "low"} + if len(AllConfidences) != len(want) { + t.Fatalf("AllConfidences has %d members, want %d", len(AllConfidences), len(want)) + } + for i, w := range want { + if AllConfidences[i].String() != w { + t.Errorf("AllConfidences[%d] = %q, want %q", i, AllConfidences[i], w) + } + } + if _, err := ParseConfidence("HIGH"); err == nil { + t.Error("ParseConfidence is case-sensitive, matching Python's Enum(value)") + } +} + +// --------------------------------------------------------------------------- +// Chain schemas — tests/test_schemas.py::TestChainSchemas +// --------------------------------------------------------------------------- + +// test_attack_step +func TestChainSchemas_AttackStep(t *testing.T) { + step := NewAttackStep() + step.StepNumber = 1 + step.ResourceID = "role.admin" + step.ResourceType = "aws_iam_role" + step.Action = "Assume role via sts:AssumeRole" + step.PermissionUsed = "sts:AssumeRole" + if step.StepNumber != 1 { + t.Errorf("step_number = %d, want 1", step.StepNumber) + } + if step.Description != "" { + t.Errorf("description = %q, want empty", step.Description) + } +} + +// test_attack_path +func TestChainSchemas_AttackPath(t *testing.T) { + path := NewAttackPath() + path.Title = "Public S3 to admin role" + path.Description = "Chain from public bucket to admin" + path.EntryPoint = "aws_s3_bucket.public" + path.Target = "aws_iam_role.admin" + path.Steps = []AttackStep{} + + if path.CombinedSeverity != scoring.SeverityHigh { + t.Errorf("combined_severity = %q, want high", path.CombinedSeverity) + } + if len(path.BlastRadius.DataStoresReachable) != 0 { + t.Errorf("blast_radius.data_stores_reachable = %v, want empty", path.BlastRadius.DataStoresReachable) + } + if path.ID == "" { + t.Error("id should be a generated uuid") + } + got := mustJSONMap(t, path) + br, ok := got["blast_radius"].(map[string]any) + if !ok { + t.Fatalf("blast_radius = %v, want an object", got["blast_radius"]) + } + if !reflect.DeepEqual(br["data_stores_reachable"], []any{}) { + t.Errorf("nested blast_radius lists must marshal as [], got %v", br["data_stores_reachable"]) + } +} + +// test_chain_result_empty +func TestChainSchemas_ChainResultEmpty(t *testing.T) { + r := NewChainResult() + if len(r.AttackPaths) != 0 { + t.Errorf("attack_paths = %v, want empty", r.AttackPaths) + } + if r.ViablePaths != 0 { + t.Errorf("viable_paths = %d, want 0", r.ViablePaths) + } +} + +// --------------------------------------------------------------------------- +// Prove schemas — tests/test_schemas.py::TestProveSchemas +// --------------------------------------------------------------------------- + +// test_verified_finding_minimal +func TestProveSchemas_VerifiedFindingMinimal(t *testing.T) { + f := NewVerifiedFinding() + f.Title = "Test finding" + f.Verdict = VerdictConfirmed + f.Severity = scoring.SeverityHigh + f.Category = "overprivilege" + + if f.Verdict != VerdictConfirmed { + t.Errorf("verdict = %q, want confirmed", f.Verdict) + } + if f.RiskScore != 0.0 { + t.Errorf("risk_score = %v, want 0.0", f.RiskScore) + } + if f.Proof.Method != ProofMethodStaticAnalysis { + t.Errorf("proof.method = %q, want static_analysis", f.Proof.Method) + } + if f.Proof.VerificationTier != "static" { + t.Errorf("proof.verification_tier = %q, want static", f.Proof.VerificationTier) + } +} + +// test_remediation_suggestion +func TestProveSchemas_RemediationSuggestion(t *testing.T) { + rem := NewRemediationSuggestion() + rem.FindingID = "test-id" + rem.Description = "Enable encryption" + diff := NewIaCDiff() + diff.FilePath = "main.tf" + diff.OriginalLines = " encryption = false" + diff.PatchedLines = " encryption = true" + diff.StartLine = 10 + diff.EndLine = 10 + rem.Diffs = []IaCDiff{diff} + rem.BreakingChange = false + downtime := "none" + rem.DowntimeEstimate = &downtime + + if len(rem.Diffs) != 1 { + t.Fatalf("diffs = %v, want 1 entry", rem.Diffs) + } + if rem.BreakingChange { + t.Error("breaking_change should be false") + } + if rem.Effort != "moderate" { + t.Errorf("effort = %q, want the default moderate", rem.Effort) + } +} + +// test_verdict_enum +func TestProveSchemas_VerdictEnum(t *testing.T) { + if VerdictConfirmed.String() != "confirmed" { + t.Errorf("CONFIRMED = %q", VerdictConfirmed) + } + if VerdictNotExploitable.String() != "not_exploitable" { + t.Errorf("NOT_EXPLOITABLE = %q", VerdictNotExploitable) + } + if len(AllVerdicts) != 4 { + t.Errorf("AllVerdicts has %d members, want 4", len(AllVerdicts)) + } +} + +// Not in the Python suite: the ProofMethod enum's members. +func TestProveSchemas_ProofMethodEnum(t *testing.T) { + want := []string{"static_analysis", "live_api_verification", "iam_simulation", "drift_comparison"} + if len(AllProofMethods) != len(want) { + t.Fatalf("AllProofMethods has %d members, want %d", len(AllProofMethods), len(want)) + } + for i, w := range want { + if AllProofMethods[i].String() != w { + t.Errorf("AllProofMethods[%d] = %q, want %q", i, AllProofMethods[i], w) + } + } +} + +// --------------------------------------------------------------------------- +// Output schemas — tests/test_schemas.py::TestOutputSchemas +// --------------------------------------------------------------------------- + +// test_scan_result_minimal +func TestOutputSchemas_ScanResultMinimal(t *testing.T) { + r := NewCloudSecurityScanResult() + r.Repository = "/tmp/repo" + r.CommitSHA = "abc123" + r.Timestamp = NewTimestamp(time.Now().UTC()) + r.DepthProfile = "standard" + r.Tier = 1 + + if r.Tier != 1 { + t.Errorf("tier = %d, want 1", r.Tier) + } + if r.Confirmed != 0 { + t.Errorf("confirmed = %d, want 0", r.Confirmed) + } + if len(r.Findings) != 0 { + t.Errorf("findings = %v, want empty", r.Findings) + } + got := mustJSONMap(t, r) + if !reflect.DeepEqual(got["findings"], []any{}) { + t.Errorf("findings marshals as %v, want []", got["findings"]) + } + if !reflect.DeepEqual(got["by_severity"], map[string]any{}) { + t.Errorf("by_severity marshals as %v, want {}", got["by_severity"]) + } + if got["branch"] != nil { + t.Errorf("branch marshals as %v, want null", got["branch"]) + } +} + +// test_scan_progress +func TestOutputSchemas_ScanProgress(t *testing.T) { + p := ScanProgress{ + Phase: "HUNT", + PhaseProgress: 0.5, + AgentsTotal: 7, + AgentsCompleted: 3, + AgentsRunning: 4, + FindingsSoFar: 12, + ElapsedSeconds: 30.0, + EstimatedRemainingSeconds: 30.0, + CostSoFarUSD: 0.02, + } + if p.Phase != "HUNT" { + t.Errorf("phase = %q, want HUNT", p.Phase) + } +} + +// test_scan_metrics +func TestOutputSchemas_ScanMetrics(t *testing.T) { + m := NewScanMetrics() + m.DurationSeconds = 60.0 + m.AgentInvocations = 15 + m.CostUSD = 0.05 + if m.BudgetExhausted { + t.Error("budget_exhausted should default to false") + } + if m.FindingsNotVerified != 0 { + t.Errorf("findings_not_verified = %d, want 0", m.FindingsNotVerified) + } + got := mustJSONMap(t, m) + if !reflect.DeepEqual(got["cost_breakdown"], map[string]any{}) { + t.Errorf("cost_breakdown marshals as %v, want {}", got["cost_breakdown"]) + } +} diff --git a/go/internal/schemas/testdata/model_keys.json b/go/internal/schemas/testdata/model_keys.json new file mode 100644 index 0000000..48a937d --- /dev/null +++ b/go/internal/schemas/testdata/model_keys.json @@ -0,0 +1,764 @@ +{ + "AffectedResource": { + "dump": { + "attribute": "", + "current_value": "", + "recommended_value": "", + "resource_id": "", + "resource_type": "" + }, + "keys": [ + "resource_id", + "resource_type", + "attribute", + "current_value", + "recommended_value" + ], + "module": "cloudsecurity_af.schemas.hunt", + "nondeterministic": [] + }, + "AttackPath": { + "dump": { + "blast_radius": { + "compute_reachable": [], + "data_stores_reachable": [], + "estimated_data_volume": null, + "services_affected": [] + }, + "combined_severity": "high", + "description": "", + "entry_point": "", + "findings_involved": [], + "id": "", + "steps": [], + "target": "", + "title": "" + }, + "keys": [ + "id", + "title", + "description", + "steps", + "entry_point", + "target", + "findings_involved", + "combined_severity", + "blast_radius" + ], + "module": "cloudsecurity_af.schemas.chain", + "nondeterministic": [ + "id" + ] + }, + "AttackStep": { + "dump": { + "action": "", + "description": "", + "permission_used": "", + "resource_id": "", + "resource_type": "", + "step_number": 0 + }, + "keys": [ + "step_number", + "resource_id", + "resource_type", + "action", + "permission_used", + "description" + ], + "module": "cloudsecurity_af.schemas.chain", + "nondeterministic": [] + }, + "BlastRadius": { + "dump": { + "compute_reachable": [], + "data_stores_reachable": [], + "estimated_data_volume": null, + "services_affected": [] + }, + "keys": [ + "data_stores_reachable", + "compute_reachable", + "estimated_data_volume", + "services_affected" + ], + "module": "cloudsecurity_af.schemas.chain", + "nondeterministic": [] + }, + "ChainResult": { + "dump": { + "attack_paths": [], + "chain_duration_seconds": 0.0, + "total_paths_evaluated": 0, + "viable_paths": 0 + }, + "keys": [ + "attack_paths", + "total_paths_evaluated", + "viable_paths", + "chain_duration_seconds" + ], + "module": "cloudsecurity_af.schemas.chain", + "nondeterministic": [] + }, + "ChildInvestigation": { + "dump": { + "child_prompt": "", + "findings_involved": [], + "rationale": "", + "title": "" + }, + "keys": [ + "title", + "rationale", + "findings_involved", + "child_prompt" + ], + "module": "cloudsecurity_af.agents.chain.path_constructor", + "nondeterministic": [] + }, + "CloudConfig": { + "dump": { + "account_id": null, + "assume_role_arn": null, + "provider": "aws", + "regions": [ + "us-east-1" + ] + }, + "keys": [ + "provider", + "regions", + "account_id", + "assume_role_arn" + ], + "module": "cloudsecurity_af.schemas.input", + "nondeterministic": [] + }, + "CloudSecurityInput": { + "dump": { + "base_commit_sha": null, + "branch": "main", + "cloud": null, + "commit_sha": null, + "compliance_frameworks": [], + "depth": "standard", + "exclude_paths": [ + "tests/", + ".git/", + "examples/", + ".terraform/" + ], + "fail_on_findings": false, + "include_paths": null, + "is_pr": false, + "max_concurrent_hunters": null, + "max_concurrent_provers": null, + "max_cost_usd": null, + "max_duration_seconds": null, + "output_formats": [ + "json" + ], + "pr_id": null, + "repo_url": "", + "severity_threshold": "low" + }, + "keys": [ + "repo_url", + "branch", + "commit_sha", + "base_commit_sha", + "depth", + "severity_threshold", + "output_formats", + "compliance_frameworks", + "cloud", + "max_cost_usd", + "max_duration_seconds", + "max_concurrent_hunters", + "max_concurrent_provers", + "include_paths", + "exclude_paths", + "is_pr", + "pr_id", + "fail_on_findings" + ], + "module": "cloudsecurity_af.schemas.input", + "nondeterministic": [] + }, + "CloudSecurityScanResult": { + "dump": { + "agent_invocations": 0, + "attack_paths": [], + "branch": null, + "by_severity": {}, + "commit_sha": "", + "compliance_frameworks_checked": [], + "compliance_gaps": [], + "confirmed": 0, + "cost_breakdown": {}, + "cost_usd": 0.0, + "depth_profile": "", + "drift_resources": 0, + "duration_seconds": 0.0, + "findings": [], + "inconclusive": 0, + "likely": 0, + "metadata": {}, + "noise_reduction_pct": 0.0, + "not_exploitable": 0, + "providers_detected": [], + "repository": "", + "sarif": "", + "shadow_it_resources": 0, + "strategies_used": [], + "tier": 0, + "timestamp": "2026-01-02T03:04:05.123456+00:00", + "total_raw_findings": 0, + "total_resources_scanned": 0 + }, + "keys": [ + "repository", + "commit_sha", + "branch", + "timestamp", + "depth_profile", + "tier", + "providers_detected", + "findings", + "attack_paths", + "total_resources_scanned", + "total_raw_findings", + "confirmed", + "likely", + "inconclusive", + "not_exploitable", + "noise_reduction_pct", + "by_severity", + "drift_resources", + "shadow_it_resources", + "compliance_frameworks_checked", + "compliance_gaps", + "strategies_used", + "duration_seconds", + "agent_invocations", + "cost_usd", + "cost_breakdown", + "metadata", + "sarif" + ], + "module": "cloudsecurity_af.schemas.output", + "nondeterministic": [] + }, + "ConfigDiff": { + "dump": { + "attribute": "", + "iac_value": null, + "live_value": null, + "security_impact": null + }, + "keys": [ + "attribute", + "iac_value", + "live_value", + "security_impact" + ], + "module": "cloudsecurity_af.schemas.recon", + "nondeterministic": [] + }, + "DriftReport": { + "dump": { + "cloud_only_resources": [], + "drifted_resources": [], + "iac_only_resources": [] + }, + "keys": [ + "drifted_resources", + "iac_only_resources", + "cloud_only_resources" + ], + "module": "cloudsecurity_af.schemas.recon", + "nondeterministic": [] + }, + "DriftedResource": { + "dump": { + "diffs": [], + "iac_config": {}, + "live_config": {}, + "resource_id": "", + "resource_type": "", + "security_relevant": false, + "significance": "medium" + }, + "keys": [ + "resource_id", + "resource_type", + "iac_config", + "live_config", + "diffs", + "security_relevant", + "significance" + ], + "module": "cloudsecurity_af.schemas.recon", + "nondeterministic": [] + }, + "FindingForChain": { + "dump": { + "category": "", + "confidence": "", + "description": "", + "estimated_severity": "", + "id": "", + "resources": [], + "title": "" + }, + "keys": [ + "id", + "title", + "description", + "category", + "resources", + "estimated_severity", + "confidence" + ], + "module": "cloudsecurity_af.schemas.views", + "nondeterministic": [] + }, + "FindingForDedup": { + "dump": { + "category": "", + "estimated_severity": "", + "fingerprint": "", + "hunter_strategy": "", + "iac_file": "", + "iac_line": 0, + "id": "", + "title": "" + }, + "keys": [ + "id", + "fingerprint", + "title", + "iac_file", + "iac_line", + "category", + "hunter_strategy", + "estimated_severity" + ], + "module": "cloudsecurity_af.schemas.views", + "nondeterministic": [] + }, + "FindingForProver": { + "dump": { + "attack_path_summary": "", + "benchmark_id": null, + "category": "", + "config_snippet": "", + "description": "", + "hunter_strategy": "", + "iac_file": "", + "iac_line": 0, + "id": "", + "resources_summary": "", + "title": "" + }, + "keys": [ + "id", + "title", + "description", + "category", + "hunter_strategy", + "iac_file", + "iac_line", + "config_snippet", + "resources_summary", + "attack_path_summary", + "benchmark_id" + ], + "module": "cloudsecurity_af.schemas.views", + "nondeterministic": [] + }, + "HuntResult": { + "dump": { + "deduplicated_count": 0, + "findings": [], + "hunt_duration_seconds": 0.0, + "strategies_run": [], + "total_raw": 0 + }, + "keys": [ + "findings", + "total_raw", + "deduplicated_count", + "strategies_run", + "hunt_duration_seconds" + ], + "module": "cloudsecurity_af.schemas.hunt", + "nondeterministic": [] + }, + "IaCDiff": { + "dump": { + "end_line": 0, + "file_path": "", + "original_lines": "", + "patched_lines": "", + "start_line": 0 + }, + "keys": [ + "file_path", + "original_lines", + "patched_lines", + "start_line", + "end_line" + ], + "module": "cloudsecurity_af.schemas.prove", + "nondeterministic": [] + }, + "Module": { + "dump": { + "file_path": null, + "name": "", + "source": "", + "version": null + }, + "keys": [ + "name", + "source", + "version", + "file_path" + ], + "module": "cloudsecurity_af.schemas.recon", + "nondeterministic": [] + }, + "Output": { + "dump": { + "description": null, + "file_path": null, + "name": "", + "value": null + }, + "keys": [ + "name", + "value", + "description", + "file_path" + ], + "module": "cloudsecurity_af.schemas.recon", + "nondeterministic": [] + }, + "PathInvestigationPlan": { + "dump": { + "investigations": [] + }, + "keys": [ + "investigations" + ], + "module": "cloudsecurity_af.agents.chain.path_constructor", + "nondeterministic": [] + }, + "Proof": { + "dump": { + "evidence": [], + "method": "static_analysis", + "scripts_executed": [], + "verification_tier": "static" + }, + "keys": [ + "method", + "evidence", + "scripts_executed", + "verification_tier" + ], + "module": "cloudsecurity_af.schemas.prove", + "nondeterministic": [] + }, + "ProviderConfig": { + "dump": { + "alias": null, + "name": "", + "region": null, + "version": null + }, + "keys": [ + "name", + "region", + "alias", + "version" + ], + "module": "cloudsecurity_af.schemas.recon", + "nondeterministic": [] + }, + "RawFinding": { + "dump": { + "benchmark_id": null, + "category": "", + "confidence": "medium", + "config_snippet": "", + "description": "", + "estimated_severity": "medium", + "fingerprint": "", + "hunter_strategy": "", + "iac_file": "", + "iac_line": 0, + "id": "", + "resources": [], + "title": "" + }, + "keys": [ + "id", + "hunter_strategy", + "title", + "description", + "category", + "resources", + "estimated_severity", + "confidence", + "iac_file", + "iac_line", + "config_snippet", + "benchmark_id", + "fingerprint" + ], + "module": "cloudsecurity_af.schemas.hunt", + "nondeterministic": [ + "id", + "fingerprint" + ] + }, + "ReconResult": { + "dump": { + "drift_report": null, + "iac_type": "terraform", + "inventory": { + "iac_type": "terraform", + "iac_version": null, + "inventory_saved_path": "", + "total_resources": 0 + }, + "live_inventory": null, + "providers_detected": [], + "recon_duration_seconds": 0.0, + "resource_graph": { + "graph_saved_path": "", + "total_edges": 0, + "total_nodes": 0 + }, + "total_edges": 0, + "total_resources": 0 + }, + "keys": [ + "inventory", + "resource_graph", + "drift_report", + "live_inventory", + "iac_type", + "providers_detected", + "total_resources", + "total_edges", + "recon_duration_seconds" + ], + "module": "cloudsecurity_af.schemas.recon", + "nondeterministic": [] + }, + "RemediationSuggestion": { + "dump": { + "alternative_approaches": [], + "breaking_change": false, + "description": "", + "diffs": [], + "downtime_estimate": null, + "effort": "moderate", + "finding_id": "" + }, + "keys": [ + "finding_id", + "description", + "diffs", + "breaking_change", + "downtime_estimate", + "effort", + "alternative_approaches" + ], + "module": "cloudsecurity_af.schemas.prove", + "nondeterministic": [] + }, + "Resource": { + "dump": { + "config": {}, + "file_path": "", + "id": "", + "line_number": 0, + "name": "", + "provider": "", + "referenced_by": [], + "references": [], + "type": "" + }, + "keys": [ + "id", + "type", + "name", + "provider", + "file_path", + "line_number", + "config", + "references", + "referenced_by" + ], + "module": "cloudsecurity_af.schemas.recon", + "nondeterministic": [] + }, + "ResourceGraph": { + "dump": { + "graph_saved_path": "", + "total_edges": 0, + "total_nodes": 0 + }, + "keys": [ + "graph_saved_path", + "total_nodes", + "total_edges" + ], + "module": "cloudsecurity_af.schemas.recon", + "nondeterministic": [] + }, + "ResourceInventory": { + "dump": { + "iac_type": "terraform", + "iac_version": null, + "inventory_saved_path": "", + "total_resources": 0 + }, + "keys": [ + "inventory_saved_path", + "total_resources", + "iac_type", + "iac_version" + ], + "module": "cloudsecurity_af.schemas.recon", + "nondeterministic": [] + }, + "ScanMetrics": { + "dump": { + "agent_invocations": 0, + "budget_exhausted": false, + "cost_breakdown": {}, + "cost_usd": 0.0, + "duration_seconds": 0.0, + "findings_not_verified": 0 + }, + "keys": [ + "duration_seconds", + "agent_invocations", + "cost_usd", + "cost_breakdown", + "budget_exhausted", + "findings_not_verified" + ], + "module": "cloudsecurity_af.schemas.output", + "nondeterministic": [] + }, + "ScanProgress": { + "dump": { + "agents_completed": 0, + "agents_running": 0, + "agents_total": 0, + "cost_so_far_usd": 0.0, + "elapsed_seconds": 0.0, + "estimated_remaining_seconds": 0.0, + "findings_so_far": 0, + "phase": "", + "phase_progress": 0.0 + }, + "keys": [ + "phase", + "phase_progress", + "agents_total", + "agents_completed", + "agents_running", + "findings_so_far", + "elapsed_seconds", + "estimated_remaining_seconds", + "cost_so_far_usd" + ], + "module": "cloudsecurity_af.schemas.output", + "nondeterministic": [] + }, + "Variable": { + "dump": { + "default": null, + "description": null, + "file_path": null, + "name": "", + "type": null + }, + "keys": [ + "name", + "type", + "default", + "description", + "file_path" + ], + "module": "cloudsecurity_af.schemas.recon", + "nondeterministic": [] + }, + "VerifiedFinding": { + "dump": { + "attack_path": null, + "category": "", + "compliance_mappings": [], + "config_snippet": "", + "description": "", + "drift": null, + "drop_reason": null, + "fingerprint": "", + "hunter_strategy": "", + "iac_file": "", + "iac_line": 0, + "id": "", + "proof": { + "evidence": [], + "method": "static_analysis", + "scripts_executed": [], + "verification_tier": "static" + }, + "remediation": null, + "resources": [], + "risk_score": 0.0, + "sarif_rule_id": "", + "sarif_security_severity": 0.0, + "severity": "medium", + "title": "", + "verdict": "confirmed" + }, + "keys": [ + "id", + "title", + "verdict", + "severity", + "category", + "resources", + "attack_path", + "drift", + "proof", + "compliance_mappings", + "risk_score", + "remediation", + "sarif_rule_id", + "sarif_security_severity", + "iac_file", + "iac_line", + "config_snippet", + "description", + "fingerprint", + "hunter_strategy", + "drop_reason" + ], + "module": "cloudsecurity_af.schemas.prove", + "nondeterministic": [ + "id", + "fingerprint" + ] + } +} diff --git a/go/internal/schemas/timestamp.go b/go/internal/schemas/timestamp.go new file mode 100644 index 0000000..1d88808 --- /dev/null +++ b/go/internal/schemas/timestamp.go @@ -0,0 +1,100 @@ +package schemas + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +// Timestamp wraps time.Time to reproduce how a pydantic `datetime` field crosses +// the AgentField JSON boundary. +// +// The Python node returns `result.model_dump()` from its reasoner; model_dump() +// leaves a datetime as a datetime, and FastAPI's jsonable_encoder then calls +// `datetime.isoformat()`. Verified against the venv interpreter: +// +// datetime(2026,1,2,3,4,5,123456,tzinfo=UTC) -> "2026-01-02T03:04:05.123456+00:00" +// datetime(2026,1,2,3,4,5, tzinfo=UTC) -> "2026-01-02T03:04:05+00:00" +// +// i.e. a numeric UTC offset (never "Z", which is what pydantic's OWN +// model_dump_json emits — that path is not the one the node takes), and the +// fractional part present with exactly 6 digits or omitted entirely when the +// microsecond field is zero. +// +// The only producer in this repo is orchestrator.py `datetime.now(UTC)` +// (CloudSecurityScanResult.timestamp), so the emitted offset is always "+00:00"; +// MarshalJSON nonetheless honors whatever zone the wrapped time carries, as +// isoformat() does. +type Timestamp struct { + time.Time +} + +// pyISOLayoutNoFraction / pyISOLayoutFraction are Go layouts for +// datetime.isoformat(). "-07:00" (rather than "Z07:00") forces the numeric +// offset form even for UTC, matching Python. +const ( + pyISOLayoutNoFraction = "2006-01-02T15:04:05-07:00" + pyISOLayoutFraction = "2006-01-02T15:04:05.000000-07:00" +) + +// NewTimestamp wraps t, truncated to microsecond resolution — Python's datetime +// has no sub-microsecond precision, so a Go time.Time carrying nanoseconds would +// otherwise round-trip differently. +func NewTimestamp(t time.Time) Timestamp { + return Timestamp{Time: t.Truncate(time.Microsecond)} +} + +// NowUTC ports `datetime.now(UTC)` — the only Timestamp producer in the node. +func NowUTC() Timestamp { + return NewTimestamp(time.Now().UTC()) +} + +// ISOFormat renders the value exactly as Python's datetime.isoformat() does. +// output/report.py and output/sarif.py interpolate `result.timestamp.isoformat()` +// into their text, so this must stay byte-identical for those ports too. +func (ts Timestamp) ISOFormat() string { + t := ts.Truncate(time.Microsecond) + if t.Nanosecond() == 0 { + return t.Format(pyISOLayoutNoFraction) + } + return t.Format(pyISOLayoutFraction) +} + +// MarshalJSON emits the ISOFormat string. +func (ts Timestamp) MarshalJSON() ([]byte, error) { + return json.Marshal(ts.ISOFormat()) +} + +// timestampParseLayouts covers both representations a Timestamp can arrive in: +// Python's isoformat (numeric offset, optional 6-digit fraction) and RFC 3339 +// with a "Z" designator — which is what pydantic's model_dump_json() produces, +// and what a Go or non-Python peer would send. Naive (offset-less) forms are +// accepted last and interpreted as UTC. +var timestampParseLayouts = []string{ + time.RFC3339Nano, // 2026-01-02T03:04:05.123456+00:00 and ...Z + time.RFC3339, // 2026-01-02T03:04:05+00:00 and ...Z + "2006-01-02T15:04:05.999999999", // naive with fraction (datetime.now() with no tz) + "2006-01-02T15:04:05", // naive without fraction +} + +// UnmarshalJSON accepts either representation (see timestampParseLayouts) and a +// JSON null, which yields the zero Timestamp. +func (ts *Timestamp) UnmarshalJSON(b []byte) error { + if string(b) == "null" { + *ts = Timestamp{} + return nil + } + var raw string + if err := json.Unmarshal(b, &raw); err != nil { + return fmt.Errorf("schemas: Timestamp must be a string: %w", err) + } + raw = strings.TrimSpace(raw) + for _, layout := range timestampParseLayouts { + if t, err := time.Parse(layout, raw); err == nil { + *ts = NewTimestamp(t) + return nil + } + } + return fmt.Errorf("schemas: %q is not a datetime.isoformat() or RFC 3339 timestamp", raw) +} diff --git a/go/internal/schemas/timestamp_test.go b/go/internal/schemas/timestamp_test.go new file mode 100644 index 0000000..385f18c --- /dev/null +++ b/go/internal/schemas/timestamp_test.go @@ -0,0 +1,167 @@ +package schemas + +import ( + "encoding/json" + "testing" + "time" +) + +// This file pins schemas.Timestamp against the two representations design §2 +// requires it to handle, with the expected strings verified against +// ~/.agentfield/packages/cloudsecurity-af/venv/bin/python: +// +// jsonable_encoder(CloudSecurityScanResult(...).model_dump())["timestamp"] +// datetime(2026,1,2,3,4,5,123456, tz=UTC) -> "2026-01-02T03:04:05.123456+00:00" +// datetime(2026,1,2,3,4,5, tz=UTC) -> "2026-01-02T03:04:05+00:00" +// CloudSecurityScanResult(...).model_dump_json() +// -> "2026-01-02T03:04:05.123456Z" (the OTHER representation, accepted on input) + +func TestTimestamp_MarshalMatchesPythonIsoformat(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 omits the fractional part entirely when microsecond == 0. + "microseconds zero", + time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), + `"2026-01-02T03:04:05+00:00"`, + }, + { + // isoformat() always pads to 6 digits when the fraction is present. + "trailing zeros padded to six digits", + time.Date(2026, 1, 2, 3, 4, 5, 120000000, time.UTC), + `"2026-01-02T03:04:05.120000+00:00"`, + }, + { + // Sub-microsecond precision does not exist in Python's datetime; + // NewTimestamp truncates so a Go time.Time round-trips identically. + "nanoseconds truncated to microseconds", + time.Date(2026, 1, 2, 3, 4, 5, 123456789, time.UTC), + `"2026-01-02T03:04:05.123456+00:00"`, + }, + { + // isoformat() renders whatever offset the datetime carries; the node + // only ever produces UTC, but a non-UTC zone must not become "Z". + "non-utc offset rendered numerically", + time.Date(2026, 1, 2, 3, 4, 5, 0, time.FixedZone("", -7*3600)), + `"2026-01-02T03:04:05-07:00"`, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := json.Marshal(NewTimestamp(c.in)) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(got) != c.want { + t.Errorf("got %s, want %s", got, c.want) + } + }) + } +} + +func TestTimestamp_ISOFormatMatchesMarshal(t *testing.T) { + // output/report.py and output/sarif.py interpolate + // `result.timestamp.isoformat()` directly into their text, so ISOFormat() + // must produce the same string MarshalJSON quotes. + ts := NewTimestamp(time.Date(2026, 1, 2, 3, 4, 5, 123456000, time.UTC)) + if got := ts.ISOFormat(); got != "2026-01-02T03:04:05.123456+00:00" { + t.Errorf("ISOFormat() = %q", got) + } + raw, err := json.Marshal(ts) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(raw) != `"`+ts.ISOFormat()+`"` { + t.Errorf("MarshalJSON %s does not wrap ISOFormat %q", raw, ts.ISOFormat()) + } +} + +func TestTimestamp_UnmarshalAcceptsBothRepresentations(t *testing.T) { + want := time.Date(2026, 1, 2, 3, 4, 5, 123456000, time.UTC) + cases := []struct { + name string + in string + want time.Time + }{ + {"python isoformat with offset", `"2026-01-02T03:04:05.123456+00:00"`, want}, + {"rfc3339 with Z", `"2026-01-02T03:04:05.123456Z"`, want}, + {"no fraction with offset", `"2026-01-02T03:04:05+00:00"`, want.Truncate(time.Second)}, + {"no fraction with Z", `"2026-01-02T03:04:05Z"`, want.Truncate(time.Second)}, + {"naive with fraction", `"2026-01-02T03:04:05.123456"`, want}, + {"naive without fraction", `"2026-01-02T03:04:05"`, want.Truncate(time.Second)}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var ts Timestamp + if err := json.Unmarshal([]byte(c.in), &ts); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !ts.Equal(c.want) { + t.Errorf("got %v, want %v", ts.Time, c.want) + } + }) + } +} + +func TestTimestamp_UnmarshalRejectsGarbageAndAcceptsNull(t *testing.T) { + var ts Timestamp + if err := json.Unmarshal([]byte(`"not a timestamp"`), &ts); err == nil { + t.Error("want an error for an unparseable timestamp") + } + if err := json.Unmarshal([]byte(`12345`), &ts); err == nil { + t.Error("want an error for a non-string timestamp") + } + ts = NewTimestamp(time.Now()) + if err := json.Unmarshal([]byte(`null`), &ts); err != nil { + t.Fatalf("null should decode to the zero Timestamp: %v", err) + } + if !ts.IsZero() { + t.Errorf("null decoded to %v, want the zero time", ts.Time) + } +} + +func TestTimestamp_RoundTripThroughScanResult(t *testing.T) { + // The end-to-end shape the control plane sees. + r := NewCloudSecurityScanResult() + r.Repository = "/tmp/repo" + r.CommitSHA = "abc123" + r.DepthProfile = "standard" + r.Tier = 1 + r.Timestamp = NewTimestamp(time.Date(2026, 1, 2, 3, 4, 5, 123456000, time.UTC)) + + raw, err := json.Marshal(r) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded CloudSecurityScanResult + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !decoded.Timestamp.Equal(r.Timestamp.Time) { + t.Errorf("round trip changed the timestamp: %v -> %v", r.Timestamp.Time, decoded.Timestamp.Time) + } + if got := mustJSONMap(t, r)["timestamp"]; got != "2026-01-02T03:04:05.123456+00:00" { + t.Errorf("timestamp key = %v", got) + } +} + +func TestNowUTC(t *testing.T) { + ts := NowUTC() + if ts.Location() != time.UTC { + t.Errorf("NowUTC location = %v, want UTC", ts.Location()) + } + if ts.Nanosecond()%1000 != 0 { + t.Errorf("NowUTC must be truncated to microsecond resolution, got %d ns", ts.Nanosecond()) + } + if d := time.Since(ts.Time); d < -time.Second || d > time.Minute { + t.Errorf("NowUTC is %v away from now", d) + } +} diff --git a/go/internal/schemas/uuid.go b/go/internal/schemas/uuid.go new file mode 100644 index 0000000..2d08bb0 --- /dev/null +++ b/go/internal/schemas/uuid.go @@ -0,0 +1,41 @@ +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. "1f6b9e5a-e48b-4a18-9f72-1047fec0d078". +// +// It ports Python's `str(uuid4())`, which several models use as a +// default_factory. Implemented on crypto/rand rather than github.com/google/uuid +// because the port adds no third-party dependencies beyond the SDK (design §0.6). +// +// Layout (RFC 4122 §4.4): 16 random bytes, then byte 6's high nibble forced to +// 4 (the version) and byte 8's two high bits forced to 10 (the variant). +// +// A crypto/rand failure is unrecoverable and cannot be reported through a +// pydantic-shaped default_factory, so it panics — the same posture as +// google/uuid's uuid.New(). +func NewUUID4() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + panic(fmt.Sprintf("schemas: crypto/rand failed generating a uuid4: %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..4bd8ff5 --- /dev/null +++ b/go/internal/schemas/uuid_test.go @@ -0,0 +1,44 @@ +package schemas + +import ( + "regexp" + "strings" + "testing" +) + +// NewUUID4 replaces Python's `str(uuid4())` without adding a dependency, so +// these tests assert the exact textual shape and the RFC 4122 version/variant +// bits a consumer might rely on. + +var uuidShape = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +func TestNewUUID4_Shape(t *testing.T) { + for i := 0; i < 200; i++ { + u := NewUUID4() + if len(u) != 36 { + t.Fatalf("length %d, want 36: %q", len(u), u) + } + if !uuidShape.MatchString(u) { + t.Fatalf("%q is not canonical lowercase 8-4-4-4-12 hex", u) + } + // RFC 4122 §4.4: version nibble is 4, variant high bits are 10xx. + if u[14] != '4' { + t.Fatalf("%q: version nibble = %q, want '4'", u, u[14]) + } + if !strings.ContainsRune("89ab", rune(u[19])) { + t.Fatalf("%q: variant nibble = %q, want one of 8/9/a/b", u, u[19]) + } + } +} + +func TestNewUUID4_Unique(t *testing.T) { + const n = 5000 + seen := make(map[string]struct{}, n) + for i := 0; i < n; i++ { + u := NewUUID4() + if _, dup := seen[u]; dup { + t.Fatalf("collision after %d draws: %q", i, u) + } + seen[u] = struct{}{} + } +} diff --git a/go/internal/schemas/views.go b/go/internal/schemas/views.go new file mode 100644 index 0000000..bbb4812 --- /dev/null +++ b/go/internal/schemas/views.go @@ -0,0 +1,54 @@ +package schemas + +// This file ports src/cloudsecurity_af/schemas/views.py — phase-boundary view +// models for context-specific data passing. +// +// These provide minimal projections of complex schemas for specific consumers, +// following the Composite Intelligence principle of contextual fidelity. + +// FindingForDedup ports views.py FindingForDedup: the minimal fields needed for +// deduplication. Produced by RawFinding.ForDedup(). +// +// Python parity: EstimatedSeverity is a plain `str` here, not a Severity enum — +// for_dedup() passes `self.estimated_severity.value`. +type FindingForDedup struct { + ID string `json:"id"` + Fingerprint string `json:"fingerprint"` + Title string `json:"title"` + IaCFile string `json:"iac_file"` + IaCLine int `json:"iac_line"` + Category string `json:"category"` + HunterStrategy string `json:"hunter_strategy"` + EstimatedSeverity string `json:"estimated_severity"` +} + +// FindingForProver ports views.py FindingForProver: what the prover pipeline +// needs from a RawFinding plus its attack path. +type FindingForProver struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Category string `json:"category"` + HunterStrategy string `json:"hunter_strategy"` + IaCFile string `json:"iac_file"` + IaCLine int `json:"iac_line"` + ConfigSnippet string `json:"config_snippet"` + // ResourcesSummary is a natural language summary of affected resources. + ResourcesSummary string `json:"resources_summary"` + // AttackPathSummary is a natural language summary of the attack path (if any). + AttackPathSummary string `json:"attack_path_summary"` + BenchmarkID *string `json:"benchmark_id"` +} + +// FindingForChain ports views.py FindingForChain: what the chain constructor +// needs from a RawFinding. +type FindingForChain struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Category string `json:"category"` + // Resources holds the resource IDs affected by this finding. + Resources []string `json:"resources"` + EstimatedSeverity string `json:"estimated_severity"` + Confidence string `json:"confidence"` +} diff --git a/go/internal/scoring/scoring.go b/go/internal/scoring/scoring.go new file mode 100644 index 0000000..50ca8e6 --- /dev/null +++ b/go/internal/scoring/scoring.go @@ -0,0 +1,327 @@ +// Package scoring is the deterministic risk-scoring engine for CloudSecurity AF — +// a 1:1 port of src/cloudsecurity_af/scoring.py. +// +// It owns the three vocabularies the rest of the node scores against (Severity, +// EvidenceMethod, Exposure), the weight/multiplier tables, the CIS benchmark +// severity floors, and the two pure functions the orchestrator calls +// (ComputeRiskScore, ApplyBenchmarkSeverityFloor) plus SeverityLabelFromScore. +// +// Import direction (mirrors Python): schemas/*.py does `from ..scoring import +// Severity`, so internal/schemas imports internal/scoring and NEVER the reverse. +// Keeping Severity here — rather than in internal/schemas — is what makes that +// acyclic. +// +// Parity notes: +// - Python `round(x, 2)` is round-half-to-even on the true binary value. +// Go's math.Round is half-away-from-zero and would diverge (2.125 -> 2.13 +// instead of 2.12), so ComputeRiskScore delegates to pyfmt.Round. +// - Python's `SEVERITY_WEIGHTS[severity.value]` / `EVIDENCE_MULTIPLIERS[m]` / +// `EXPOSURE_MULTIPLIERS[e]` raise KeyError on an unknown member. Go map +// lookups yield the zero value instead, so a hand-built Severity("bogus") +// scores 0.0 here where Python would raise. The enum types are closed +// (Valid()/ParseX + a strict UnmarshalJSON), so this is unreachable for any +// value that came in over the wire; it is documented rather than papered +// over with a panic. +package scoring + +import ( + "encoding/json" + "fmt" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" +) + +// Severity ports scoring.py `class Severity(str, Enum)`. +type Severity string + +// The five severity members, values exactly as Python. +const ( + SeverityCritical Severity = "critical" + SeverityHigh Severity = "high" + SeverityMedium Severity = "medium" + SeverityLow Severity = "low" + SeverityInfo Severity = "info" +) + +// AllSeverities lists every Severity member 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 +} + +// String returns the raw enum value ("critical", …), matching Python's +// `Severity.CRITICAL.value` (the member is a str subclass). +func (s Severity) String() string { return string(s) } + +// ParseSeverity ports `Severity(value)`: an unknown value is an error, exactly +// as pydantic/enum raises ValueError. +func ParseSeverity(v string) (Severity, error) { + s := Severity(v) + if !s.Valid() { + return "", fmt.Errorf("scoring: %q is not a valid Severity", v) + } + return s, nil +} + +// UnmarshalJSON is strict, mirroring pydantic: an unknown value (or null, or a +// non-string) is a validation error rather than a silent coercion. This is a +// deliberate difference from pr-af, whose Severity carries a BeforeValidator. +func (s *Severity) UnmarshalJSON(b []byte) error { + var raw string + if err := json.Unmarshal(b, &raw); err != nil { + return fmt.Errorf("scoring: Severity must be a string: %w", err) + } + parsed, err := ParseSeverity(raw) + if err != nil { + return err + } + *s = parsed + return nil +} + +// EvidenceMethod ports scoring.py `class EvidenceMethod(str, Enum)`. +type EvidenceMethod string + +// The six evidence methods, values exactly as Python. +const ( + EvidenceMethodLiveVerified EvidenceMethod = "live_verified" + EvidenceMethodIAMSimulated EvidenceMethod = "iam_simulated" + EvidenceMethodDriftConfirmed EvidenceMethod = "drift_confirmed" + EvidenceMethodStaticGraphConfirmed EvidenceMethod = "static_graph_confirmed" + EvidenceMethodStaticConfigMatch EvidenceMethod = "static_config_match" + EvidenceMethodHeuristicMatch EvidenceMethod = "heuristic_match" +) + +// AllEvidenceMethods lists every EvidenceMethod member in Python declaration order. +var AllEvidenceMethods = []EvidenceMethod{ + EvidenceMethodLiveVerified, + EvidenceMethodIAMSimulated, + EvidenceMethodDriftConfirmed, + EvidenceMethodStaticGraphConfirmed, + EvidenceMethodStaticConfigMatch, + EvidenceMethodHeuristicMatch, +} + +// Valid reports whether m is one of the declared members. +func (m EvidenceMethod) Valid() bool { + for _, v := range AllEvidenceMethods { + if m == v { + return true + } + } + return false +} + +// String returns the raw enum value. +func (m EvidenceMethod) String() string { return string(m) } + +// ParseEvidenceMethod ports `EvidenceMethod(value)`. +func ParseEvidenceMethod(v string) (EvidenceMethod, error) { + m := EvidenceMethod(v) + if !m.Valid() { + return "", fmt.Errorf("scoring: %q is not a valid EvidenceMethod", v) + } + return m, nil +} + +// UnmarshalJSON is strict, mirroring pydantic. +func (m *EvidenceMethod) UnmarshalJSON(b []byte) error { + var raw string + if err := json.Unmarshal(b, &raw); err != nil { + return fmt.Errorf("scoring: EvidenceMethod must be a string: %w", err) + } + parsed, err := ParseEvidenceMethod(raw) + if err != nil { + return err + } + *m = parsed + return nil +} + +// Exposure ports scoring.py `class Exposure(str, Enum)`. +type Exposure string + +// The five exposure levels, values exactly as Python. +const ( + ExposureInternetFacing Exposure = "internet_facing" + ExposureVPCInternal Exposure = "vpc_internal" + ExposurePrivateSubnet Exposure = "private_subnet" + ExposureRequiresIAMAuth Exposure = "requires_iam_auth" + ExposureRequiresAdmin Exposure = "requires_admin" +) + +// AllExposures lists every Exposure member in Python declaration order. +var AllExposures = []Exposure{ + ExposureInternetFacing, + ExposureVPCInternal, + ExposurePrivateSubnet, + ExposureRequiresIAMAuth, + ExposureRequiresAdmin, +} + +// Valid reports whether e is one of the declared members. +func (e Exposure) Valid() bool { + for _, v := range AllExposures { + if e == v { + return true + } + } + return false +} + +// String returns the raw enum value. +func (e Exposure) String() string { return string(e) } + +// ParseExposure ports `Exposure(value)`. +func ParseExposure(v string) (Exposure, error) { + e := Exposure(v) + if !e.Valid() { + return "", fmt.Errorf("scoring: %q is not a valid Exposure", v) + } + return e, nil +} + +// UnmarshalJSON is strict, mirroring pydantic. +func (e *Exposure) UnmarshalJSON(b []byte) error { + var raw string + if err := json.Unmarshal(b, &raw); err != nil { + return fmt.Errorf("scoring: Exposure must be a string: %w", err) + } + parsed, err := ParseExposure(raw) + if err != nil { + return err + } + *e = parsed + return nil +} + +// SeverityWeights ports scoring.py SEVERITY_WEIGHTS. Keyed by the severity +// *value* string (Python indexes it with `severity.value`). +var SeverityWeights = map[string]float64{ + "critical": 10.0, + "high": 8.0, + "medium": 5.0, + "low": 3.0, + "info": 1.0, +} + +// EvidenceMultipliers ports scoring.py EVIDENCE_MULTIPLIERS. +var EvidenceMultipliers = map[EvidenceMethod]float64{ + EvidenceMethodLiveVerified: 1.0, + EvidenceMethodIAMSimulated: 0.9, + EvidenceMethodDriftConfirmed: 0.85, + EvidenceMethodStaticGraphConfirmed: 0.7, + EvidenceMethodStaticConfigMatch: 0.5, + EvidenceMethodHeuristicMatch: 0.2, +} + +// ExposureMultipliers ports scoring.py EXPOSURE_MULTIPLIERS. +var ExposureMultipliers = map[Exposure]float64{ + ExposureInternetFacing: 1.0, + ExposureVPCInternal: 0.7, + ExposurePrivateSubnet: 0.5, + ExposureRequiresIAMAuth: 0.4, + ExposureRequiresAdmin: 0.2, +} + +// benchmarkSeverityFloors ports scoring.py _BENCHMARK_SEVERITY_FLOORS — CIS AWS +// controls that must never be reported below a given severity. Unexported, +// matching the Python leading underscore. +var benchmarkSeverityFloors = map[string]string{ + // CIS AWS controls that should never be reported below certain severity + "CIS-AWS-1.4": "critical", // root account MFA + "CIS-AWS-1.5": "critical", // root account access keys + "CIS-AWS-2.1.1": "high", // S3 bucket public access + "CIS-AWS-2.1.2": "high", // S3 bucket encryption + "CIS-AWS-2.2.1": "high", // EBS encryption + "CIS-AWS-3.1": "high", // CloudTrail enabled + "CIS-AWS-4.1": "high", // security group ingress 0.0.0.0/0 + "CIS-AWS-5.1": "high", // VPC flow logs +} + +// severityOrder ports scoring.py _SEVERITY_ORDER — the rank used to decide +// whether a floor is an upgrade. +var severityOrder = map[string]int{ + "critical": 4, + "high": 3, + "medium": 2, + "low": 1, + "info": 0, +} + +// ApplyBenchmarkSeverityFloor ports scoring.py apply_benchmark_severity_floor. +// +// benchmarkID is `str | None`; a nil pointer is Python's None and returns +// currentSeverity untouched, as does an ID with no floor entry. The floor only +// ever *raises* severity — Python parity: the comparison is strictly greater, so +// an equal or lower floor is a no-op. +func ApplyBenchmarkSeverityFloor(benchmarkID *string, currentSeverity Severity) Severity { + if benchmarkID == nil { + return currentSeverity + } + floorLabel, ok := benchmarkSeverityFloors[*benchmarkID] + if !ok { + return currentSeverity + } + // Python parity: `_SEVERITY_ORDER.get(label, 0)` — an unknown label ranks 0. + if severityOrder[floorLabel] > severityOrder[currentSeverity.String()] { + return Severity(floorLabel) + } + return currentSeverity +} + +// ComputeRiskScore ports scoring.py compute_risk_score. +// +// score = severity_weight * evidence_mult * exposure_mult * path_bonus * drift_bonus +// return round(min(max(score, 0.0), 10.0), 2) +// +// hasAttackPath and hasDrift are keyword-only in Python (`*, has_attack_path, +// has_drift`); Go takes them positionally in the same order. +func ComputeRiskScore(severity Severity, evidenceMethod EvidenceMethod, exposure Exposure, hasAttackPath, hasDrift bool) float64 { + severityWeight := SeverityWeights[severity.String()] + evidenceMult := EvidenceMultipliers[evidenceMethod] + exposureMult := ExposureMultipliers[exposure] + pathBonus := 1.0 + if hasAttackPath { + pathBonus = 2.0 + } + driftBonus := 1.0 + if hasDrift { + driftBonus = 1.3 + } + + score := severityWeight * evidenceMult * exposureMult * pathBonus * driftBonus + if score < 0.0 { + score = 0.0 + } + if score > 10.0 { + score = 10.0 + } + return pyfmt.Round(score, 2) +} + +// SeverityLabelFromScore ports scoring.py severity_label_from_score. It returns +// a plain string (Python returns str, not Severity) so callers must convert +// explicitly if they want the enum. +func SeverityLabelFromScore(score float64) string { + if score >= 9.0 { + return "critical" + } + if score >= 7.0 { + return "high" + } + if score >= 4.0 { + return "medium" + } + if score >= 1.0 { + return "low" + } + return "info" +} diff --git a/go/internal/scoring/scoring_test.go b/go/internal/scoring/scoring_test.go new file mode 100644 index 0000000..fd86787 --- /dev/null +++ b/go/internal/scoring/scoring_test.go @@ -0,0 +1,381 @@ +package scoring + +import ( + "encoding/json" + "math" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" +) + +// This file ports tests/test_scoring.py 1:1. Every Go test names the Python +// class::test it mirrors so reviewers can diff coverage, plus a handful of extra +// tests that pin behavior the Python suite only exercises implicitly (the +// exhaustive score matrix, the rounding mode, and the enum helpers). + +// approx mirrors pytest.approx(expected, abs=tol). +func approx(t *testing.T, label string, got, want, tol float64) { + t.Helper() + if math.Abs(got-want) > tol { + t.Errorf("%s: got %v, want %v (+/- %v)", label, got, want, tol) + } +} + +// --- TestComputeRiskScore (tests/test_scoring.py::TestComputeRiskScore) ----- + +// test_critical_live_internet +func TestComputeRiskScore_CriticalLiveInternet(t *testing.T) { + score := ComputeRiskScore(SeverityCritical, EvidenceMethodLiveVerified, ExposureInternetFacing, false, false) + if score != 10.0 { + t.Fatalf("got %v, want 10.0", score) + } +} + +// test_info_heuristic_admin — pytest.approx(0.04, abs=0.01); Python's exact +// value is 0.04, so this asserts equality as well as the tolerance. +func TestComputeRiskScore_InfoHeuristicAdmin(t *testing.T) { + score := ComputeRiskScore(SeverityInfo, EvidenceMethodHeuristicMatch, ExposureRequiresAdmin, false, false) + approx(t, "info/heuristic/admin", score, 0.04, 0.01) + if score != 0.04 { + t.Fatalf("exact parity: got %v, want 0.04", score) + } +} + +// test_attack_path_bonus +func TestComputeRiskScore_AttackPathBonus(t *testing.T) { + base := ComputeRiskScore(SeverityHigh, EvidenceMethodStaticConfigMatch, ExposureVPCInternal, false, false) + withPath := ComputeRiskScore(SeverityHigh, EvidenceMethodStaticConfigMatch, ExposureVPCInternal, true, false) + approx(t, "attack path bonus", withPath, base*2.0, 0.01) + // Exact Python values, checked against the interpreter. + if base != 2.8 || withPath != 5.6 { + t.Fatalf("exact parity: base=%v withPath=%v, want 2.8 / 5.6", base, withPath) + } +} + +// test_drift_bonus +func TestComputeRiskScore_DriftBonus(t *testing.T) { + base := ComputeRiskScore(SeverityMedium, EvidenceMethodDriftConfirmed, ExposurePrivateSubnet, false, false) + withDrift := ComputeRiskScore(SeverityMedium, EvidenceMethodDriftConfirmed, ExposurePrivateSubnet, false, true) + approx(t, "drift bonus", withDrift, base*1.3, 0.01) + // Exact Python values: 5*0.85*0.5 = 2.125 -> round-half-EVEN -> 2.12 + // (math.Round would give 2.13), and 2.7625 -> 2.76. + if base != 2.12 || withDrift != 2.76 { + t.Fatalf("exact parity: base=%v withDrift=%v, want 2.12 / 2.76", base, withDrift) + } +} + +// test_score_clamped_at_10 +func TestComputeRiskScore_ClampedAt10(t *testing.T) { + score := ComputeRiskScore(SeverityCritical, EvidenceMethodLiveVerified, ExposureInternetFacing, true, true) + if score != 10.0 { + t.Fatalf("got %v, want 10.0", score) + } +} + +// test_score_non_negative +func TestComputeRiskScore_NonNegative(t *testing.T) { + score := ComputeRiskScore(SeverityInfo, EvidenceMethodHeuristicMatch, ExposureRequiresAdmin, false, false) + if score < 0.0 { + t.Fatalf("got %v, want >= 0", score) + } +} + +// TestComputeRiskScore_PythonMatrix replays every (severity x evidence x +// exposure x path x drift) combination captured from the Python implementation +// by scripts/gen_scoring_matrix.py and requires bit-identical float64s. This is +// the test that would catch a rounding-mode regression anywhere in the table. +func TestComputeRiskScore_PythonMatrix(t *testing.T) { + type row struct { + Severity string `json:"severity"` + EvidenceMethod string `json:"evidence_method"` + Exposure string `json:"exposure"` + HasAttackPath bool `json:"has_attack_path"` + HasDrift bool `json:"has_drift"` + Score float64 `json:"score"` + } + raw, err := os.ReadFile(filepath.Join("testdata", "risk_score_matrix.json")) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + var rows []row + if err := json.Unmarshal(raw, &rows); err != nil { + t.Fatalf("decode fixture: %v", err) + } + want := len(AllSeverities) * len(AllEvidenceMethods) * len(AllExposures) * 2 * 2 + if len(rows) != want { + t.Fatalf("fixture has %d rows, want %d — regenerate with scripts/gen_scoring_matrix.py", len(rows), want) + } + for _, r := range rows { + sev, err := ParseSeverity(r.Severity) + if err != nil { + t.Fatalf("fixture severity %q: %v", r.Severity, err) + } + ev, err := ParseEvidenceMethod(r.EvidenceMethod) + if err != nil { + t.Fatalf("fixture evidence %q: %v", r.EvidenceMethod, err) + } + exp, err := ParseExposure(r.Exposure) + if err != nil { + t.Fatalf("fixture exposure %q: %v", r.Exposure, err) + } + got := ComputeRiskScore(sev, ev, exp, r.HasAttackPath, r.HasDrift) + if got != r.Score { + t.Errorf("ComputeRiskScore(%s, %s, %s, path=%v, drift=%v) = %v, want %v", + r.Severity, r.EvidenceMethod, r.Exposure, r.HasAttackPath, r.HasDrift, got, r.Score) + } + } +} + +// --- TestBenchmarkSeverityFloor (tests/test_scoring.py::TestBenchmarkSeverityFloor) + +func strptr(s string) *string { return &s } + +// test_floor_upgrades_severity +func TestBenchmarkSeverityFloor_FloorUpgradesSeverity(t *testing.T) { + if got := ApplyBenchmarkSeverityFloor(strptr("CIS-AWS-1.4"), SeverityLow); got != SeverityCritical { + t.Fatalf("got %q, want %q", got, SeverityCritical) + } +} + +// test_floor_no_downgrade +func TestBenchmarkSeverityFloor_NoDowngrade(t *testing.T) { + if got := ApplyBenchmarkSeverityFloor(strptr("CIS-AWS-2.1.1"), SeverityCritical); got != SeverityCritical { + t.Fatalf("got %q, want %q", got, SeverityCritical) + } +} + +// test_unknown_benchmark_passthrough +func TestBenchmarkSeverityFloor_UnknownBenchmarkPassthrough(t *testing.T) { + if got := ApplyBenchmarkSeverityFloor(strptr("CIS-AWS-99.99"), SeverityLow); got != SeverityLow { + t.Fatalf("got %q, want %q", got, SeverityLow) + } +} + +// test_none_benchmark_passthrough — Python passes None; Go passes a nil *string. +func TestBenchmarkSeverityFloor_NoneBenchmarkPassthrough(t *testing.T) { + if got := ApplyBenchmarkSeverityFloor(nil, SeverityMedium); got != SeverityMedium { + t.Fatalf("got %q, want %q", got, SeverityMedium) + } +} + +// TestBenchmarkSeverityFloor_Table walks every entry in the floor table with a +// severity below, equal to, and above the floor. Not in the Python suite; it +// pins the full table (8 controls) against accidental edits. +func TestBenchmarkSeverityFloor_Table(t *testing.T) { + wantFloors := map[string]Severity{ + "CIS-AWS-1.4": SeverityCritical, + "CIS-AWS-1.5": SeverityCritical, + "CIS-AWS-2.1.1": SeverityHigh, + "CIS-AWS-2.1.2": SeverityHigh, + "CIS-AWS-2.2.1": SeverityHigh, + "CIS-AWS-3.1": SeverityHigh, + "CIS-AWS-4.1": SeverityHigh, + "CIS-AWS-5.1": SeverityHigh, + } + if len(benchmarkSeverityFloors) != len(wantFloors) { + t.Fatalf("floor table has %d entries, want %d", len(benchmarkSeverityFloors), len(wantFloors)) + } + for id, floor := range wantFloors { + // Below the floor -> upgraded to the floor. + if got := ApplyBenchmarkSeverityFloor(strptr(id), SeverityInfo); got != floor { + t.Errorf("%s from info: got %q, want %q", id, got, floor) + } + // At the floor -> unchanged. + if got := ApplyBenchmarkSeverityFloor(strptr(id), floor); got != floor { + t.Errorf("%s at floor: got %q, want %q", id, got, floor) + } + // Above the floor -> never downgraded. + if got := ApplyBenchmarkSeverityFloor(strptr(id), SeverityCritical); got != SeverityCritical { + t.Errorf("%s from critical: got %q, want %q", id, got, SeverityCritical) + } + } +} + +// --- TestSeverityLabelFromScore (tests/test_scoring.py::TestSeverityLabelFromScore) + +// test_label_mapping (all 10 parametrize cases) +func TestSeverityLabelFromScore_LabelMapping(t *testing.T) { + cases := []struct { + score float64 + want string + }{ + {10.0, "critical"}, + {9.0, "critical"}, + {8.5, "high"}, + {7.0, "high"}, + {5.0, "medium"}, + {4.0, "medium"}, + {2.0, "low"}, + {1.0, "low"}, + {0.5, "info"}, + {0.0, "info"}, + } + for _, c := range cases { + if got := SeverityLabelFromScore(c.score); got != c.want { + t.Errorf("SeverityLabelFromScore(%v) = %q, want %q", c.score, got, c.want) + } + } +} + +// --- Extra coverage not present in tests/test_scoring.py --------------------- + +// TestRounding pins the parity trap at the seam scoring actually uses: +// Python round() is round-half-to-even on the true binary value, while Go's +// math.Round is half-away-from-zero. The implementation lives in +// internal/pyfmt (which has its own, broader suite); this asserts the values +// scoring itself depends on, captured from the venv interpreter. +func TestRounding(t *testing.T) { + cases := []struct { + in float64 + digits int + want float64 + }{ + {0.125, 2, 0.12}, // ties-to-even rounds DOWN (math.Round -> 0.13) + {0.0625, 2, 0.06}, // ties-to-even rounds DOWN + {2.675, 2, 2.67}, // binary repr is just below the tie + {1.005, 2, 1.0}, // binary repr is just below the tie + {2.125, 2, 2.12}, // the drift-bonus case from TestComputeRiskScore + {2.7625, 2, 2.76}, // 2.125 * 1.3 + {0.04, 2, 0.04}, // exact + {10.0, 2, 10.0}, // exact + {0.135, 2, 0.14}, // ties-to-even rounds UP here + } + for _, c := range cases { + if got := pyfmt.Round(c.in, c.digits); got != c.want { + t.Errorf("pyfmt.Round(%v, %d) = %v, want %v", c.in, c.digits, got, c.want) + } + } +} + +// TestSeverityEnum pins the member values and the strict Parse/Valid helpers. +func TestSeverityEnum(t *testing.T) { + want := []string{"critical", "high", "medium", "low", "info"} + if len(AllSeverities) != len(want) { + t.Fatalf("AllSeverities has %d members, want %d", len(AllSeverities), len(want)) + } + for i, w := range want { + if AllSeverities[i].String() != w { + t.Errorf("AllSeverities[%d] = %q, want %q", i, AllSeverities[i], w) + } + if !AllSeverities[i].Valid() { + t.Errorf("%q should be Valid", AllSeverities[i]) + } + } + if Severity("bogus").Valid() { + t.Error(`Severity("bogus") should not be Valid`) + } + if _, err := ParseSeverity("bogus"); err == nil { + t.Error("ParseSeverity(bogus) should error, matching pydantic ValidationError") + } + if _, err := ParseSeverity("CRITICAL"); err == nil { + t.Error("ParseSeverity is case-sensitive like Python's Enum(value)") + } +} + +// TestEvidenceAndExposureEnums pins the remaining two vocabularies. +func TestEvidenceAndExposureEnums(t *testing.T) { + wantEvidence := []string{ + "live_verified", "iam_simulated", "drift_confirmed", + "static_graph_confirmed", "static_config_match", "heuristic_match", + } + for i, w := range wantEvidence { + if AllEvidenceMethods[i].String() != w { + t.Errorf("AllEvidenceMethods[%d] = %q, want %q", i, AllEvidenceMethods[i], w) + } + } + if len(AllEvidenceMethods) != len(wantEvidence) { + t.Errorf("AllEvidenceMethods has %d members, want %d", len(AllEvidenceMethods), len(wantEvidence)) + } + wantExposure := []string{ + "internet_facing", "vpc_internal", "private_subnet", + "requires_iam_auth", "requires_admin", + } + for i, w := range wantExposure { + if AllExposures[i].String() != w { + t.Errorf("AllExposures[%d] = %q, want %q", i, AllExposures[i], w) + } + } + if len(AllExposures) != len(wantExposure) { + t.Errorf("AllExposures has %d members, want %d", len(AllExposures), len(wantExposure)) + } + if _, err := ParseEvidenceMethod("nope"); err == nil { + t.Error("ParseEvidenceMethod(nope) should error") + } + if _, err := ParseExposure("nope"); err == nil { + t.Error("ParseExposure(nope) should error") + } +} + +// TestEnumJSONRoundTrip checks that the enums marshal to their Python value and +// that UnmarshalJSON is strict (pydantic raises for an unknown member or null). +func TestEnumJSONRoundTrip(t *testing.T) { + b, err := json.Marshal(SeverityHigh) + if err != nil || string(b) != `"high"` { + t.Fatalf("marshal Severity: %s, %v", b, err) + } + var s Severity + if err := json.Unmarshal([]byte(`"low"`), &s); err != nil || s != SeverityLow { + t.Fatalf("unmarshal Severity: %q, %v", s, err) + } + if err := json.Unmarshal([]byte(`"bogus"`), &s); err == nil { + t.Error("unmarshal of an unknown Severity should error (pydantic parity)") + } + if err := json.Unmarshal([]byte(`null`), &s); err == nil { + t.Error("unmarshal of null into Severity should error (pydantic parity)") + } + var m EvidenceMethod + if err := json.Unmarshal([]byte(`"drift_confirmed"`), &m); err != nil || m != EvidenceMethodDriftConfirmed { + t.Fatalf("unmarshal EvidenceMethod: %q, %v", m, err) + } + var e Exposure + if err := json.Unmarshal([]byte(`"vpc_internal"`), &e); err != nil || e != ExposureVPCInternal { + t.Fatalf("unmarshal Exposure: %q, %v", e, err) + } +} + +// TestMultiplierTables pins the three weight/multiplier tables byte-exact +// against scoring.py. +func TestMultiplierTables(t *testing.T) { + wantSeverity := map[string]float64{"critical": 10.0, "high": 8.0, "medium": 5.0, "low": 3.0, "info": 1.0} + if len(SeverityWeights) != len(wantSeverity) { + t.Fatalf("SeverityWeights has %d entries, want %d", len(SeverityWeights), len(wantSeverity)) + } + for k, v := range wantSeverity { + if SeverityWeights[k] != v { + t.Errorf("SeverityWeights[%q] = %v, want %v", k, SeverityWeights[k], v) + } + } + wantEvidence := map[EvidenceMethod]float64{ + EvidenceMethodLiveVerified: 1.0, + EvidenceMethodIAMSimulated: 0.9, + EvidenceMethodDriftConfirmed: 0.85, + EvidenceMethodStaticGraphConfirmed: 0.7, + EvidenceMethodStaticConfigMatch: 0.5, + EvidenceMethodHeuristicMatch: 0.2, + } + if len(EvidenceMultipliers) != len(wantEvidence) { + t.Fatalf("EvidenceMultipliers has %d entries, want %d", len(EvidenceMultipliers), len(wantEvidence)) + } + for k, v := range wantEvidence { + if EvidenceMultipliers[k] != v { + t.Errorf("EvidenceMultipliers[%q] = %v, want %v", k, EvidenceMultipliers[k], v) + } + } + wantExposure := map[Exposure]float64{ + ExposureInternetFacing: 1.0, + ExposureVPCInternal: 0.7, + ExposurePrivateSubnet: 0.5, + ExposureRequiresIAMAuth: 0.4, + ExposureRequiresAdmin: 0.2, + } + if len(ExposureMultipliers) != len(wantExposure) { + t.Fatalf("ExposureMultipliers has %d entries, want %d", len(ExposureMultipliers), len(wantExposure)) + } + for k, v := range wantExposure { + if ExposureMultipliers[k] != v { + t.Errorf("ExposureMultipliers[%q] = %v, want %v", k, ExposureMultipliers[k], v) + } + } +} diff --git a/go/internal/scoring/testdata/risk_score_matrix.json b/go/internal/scoring/testdata/risk_score_matrix.json new file mode 100644 index 0000000..9b5e58a --- /dev/null +++ b/go/internal/scoring/testdata/risk_score_matrix.json @@ -0,0 +1,4802 @@ +[ + { + "severity": "critical", + "evidence_method": "live_verified", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "live_verified", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "live_verified", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "live_verified", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "live_verified", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 7.0 + }, + { + "severity": "critical", + "evidence_method": "live_verified", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 9.1 + }, + { + "severity": "critical", + "evidence_method": "live_verified", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "live_verified", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "live_verified", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 5.0 + }, + { + "severity": "critical", + "evidence_method": "live_verified", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 6.5 + }, + { + "severity": "critical", + "evidence_method": "live_verified", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "live_verified", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "live_verified", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 4.0 + }, + { + "severity": "critical", + "evidence_method": "live_verified", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 5.2 + }, + { + "severity": "critical", + "evidence_method": "live_verified", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 8.0 + }, + { + "severity": "critical", + "evidence_method": "live_verified", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "live_verified", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 2.0 + }, + { + "severity": "critical", + "evidence_method": "live_verified", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 2.6 + }, + { + "severity": "critical", + "evidence_method": "live_verified", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 4.0 + }, + { + "severity": "critical", + "evidence_method": "live_verified", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 5.2 + }, + { + "severity": "critical", + "evidence_method": "iam_simulated", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 9.0 + }, + { + "severity": "critical", + "evidence_method": "iam_simulated", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "iam_simulated", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "iam_simulated", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "iam_simulated", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 6.3 + }, + { + "severity": "critical", + "evidence_method": "iam_simulated", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 8.19 + }, + { + "severity": "critical", + "evidence_method": "iam_simulated", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "iam_simulated", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "iam_simulated", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 4.5 + }, + { + "severity": "critical", + "evidence_method": "iam_simulated", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 5.85 + }, + { + "severity": "critical", + "evidence_method": "iam_simulated", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 9.0 + }, + { + "severity": "critical", + "evidence_method": "iam_simulated", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "iam_simulated", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 3.6 + }, + { + "severity": "critical", + "evidence_method": "iam_simulated", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 4.68 + }, + { + "severity": "critical", + "evidence_method": "iam_simulated", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 7.2 + }, + { + "severity": "critical", + "evidence_method": "iam_simulated", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 9.36 + }, + { + "severity": "critical", + "evidence_method": "iam_simulated", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 1.8 + }, + { + "severity": "critical", + "evidence_method": "iam_simulated", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 2.34 + }, + { + "severity": "critical", + "evidence_method": "iam_simulated", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 3.6 + }, + { + "severity": "critical", + "evidence_method": "iam_simulated", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 4.68 + }, + { + "severity": "critical", + "evidence_method": "drift_confirmed", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 8.5 + }, + { + "severity": "critical", + "evidence_method": "drift_confirmed", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "drift_confirmed", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "drift_confirmed", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "drift_confirmed", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 5.95 + }, + { + "severity": "critical", + "evidence_method": "drift_confirmed", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 7.73 + }, + { + "severity": "critical", + "evidence_method": "drift_confirmed", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "drift_confirmed", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "drift_confirmed", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 4.25 + }, + { + "severity": "critical", + "evidence_method": "drift_confirmed", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 5.53 + }, + { + "severity": "critical", + "evidence_method": "drift_confirmed", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 8.5 + }, + { + "severity": "critical", + "evidence_method": "drift_confirmed", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "drift_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 3.4 + }, + { + "severity": "critical", + "evidence_method": "drift_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 4.42 + }, + { + "severity": "critical", + "evidence_method": "drift_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 6.8 + }, + { + "severity": "critical", + "evidence_method": "drift_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 8.84 + }, + { + "severity": "critical", + "evidence_method": "drift_confirmed", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 1.7 + }, + { + "severity": "critical", + "evidence_method": "drift_confirmed", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 2.21 + }, + { + "severity": "critical", + "evidence_method": "drift_confirmed", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 3.4 + }, + { + "severity": "critical", + "evidence_method": "drift_confirmed", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 4.42 + }, + { + "severity": "critical", + "evidence_method": "static_graph_confirmed", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 7.0 + }, + { + "severity": "critical", + "evidence_method": "static_graph_confirmed", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 9.1 + }, + { + "severity": "critical", + "evidence_method": "static_graph_confirmed", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "static_graph_confirmed", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "static_graph_confirmed", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 4.9 + }, + { + "severity": "critical", + "evidence_method": "static_graph_confirmed", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 6.37 + }, + { + "severity": "critical", + "evidence_method": "static_graph_confirmed", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 9.8 + }, + { + "severity": "critical", + "evidence_method": "static_graph_confirmed", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "static_graph_confirmed", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 3.5 + }, + { + "severity": "critical", + "evidence_method": "static_graph_confirmed", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 4.55 + }, + { + "severity": "critical", + "evidence_method": "static_graph_confirmed", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 7.0 + }, + { + "severity": "critical", + "evidence_method": "static_graph_confirmed", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 9.1 + }, + { + "severity": "critical", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 2.8 + }, + { + "severity": "critical", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 3.64 + }, + { + "severity": "critical", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 5.6 + }, + { + "severity": "critical", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 7.28 + }, + { + "severity": "critical", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 1.4 + }, + { + "severity": "critical", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 1.82 + }, + { + "severity": "critical", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 2.8 + }, + { + "severity": "critical", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 3.64 + }, + { + "severity": "critical", + "evidence_method": "static_config_match", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 5.0 + }, + { + "severity": "critical", + "evidence_method": "static_config_match", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 6.5 + }, + { + "severity": "critical", + "evidence_method": "static_config_match", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "static_config_match", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "critical", + "evidence_method": "static_config_match", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 3.5 + }, + { + "severity": "critical", + "evidence_method": "static_config_match", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 4.55 + }, + { + "severity": "critical", + "evidence_method": "static_config_match", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 7.0 + }, + { + "severity": "critical", + "evidence_method": "static_config_match", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 9.1 + }, + { + "severity": "critical", + "evidence_method": "static_config_match", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 2.5 + }, + { + "severity": "critical", + "evidence_method": "static_config_match", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 3.25 + }, + { + "severity": "critical", + "evidence_method": "static_config_match", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 5.0 + }, + { + "severity": "critical", + "evidence_method": "static_config_match", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 6.5 + }, + { + "severity": "critical", + "evidence_method": "static_config_match", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 2.0 + }, + { + "severity": "critical", + "evidence_method": "static_config_match", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 2.6 + }, + { + "severity": "critical", + "evidence_method": "static_config_match", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 4.0 + }, + { + "severity": "critical", + "evidence_method": "static_config_match", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 5.2 + }, + { + "severity": "critical", + "evidence_method": "static_config_match", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 1.0 + }, + { + "severity": "critical", + "evidence_method": "static_config_match", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 1.3 + }, + { + "severity": "critical", + "evidence_method": "static_config_match", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 2.0 + }, + { + "severity": "critical", + "evidence_method": "static_config_match", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 2.6 + }, + { + "severity": "critical", + "evidence_method": "heuristic_match", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 2.0 + }, + { + "severity": "critical", + "evidence_method": "heuristic_match", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 2.6 + }, + { + "severity": "critical", + "evidence_method": "heuristic_match", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 4.0 + }, + { + "severity": "critical", + "evidence_method": "heuristic_match", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 5.2 + }, + { + "severity": "critical", + "evidence_method": "heuristic_match", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 1.4 + }, + { + "severity": "critical", + "evidence_method": "heuristic_match", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 1.82 + }, + { + "severity": "critical", + "evidence_method": "heuristic_match", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 2.8 + }, + { + "severity": "critical", + "evidence_method": "heuristic_match", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 3.64 + }, + { + "severity": "critical", + "evidence_method": "heuristic_match", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 1.0 + }, + { + "severity": "critical", + "evidence_method": "heuristic_match", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 1.3 + }, + { + "severity": "critical", + "evidence_method": "heuristic_match", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 2.0 + }, + { + "severity": "critical", + "evidence_method": "heuristic_match", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 2.6 + }, + { + "severity": "critical", + "evidence_method": "heuristic_match", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 0.8 + }, + { + "severity": "critical", + "evidence_method": "heuristic_match", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 1.04 + }, + { + "severity": "critical", + "evidence_method": "heuristic_match", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 1.6 + }, + { + "severity": "critical", + "evidence_method": "heuristic_match", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 2.08 + }, + { + "severity": "critical", + "evidence_method": "heuristic_match", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 0.4 + }, + { + "severity": "critical", + "evidence_method": "heuristic_match", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 0.52 + }, + { + "severity": "critical", + "evidence_method": "heuristic_match", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 0.8 + }, + { + "severity": "critical", + "evidence_method": "heuristic_match", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 1.04 + }, + { + "severity": "high", + "evidence_method": "live_verified", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 8.0 + }, + { + "severity": "high", + "evidence_method": "live_verified", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "high", + "evidence_method": "live_verified", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 10.0 + }, + { + "severity": "high", + "evidence_method": "live_verified", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "high", + "evidence_method": "live_verified", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 5.6 + }, + { + "severity": "high", + "evidence_method": "live_verified", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 7.28 + }, + { + "severity": "high", + "evidence_method": "live_verified", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 10.0 + }, + { + "severity": "high", + "evidence_method": "live_verified", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "high", + "evidence_method": "live_verified", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 4.0 + }, + { + "severity": "high", + "evidence_method": "live_verified", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 5.2 + }, + { + "severity": "high", + "evidence_method": "live_verified", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 8.0 + }, + { + "severity": "high", + "evidence_method": "live_verified", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "high", + "evidence_method": "live_verified", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 3.2 + }, + { + "severity": "high", + "evidence_method": "live_verified", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 4.16 + }, + { + "severity": "high", + "evidence_method": "live_verified", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 6.4 + }, + { + "severity": "high", + "evidence_method": "live_verified", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 8.32 + }, + { + "severity": "high", + "evidence_method": "live_verified", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 1.6 + }, + { + "severity": "high", + "evidence_method": "live_verified", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 2.08 + }, + { + "severity": "high", + "evidence_method": "live_verified", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 3.2 + }, + { + "severity": "high", + "evidence_method": "live_verified", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 4.16 + }, + { + "severity": "high", + "evidence_method": "iam_simulated", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 7.2 + }, + { + "severity": "high", + "evidence_method": "iam_simulated", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 9.36 + }, + { + "severity": "high", + "evidence_method": "iam_simulated", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 10.0 + }, + { + "severity": "high", + "evidence_method": "iam_simulated", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "high", + "evidence_method": "iam_simulated", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 5.04 + }, + { + "severity": "high", + "evidence_method": "iam_simulated", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 6.55 + }, + { + "severity": "high", + "evidence_method": "iam_simulated", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 10.0 + }, + { + "severity": "high", + "evidence_method": "iam_simulated", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "high", + "evidence_method": "iam_simulated", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 3.6 + }, + { + "severity": "high", + "evidence_method": "iam_simulated", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 4.68 + }, + { + "severity": "high", + "evidence_method": "iam_simulated", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 7.2 + }, + { + "severity": "high", + "evidence_method": "iam_simulated", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 9.36 + }, + { + "severity": "high", + "evidence_method": "iam_simulated", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 2.88 + }, + { + "severity": "high", + "evidence_method": "iam_simulated", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 3.74 + }, + { + "severity": "high", + "evidence_method": "iam_simulated", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 5.76 + }, + { + "severity": "high", + "evidence_method": "iam_simulated", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 7.49 + }, + { + "severity": "high", + "evidence_method": "iam_simulated", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 1.44 + }, + { + "severity": "high", + "evidence_method": "iam_simulated", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 1.87 + }, + { + "severity": "high", + "evidence_method": "iam_simulated", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 2.88 + }, + { + "severity": "high", + "evidence_method": "iam_simulated", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 3.74 + }, + { + "severity": "high", + "evidence_method": "drift_confirmed", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 6.8 + }, + { + "severity": "high", + "evidence_method": "drift_confirmed", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 8.84 + }, + { + "severity": "high", + "evidence_method": "drift_confirmed", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 10.0 + }, + { + "severity": "high", + "evidence_method": "drift_confirmed", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "high", + "evidence_method": "drift_confirmed", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 4.76 + }, + { + "severity": "high", + "evidence_method": "drift_confirmed", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 6.19 + }, + { + "severity": "high", + "evidence_method": "drift_confirmed", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 9.52 + }, + { + "severity": "high", + "evidence_method": "drift_confirmed", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "high", + "evidence_method": "drift_confirmed", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 3.4 + }, + { + "severity": "high", + "evidence_method": "drift_confirmed", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 4.42 + }, + { + "severity": "high", + "evidence_method": "drift_confirmed", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 6.8 + }, + { + "severity": "high", + "evidence_method": "drift_confirmed", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 8.84 + }, + { + "severity": "high", + "evidence_method": "drift_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 2.72 + }, + { + "severity": "high", + "evidence_method": "drift_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 3.54 + }, + { + "severity": "high", + "evidence_method": "drift_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 5.44 + }, + { + "severity": "high", + "evidence_method": "drift_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 7.07 + }, + { + "severity": "high", + "evidence_method": "drift_confirmed", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 1.36 + }, + { + "severity": "high", + "evidence_method": "drift_confirmed", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 1.77 + }, + { + "severity": "high", + "evidence_method": "drift_confirmed", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 2.72 + }, + { + "severity": "high", + "evidence_method": "drift_confirmed", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 3.54 + }, + { + "severity": "high", + "evidence_method": "static_graph_confirmed", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 5.6 + }, + { + "severity": "high", + "evidence_method": "static_graph_confirmed", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 7.28 + }, + { + "severity": "high", + "evidence_method": "static_graph_confirmed", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 10.0 + }, + { + "severity": "high", + "evidence_method": "static_graph_confirmed", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "high", + "evidence_method": "static_graph_confirmed", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 3.92 + }, + { + "severity": "high", + "evidence_method": "static_graph_confirmed", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 5.1 + }, + { + "severity": "high", + "evidence_method": "static_graph_confirmed", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 7.84 + }, + { + "severity": "high", + "evidence_method": "static_graph_confirmed", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "high", + "evidence_method": "static_graph_confirmed", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 2.8 + }, + { + "severity": "high", + "evidence_method": "static_graph_confirmed", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 3.64 + }, + { + "severity": "high", + "evidence_method": "static_graph_confirmed", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 5.6 + }, + { + "severity": "high", + "evidence_method": "static_graph_confirmed", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 7.28 + }, + { + "severity": "high", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 2.24 + }, + { + "severity": "high", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 2.91 + }, + { + "severity": "high", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 4.48 + }, + { + "severity": "high", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 5.82 + }, + { + "severity": "high", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 1.12 + }, + { + "severity": "high", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 1.46 + }, + { + "severity": "high", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 2.24 + }, + { + "severity": "high", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 2.91 + }, + { + "severity": "high", + "evidence_method": "static_config_match", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 4.0 + }, + { + "severity": "high", + "evidence_method": "static_config_match", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 5.2 + }, + { + "severity": "high", + "evidence_method": "static_config_match", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 8.0 + }, + { + "severity": "high", + "evidence_method": "static_config_match", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "high", + "evidence_method": "static_config_match", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 2.8 + }, + { + "severity": "high", + "evidence_method": "static_config_match", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 3.64 + }, + { + "severity": "high", + "evidence_method": "static_config_match", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 5.6 + }, + { + "severity": "high", + "evidence_method": "static_config_match", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 7.28 + }, + { + "severity": "high", + "evidence_method": "static_config_match", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 2.0 + }, + { + "severity": "high", + "evidence_method": "static_config_match", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 2.6 + }, + { + "severity": "high", + "evidence_method": "static_config_match", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 4.0 + }, + { + "severity": "high", + "evidence_method": "static_config_match", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 5.2 + }, + { + "severity": "high", + "evidence_method": "static_config_match", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 1.6 + }, + { + "severity": "high", + "evidence_method": "static_config_match", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 2.08 + }, + { + "severity": "high", + "evidence_method": "static_config_match", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 3.2 + }, + { + "severity": "high", + "evidence_method": "static_config_match", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 4.16 + }, + { + "severity": "high", + "evidence_method": "static_config_match", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 0.8 + }, + { + "severity": "high", + "evidence_method": "static_config_match", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 1.04 + }, + { + "severity": "high", + "evidence_method": "static_config_match", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 1.6 + }, + { + "severity": "high", + "evidence_method": "static_config_match", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 2.08 + }, + { + "severity": "high", + "evidence_method": "heuristic_match", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 1.6 + }, + { + "severity": "high", + "evidence_method": "heuristic_match", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 2.08 + }, + { + "severity": "high", + "evidence_method": "heuristic_match", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 3.2 + }, + { + "severity": "high", + "evidence_method": "heuristic_match", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 4.16 + }, + { + "severity": "high", + "evidence_method": "heuristic_match", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 1.12 + }, + { + "severity": "high", + "evidence_method": "heuristic_match", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 1.46 + }, + { + "severity": "high", + "evidence_method": "heuristic_match", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 2.24 + }, + { + "severity": "high", + "evidence_method": "heuristic_match", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 2.91 + }, + { + "severity": "high", + "evidence_method": "heuristic_match", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 0.8 + }, + { + "severity": "high", + "evidence_method": "heuristic_match", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 1.04 + }, + { + "severity": "high", + "evidence_method": "heuristic_match", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 1.6 + }, + { + "severity": "high", + "evidence_method": "heuristic_match", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 2.08 + }, + { + "severity": "high", + "evidence_method": "heuristic_match", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 0.64 + }, + { + "severity": "high", + "evidence_method": "heuristic_match", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 0.83 + }, + { + "severity": "high", + "evidence_method": "heuristic_match", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 1.28 + }, + { + "severity": "high", + "evidence_method": "heuristic_match", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 1.66 + }, + { + "severity": "high", + "evidence_method": "heuristic_match", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 0.32 + }, + { + "severity": "high", + "evidence_method": "heuristic_match", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 0.42 + }, + { + "severity": "high", + "evidence_method": "heuristic_match", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 0.64 + }, + { + "severity": "high", + "evidence_method": "heuristic_match", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 0.83 + }, + { + "severity": "medium", + "evidence_method": "live_verified", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 5.0 + }, + { + "severity": "medium", + "evidence_method": "live_verified", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 6.5 + }, + { + "severity": "medium", + "evidence_method": "live_verified", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 10.0 + }, + { + "severity": "medium", + "evidence_method": "live_verified", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "medium", + "evidence_method": "live_verified", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 3.5 + }, + { + "severity": "medium", + "evidence_method": "live_verified", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 4.55 + }, + { + "severity": "medium", + "evidence_method": "live_verified", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 7.0 + }, + { + "severity": "medium", + "evidence_method": "live_verified", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 9.1 + }, + { + "severity": "medium", + "evidence_method": "live_verified", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 2.5 + }, + { + "severity": "medium", + "evidence_method": "live_verified", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 3.25 + }, + { + "severity": "medium", + "evidence_method": "live_verified", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 5.0 + }, + { + "severity": "medium", + "evidence_method": "live_verified", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 6.5 + }, + { + "severity": "medium", + "evidence_method": "live_verified", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 2.0 + }, + { + "severity": "medium", + "evidence_method": "live_verified", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 2.6 + }, + { + "severity": "medium", + "evidence_method": "live_verified", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 4.0 + }, + { + "severity": "medium", + "evidence_method": "live_verified", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 5.2 + }, + { + "severity": "medium", + "evidence_method": "live_verified", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 1.0 + }, + { + "severity": "medium", + "evidence_method": "live_verified", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 1.3 + }, + { + "severity": "medium", + "evidence_method": "live_verified", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 2.0 + }, + { + "severity": "medium", + "evidence_method": "live_verified", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 2.6 + }, + { + "severity": "medium", + "evidence_method": "iam_simulated", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 4.5 + }, + { + "severity": "medium", + "evidence_method": "iam_simulated", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 5.85 + }, + { + "severity": "medium", + "evidence_method": "iam_simulated", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 9.0 + }, + { + "severity": "medium", + "evidence_method": "iam_simulated", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "medium", + "evidence_method": "iam_simulated", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 3.15 + }, + { + "severity": "medium", + "evidence_method": "iam_simulated", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 4.09 + }, + { + "severity": "medium", + "evidence_method": "iam_simulated", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 6.3 + }, + { + "severity": "medium", + "evidence_method": "iam_simulated", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 8.19 + }, + { + "severity": "medium", + "evidence_method": "iam_simulated", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 2.25 + }, + { + "severity": "medium", + "evidence_method": "iam_simulated", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 2.93 + }, + { + "severity": "medium", + "evidence_method": "iam_simulated", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 4.5 + }, + { + "severity": "medium", + "evidence_method": "iam_simulated", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 5.85 + }, + { + "severity": "medium", + "evidence_method": "iam_simulated", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 1.8 + }, + { + "severity": "medium", + "evidence_method": "iam_simulated", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 2.34 + }, + { + "severity": "medium", + "evidence_method": "iam_simulated", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 3.6 + }, + { + "severity": "medium", + "evidence_method": "iam_simulated", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 4.68 + }, + { + "severity": "medium", + "evidence_method": "iam_simulated", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 0.9 + }, + { + "severity": "medium", + "evidence_method": "iam_simulated", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 1.17 + }, + { + "severity": "medium", + "evidence_method": "iam_simulated", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 1.8 + }, + { + "severity": "medium", + "evidence_method": "iam_simulated", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 2.34 + }, + { + "severity": "medium", + "evidence_method": "drift_confirmed", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 4.25 + }, + { + "severity": "medium", + "evidence_method": "drift_confirmed", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 5.53 + }, + { + "severity": "medium", + "evidence_method": "drift_confirmed", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 8.5 + }, + { + "severity": "medium", + "evidence_method": "drift_confirmed", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 10.0 + }, + { + "severity": "medium", + "evidence_method": "drift_confirmed", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 2.97 + }, + { + "severity": "medium", + "evidence_method": "drift_confirmed", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 3.87 + }, + { + "severity": "medium", + "evidence_method": "drift_confirmed", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 5.95 + }, + { + "severity": "medium", + "evidence_method": "drift_confirmed", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 7.73 + }, + { + "severity": "medium", + "evidence_method": "drift_confirmed", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 2.12 + }, + { + "severity": "medium", + "evidence_method": "drift_confirmed", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 2.76 + }, + { + "severity": "medium", + "evidence_method": "drift_confirmed", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 4.25 + }, + { + "severity": "medium", + "evidence_method": "drift_confirmed", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 5.53 + }, + { + "severity": "medium", + "evidence_method": "drift_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 1.7 + }, + { + "severity": "medium", + "evidence_method": "drift_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 2.21 + }, + { + "severity": "medium", + "evidence_method": "drift_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 3.4 + }, + { + "severity": "medium", + "evidence_method": "drift_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 4.42 + }, + { + "severity": "medium", + "evidence_method": "drift_confirmed", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 0.85 + }, + { + "severity": "medium", + "evidence_method": "drift_confirmed", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 1.11 + }, + { + "severity": "medium", + "evidence_method": "drift_confirmed", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 1.7 + }, + { + "severity": "medium", + "evidence_method": "drift_confirmed", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 2.21 + }, + { + "severity": "medium", + "evidence_method": "static_graph_confirmed", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 3.5 + }, + { + "severity": "medium", + "evidence_method": "static_graph_confirmed", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 4.55 + }, + { + "severity": "medium", + "evidence_method": "static_graph_confirmed", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 7.0 + }, + { + "severity": "medium", + "evidence_method": "static_graph_confirmed", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 9.1 + }, + { + "severity": "medium", + "evidence_method": "static_graph_confirmed", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 2.45 + }, + { + "severity": "medium", + "evidence_method": "static_graph_confirmed", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 3.18 + }, + { + "severity": "medium", + "evidence_method": "static_graph_confirmed", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 4.9 + }, + { + "severity": "medium", + "evidence_method": "static_graph_confirmed", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 6.37 + }, + { + "severity": "medium", + "evidence_method": "static_graph_confirmed", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 1.75 + }, + { + "severity": "medium", + "evidence_method": "static_graph_confirmed", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 2.27 + }, + { + "severity": "medium", + "evidence_method": "static_graph_confirmed", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 3.5 + }, + { + "severity": "medium", + "evidence_method": "static_graph_confirmed", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 4.55 + }, + { + "severity": "medium", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 1.4 + }, + { + "severity": "medium", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 1.82 + }, + { + "severity": "medium", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 2.8 + }, + { + "severity": "medium", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 3.64 + }, + { + "severity": "medium", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 0.7 + }, + { + "severity": "medium", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 0.91 + }, + { + "severity": "medium", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 1.4 + }, + { + "severity": "medium", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 1.82 + }, + { + "severity": "medium", + "evidence_method": "static_config_match", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 2.5 + }, + { + "severity": "medium", + "evidence_method": "static_config_match", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 3.25 + }, + { + "severity": "medium", + "evidence_method": "static_config_match", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 5.0 + }, + { + "severity": "medium", + "evidence_method": "static_config_match", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 6.5 + }, + { + "severity": "medium", + "evidence_method": "static_config_match", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 1.75 + }, + { + "severity": "medium", + "evidence_method": "static_config_match", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 2.27 + }, + { + "severity": "medium", + "evidence_method": "static_config_match", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 3.5 + }, + { + "severity": "medium", + "evidence_method": "static_config_match", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 4.55 + }, + { + "severity": "medium", + "evidence_method": "static_config_match", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 1.25 + }, + { + "severity": "medium", + "evidence_method": "static_config_match", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 1.62 + }, + { + "severity": "medium", + "evidence_method": "static_config_match", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 2.5 + }, + { + "severity": "medium", + "evidence_method": "static_config_match", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 3.25 + }, + { + "severity": "medium", + "evidence_method": "static_config_match", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 1.0 + }, + { + "severity": "medium", + "evidence_method": "static_config_match", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 1.3 + }, + { + "severity": "medium", + "evidence_method": "static_config_match", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 2.0 + }, + { + "severity": "medium", + "evidence_method": "static_config_match", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 2.6 + }, + { + "severity": "medium", + "evidence_method": "static_config_match", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 0.5 + }, + { + "severity": "medium", + "evidence_method": "static_config_match", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 0.65 + }, + { + "severity": "medium", + "evidence_method": "static_config_match", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 1.0 + }, + { + "severity": "medium", + "evidence_method": "static_config_match", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 1.3 + }, + { + "severity": "medium", + "evidence_method": "heuristic_match", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 1.0 + }, + { + "severity": "medium", + "evidence_method": "heuristic_match", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 1.3 + }, + { + "severity": "medium", + "evidence_method": "heuristic_match", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 2.0 + }, + { + "severity": "medium", + "evidence_method": "heuristic_match", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 2.6 + }, + { + "severity": "medium", + "evidence_method": "heuristic_match", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 0.7 + }, + { + "severity": "medium", + "evidence_method": "heuristic_match", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 0.91 + }, + { + "severity": "medium", + "evidence_method": "heuristic_match", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 1.4 + }, + { + "severity": "medium", + "evidence_method": "heuristic_match", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 1.82 + }, + { + "severity": "medium", + "evidence_method": "heuristic_match", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 0.5 + }, + { + "severity": "medium", + "evidence_method": "heuristic_match", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 0.65 + }, + { + "severity": "medium", + "evidence_method": "heuristic_match", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 1.0 + }, + { + "severity": "medium", + "evidence_method": "heuristic_match", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 1.3 + }, + { + "severity": "medium", + "evidence_method": "heuristic_match", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 0.4 + }, + { + "severity": "medium", + "evidence_method": "heuristic_match", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 0.52 + }, + { + "severity": "medium", + "evidence_method": "heuristic_match", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 0.8 + }, + { + "severity": "medium", + "evidence_method": "heuristic_match", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 1.04 + }, + { + "severity": "medium", + "evidence_method": "heuristic_match", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 0.2 + }, + { + "severity": "medium", + "evidence_method": "heuristic_match", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 0.26 + }, + { + "severity": "medium", + "evidence_method": "heuristic_match", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 0.4 + }, + { + "severity": "medium", + "evidence_method": "heuristic_match", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 0.52 + }, + { + "severity": "low", + "evidence_method": "live_verified", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 3.0 + }, + { + "severity": "low", + "evidence_method": "live_verified", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 3.9 + }, + { + "severity": "low", + "evidence_method": "live_verified", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 6.0 + }, + { + "severity": "low", + "evidence_method": "live_verified", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 7.8 + }, + { + "severity": "low", + "evidence_method": "live_verified", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 2.1 + }, + { + "severity": "low", + "evidence_method": "live_verified", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 2.73 + }, + { + "severity": "low", + "evidence_method": "live_verified", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 4.2 + }, + { + "severity": "low", + "evidence_method": "live_verified", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 5.46 + }, + { + "severity": "low", + "evidence_method": "live_verified", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 1.5 + }, + { + "severity": "low", + "evidence_method": "live_verified", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 1.95 + }, + { + "severity": "low", + "evidence_method": "live_verified", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 3.0 + }, + { + "severity": "low", + "evidence_method": "live_verified", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 3.9 + }, + { + "severity": "low", + "evidence_method": "live_verified", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 1.2 + }, + { + "severity": "low", + "evidence_method": "live_verified", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 1.56 + }, + { + "severity": "low", + "evidence_method": "live_verified", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 2.4 + }, + { + "severity": "low", + "evidence_method": "live_verified", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 3.12 + }, + { + "severity": "low", + "evidence_method": "live_verified", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 0.6 + }, + { + "severity": "low", + "evidence_method": "live_verified", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 0.78 + }, + { + "severity": "low", + "evidence_method": "live_verified", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 1.2 + }, + { + "severity": "low", + "evidence_method": "live_verified", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 1.56 + }, + { + "severity": "low", + "evidence_method": "iam_simulated", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 2.7 + }, + { + "severity": "low", + "evidence_method": "iam_simulated", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 3.51 + }, + { + "severity": "low", + "evidence_method": "iam_simulated", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 5.4 + }, + { + "severity": "low", + "evidence_method": "iam_simulated", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 7.02 + }, + { + "severity": "low", + "evidence_method": "iam_simulated", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 1.89 + }, + { + "severity": "low", + "evidence_method": "iam_simulated", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 2.46 + }, + { + "severity": "low", + "evidence_method": "iam_simulated", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 3.78 + }, + { + "severity": "low", + "evidence_method": "iam_simulated", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 4.91 + }, + { + "severity": "low", + "evidence_method": "iam_simulated", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 1.35 + }, + { + "severity": "low", + "evidence_method": "iam_simulated", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 1.76 + }, + { + "severity": "low", + "evidence_method": "iam_simulated", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 2.7 + }, + { + "severity": "low", + "evidence_method": "iam_simulated", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 3.51 + }, + { + "severity": "low", + "evidence_method": "iam_simulated", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 1.08 + }, + { + "severity": "low", + "evidence_method": "iam_simulated", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 1.4 + }, + { + "severity": "low", + "evidence_method": "iam_simulated", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 2.16 + }, + { + "severity": "low", + "evidence_method": "iam_simulated", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 2.81 + }, + { + "severity": "low", + "evidence_method": "iam_simulated", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 0.54 + }, + { + "severity": "low", + "evidence_method": "iam_simulated", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 0.7 + }, + { + "severity": "low", + "evidence_method": "iam_simulated", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 1.08 + }, + { + "severity": "low", + "evidence_method": "iam_simulated", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 1.4 + }, + { + "severity": "low", + "evidence_method": "drift_confirmed", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 2.55 + }, + { + "severity": "low", + "evidence_method": "drift_confirmed", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 3.31 + }, + { + "severity": "low", + "evidence_method": "drift_confirmed", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 5.1 + }, + { + "severity": "low", + "evidence_method": "drift_confirmed", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 6.63 + }, + { + "severity": "low", + "evidence_method": "drift_confirmed", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 1.78 + }, + { + "severity": "low", + "evidence_method": "drift_confirmed", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 2.32 + }, + { + "severity": "low", + "evidence_method": "drift_confirmed", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 3.57 + }, + { + "severity": "low", + "evidence_method": "drift_confirmed", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 4.64 + }, + { + "severity": "low", + "evidence_method": "drift_confirmed", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 1.27 + }, + { + "severity": "low", + "evidence_method": "drift_confirmed", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 1.66 + }, + { + "severity": "low", + "evidence_method": "drift_confirmed", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 2.55 + }, + { + "severity": "low", + "evidence_method": "drift_confirmed", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 3.31 + }, + { + "severity": "low", + "evidence_method": "drift_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 1.02 + }, + { + "severity": "low", + "evidence_method": "drift_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 1.33 + }, + { + "severity": "low", + "evidence_method": "drift_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 2.04 + }, + { + "severity": "low", + "evidence_method": "drift_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 2.65 + }, + { + "severity": "low", + "evidence_method": "drift_confirmed", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 0.51 + }, + { + "severity": "low", + "evidence_method": "drift_confirmed", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 0.66 + }, + { + "severity": "low", + "evidence_method": "drift_confirmed", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 1.02 + }, + { + "severity": "low", + "evidence_method": "drift_confirmed", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 1.33 + }, + { + "severity": "low", + "evidence_method": "static_graph_confirmed", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 2.1 + }, + { + "severity": "low", + "evidence_method": "static_graph_confirmed", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 2.73 + }, + { + "severity": "low", + "evidence_method": "static_graph_confirmed", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 4.2 + }, + { + "severity": "low", + "evidence_method": "static_graph_confirmed", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 5.46 + }, + { + "severity": "low", + "evidence_method": "static_graph_confirmed", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 1.47 + }, + { + "severity": "low", + "evidence_method": "static_graph_confirmed", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 1.91 + }, + { + "severity": "low", + "evidence_method": "static_graph_confirmed", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 2.94 + }, + { + "severity": "low", + "evidence_method": "static_graph_confirmed", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 3.82 + }, + { + "severity": "low", + "evidence_method": "static_graph_confirmed", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 1.05 + }, + { + "severity": "low", + "evidence_method": "static_graph_confirmed", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 1.36 + }, + { + "severity": "low", + "evidence_method": "static_graph_confirmed", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 2.1 + }, + { + "severity": "low", + "evidence_method": "static_graph_confirmed", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 2.73 + }, + { + "severity": "low", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 0.84 + }, + { + "severity": "low", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 1.09 + }, + { + "severity": "low", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 1.68 + }, + { + "severity": "low", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 2.18 + }, + { + "severity": "low", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 0.42 + }, + { + "severity": "low", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 0.55 + }, + { + "severity": "low", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 0.84 + }, + { + "severity": "low", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 1.09 + }, + { + "severity": "low", + "evidence_method": "static_config_match", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 1.5 + }, + { + "severity": "low", + "evidence_method": "static_config_match", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 1.95 + }, + { + "severity": "low", + "evidence_method": "static_config_match", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 3.0 + }, + { + "severity": "low", + "evidence_method": "static_config_match", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 3.9 + }, + { + "severity": "low", + "evidence_method": "static_config_match", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 1.05 + }, + { + "severity": "low", + "evidence_method": "static_config_match", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 1.36 + }, + { + "severity": "low", + "evidence_method": "static_config_match", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 2.1 + }, + { + "severity": "low", + "evidence_method": "static_config_match", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 2.73 + }, + { + "severity": "low", + "evidence_method": "static_config_match", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 0.75 + }, + { + "severity": "low", + "evidence_method": "static_config_match", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 0.98 + }, + { + "severity": "low", + "evidence_method": "static_config_match", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 1.5 + }, + { + "severity": "low", + "evidence_method": "static_config_match", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 1.95 + }, + { + "severity": "low", + "evidence_method": "static_config_match", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 0.6 + }, + { + "severity": "low", + "evidence_method": "static_config_match", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 0.78 + }, + { + "severity": "low", + "evidence_method": "static_config_match", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 1.2 + }, + { + "severity": "low", + "evidence_method": "static_config_match", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 1.56 + }, + { + "severity": "low", + "evidence_method": "static_config_match", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 0.3 + }, + { + "severity": "low", + "evidence_method": "static_config_match", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 0.39 + }, + { + "severity": "low", + "evidence_method": "static_config_match", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 0.6 + }, + { + "severity": "low", + "evidence_method": "static_config_match", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 0.78 + }, + { + "severity": "low", + "evidence_method": "heuristic_match", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 0.6 + }, + { + "severity": "low", + "evidence_method": "heuristic_match", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 0.78 + }, + { + "severity": "low", + "evidence_method": "heuristic_match", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 1.2 + }, + { + "severity": "low", + "evidence_method": "heuristic_match", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 1.56 + }, + { + "severity": "low", + "evidence_method": "heuristic_match", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 0.42 + }, + { + "severity": "low", + "evidence_method": "heuristic_match", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 0.55 + }, + { + "severity": "low", + "evidence_method": "heuristic_match", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 0.84 + }, + { + "severity": "low", + "evidence_method": "heuristic_match", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 1.09 + }, + { + "severity": "low", + "evidence_method": "heuristic_match", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 0.3 + }, + { + "severity": "low", + "evidence_method": "heuristic_match", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 0.39 + }, + { + "severity": "low", + "evidence_method": "heuristic_match", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 0.6 + }, + { + "severity": "low", + "evidence_method": "heuristic_match", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 0.78 + }, + { + "severity": "low", + "evidence_method": "heuristic_match", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 0.24 + }, + { + "severity": "low", + "evidence_method": "heuristic_match", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 0.31 + }, + { + "severity": "low", + "evidence_method": "heuristic_match", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 0.48 + }, + { + "severity": "low", + "evidence_method": "heuristic_match", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 0.62 + }, + { + "severity": "low", + "evidence_method": "heuristic_match", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 0.12 + }, + { + "severity": "low", + "evidence_method": "heuristic_match", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 0.16 + }, + { + "severity": "low", + "evidence_method": "heuristic_match", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 0.24 + }, + { + "severity": "low", + "evidence_method": "heuristic_match", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 0.31 + }, + { + "severity": "info", + "evidence_method": "live_verified", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 1.0 + }, + { + "severity": "info", + "evidence_method": "live_verified", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 1.3 + }, + { + "severity": "info", + "evidence_method": "live_verified", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 2.0 + }, + { + "severity": "info", + "evidence_method": "live_verified", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 2.6 + }, + { + "severity": "info", + "evidence_method": "live_verified", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 0.7 + }, + { + "severity": "info", + "evidence_method": "live_verified", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 0.91 + }, + { + "severity": "info", + "evidence_method": "live_verified", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 1.4 + }, + { + "severity": "info", + "evidence_method": "live_verified", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 1.82 + }, + { + "severity": "info", + "evidence_method": "live_verified", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 0.5 + }, + { + "severity": "info", + "evidence_method": "live_verified", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 0.65 + }, + { + "severity": "info", + "evidence_method": "live_verified", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 1.0 + }, + { + "severity": "info", + "evidence_method": "live_verified", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 1.3 + }, + { + "severity": "info", + "evidence_method": "live_verified", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 0.4 + }, + { + "severity": "info", + "evidence_method": "live_verified", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 0.52 + }, + { + "severity": "info", + "evidence_method": "live_verified", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 0.8 + }, + { + "severity": "info", + "evidence_method": "live_verified", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 1.04 + }, + { + "severity": "info", + "evidence_method": "live_verified", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 0.2 + }, + { + "severity": "info", + "evidence_method": "live_verified", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 0.26 + }, + { + "severity": "info", + "evidence_method": "live_verified", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 0.4 + }, + { + "severity": "info", + "evidence_method": "live_verified", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 0.52 + }, + { + "severity": "info", + "evidence_method": "iam_simulated", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 0.9 + }, + { + "severity": "info", + "evidence_method": "iam_simulated", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 1.17 + }, + { + "severity": "info", + "evidence_method": "iam_simulated", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 1.8 + }, + { + "severity": "info", + "evidence_method": "iam_simulated", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 2.34 + }, + { + "severity": "info", + "evidence_method": "iam_simulated", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 0.63 + }, + { + "severity": "info", + "evidence_method": "iam_simulated", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 0.82 + }, + { + "severity": "info", + "evidence_method": "iam_simulated", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 1.26 + }, + { + "severity": "info", + "evidence_method": "iam_simulated", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 1.64 + }, + { + "severity": "info", + "evidence_method": "iam_simulated", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 0.45 + }, + { + "severity": "info", + "evidence_method": "iam_simulated", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 0.59 + }, + { + "severity": "info", + "evidence_method": "iam_simulated", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 0.9 + }, + { + "severity": "info", + "evidence_method": "iam_simulated", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 1.17 + }, + { + "severity": "info", + "evidence_method": "iam_simulated", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 0.36 + }, + { + "severity": "info", + "evidence_method": "iam_simulated", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 0.47 + }, + { + "severity": "info", + "evidence_method": "iam_simulated", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 0.72 + }, + { + "severity": "info", + "evidence_method": "iam_simulated", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 0.94 + }, + { + "severity": "info", + "evidence_method": "iam_simulated", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 0.18 + }, + { + "severity": "info", + "evidence_method": "iam_simulated", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 0.23 + }, + { + "severity": "info", + "evidence_method": "iam_simulated", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 0.36 + }, + { + "severity": "info", + "evidence_method": "iam_simulated", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 0.47 + }, + { + "severity": "info", + "evidence_method": "drift_confirmed", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 0.85 + }, + { + "severity": "info", + "evidence_method": "drift_confirmed", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 1.1 + }, + { + "severity": "info", + "evidence_method": "drift_confirmed", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 1.7 + }, + { + "severity": "info", + "evidence_method": "drift_confirmed", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 2.21 + }, + { + "severity": "info", + "evidence_method": "drift_confirmed", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 0.59 + }, + { + "severity": "info", + "evidence_method": "drift_confirmed", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 0.77 + }, + { + "severity": "info", + "evidence_method": "drift_confirmed", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 1.19 + }, + { + "severity": "info", + "evidence_method": "drift_confirmed", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 1.55 + }, + { + "severity": "info", + "evidence_method": "drift_confirmed", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 0.42 + }, + { + "severity": "info", + "evidence_method": "drift_confirmed", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 0.55 + }, + { + "severity": "info", + "evidence_method": "drift_confirmed", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 0.85 + }, + { + "severity": "info", + "evidence_method": "drift_confirmed", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 1.1 + }, + { + "severity": "info", + "evidence_method": "drift_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 0.34 + }, + { + "severity": "info", + "evidence_method": "drift_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 0.44 + }, + { + "severity": "info", + "evidence_method": "drift_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 0.68 + }, + { + "severity": "info", + "evidence_method": "drift_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 0.88 + }, + { + "severity": "info", + "evidence_method": "drift_confirmed", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 0.17 + }, + { + "severity": "info", + "evidence_method": "drift_confirmed", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 0.22 + }, + { + "severity": "info", + "evidence_method": "drift_confirmed", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 0.34 + }, + { + "severity": "info", + "evidence_method": "drift_confirmed", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 0.44 + }, + { + "severity": "info", + "evidence_method": "static_graph_confirmed", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 0.7 + }, + { + "severity": "info", + "evidence_method": "static_graph_confirmed", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 0.91 + }, + { + "severity": "info", + "evidence_method": "static_graph_confirmed", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 1.4 + }, + { + "severity": "info", + "evidence_method": "static_graph_confirmed", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 1.82 + }, + { + "severity": "info", + "evidence_method": "static_graph_confirmed", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 0.49 + }, + { + "severity": "info", + "evidence_method": "static_graph_confirmed", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 0.64 + }, + { + "severity": "info", + "evidence_method": "static_graph_confirmed", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 0.98 + }, + { + "severity": "info", + "evidence_method": "static_graph_confirmed", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 1.27 + }, + { + "severity": "info", + "evidence_method": "static_graph_confirmed", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 0.35 + }, + { + "severity": "info", + "evidence_method": "static_graph_confirmed", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 0.45 + }, + { + "severity": "info", + "evidence_method": "static_graph_confirmed", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 0.7 + }, + { + "severity": "info", + "evidence_method": "static_graph_confirmed", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 0.91 + }, + { + "severity": "info", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 0.28 + }, + { + "severity": "info", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 0.36 + }, + { + "severity": "info", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 0.56 + }, + { + "severity": "info", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 0.73 + }, + { + "severity": "info", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 0.14 + }, + { + "severity": "info", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 0.18 + }, + { + "severity": "info", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 0.28 + }, + { + "severity": "info", + "evidence_method": "static_graph_confirmed", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 0.36 + }, + { + "severity": "info", + "evidence_method": "static_config_match", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 0.5 + }, + { + "severity": "info", + "evidence_method": "static_config_match", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 0.65 + }, + { + "severity": "info", + "evidence_method": "static_config_match", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 1.0 + }, + { + "severity": "info", + "evidence_method": "static_config_match", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 1.3 + }, + { + "severity": "info", + "evidence_method": "static_config_match", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 0.35 + }, + { + "severity": "info", + "evidence_method": "static_config_match", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 0.45 + }, + { + "severity": "info", + "evidence_method": "static_config_match", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 0.7 + }, + { + "severity": "info", + "evidence_method": "static_config_match", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 0.91 + }, + { + "severity": "info", + "evidence_method": "static_config_match", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 0.25 + }, + { + "severity": "info", + "evidence_method": "static_config_match", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 0.33 + }, + { + "severity": "info", + "evidence_method": "static_config_match", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 0.5 + }, + { + "severity": "info", + "evidence_method": "static_config_match", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 0.65 + }, + { + "severity": "info", + "evidence_method": "static_config_match", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 0.2 + }, + { + "severity": "info", + "evidence_method": "static_config_match", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 0.26 + }, + { + "severity": "info", + "evidence_method": "static_config_match", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 0.4 + }, + { + "severity": "info", + "evidence_method": "static_config_match", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 0.52 + }, + { + "severity": "info", + "evidence_method": "static_config_match", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 0.1 + }, + { + "severity": "info", + "evidence_method": "static_config_match", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 0.13 + }, + { + "severity": "info", + "evidence_method": "static_config_match", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 0.2 + }, + { + "severity": "info", + "evidence_method": "static_config_match", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 0.26 + }, + { + "severity": "info", + "evidence_method": "heuristic_match", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": false, + "score": 0.2 + }, + { + "severity": "info", + "evidence_method": "heuristic_match", + "exposure": "internet_facing", + "has_attack_path": false, + "has_drift": true, + "score": 0.26 + }, + { + "severity": "info", + "evidence_method": "heuristic_match", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": false, + "score": 0.4 + }, + { + "severity": "info", + "evidence_method": "heuristic_match", + "exposure": "internet_facing", + "has_attack_path": true, + "has_drift": true, + "score": 0.52 + }, + { + "severity": "info", + "evidence_method": "heuristic_match", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": false, + "score": 0.14 + }, + { + "severity": "info", + "evidence_method": "heuristic_match", + "exposure": "vpc_internal", + "has_attack_path": false, + "has_drift": true, + "score": 0.18 + }, + { + "severity": "info", + "evidence_method": "heuristic_match", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": false, + "score": 0.28 + }, + { + "severity": "info", + "evidence_method": "heuristic_match", + "exposure": "vpc_internal", + "has_attack_path": true, + "has_drift": true, + "score": 0.36 + }, + { + "severity": "info", + "evidence_method": "heuristic_match", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": false, + "score": 0.1 + }, + { + "severity": "info", + "evidence_method": "heuristic_match", + "exposure": "private_subnet", + "has_attack_path": false, + "has_drift": true, + "score": 0.13 + }, + { + "severity": "info", + "evidence_method": "heuristic_match", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": false, + "score": 0.2 + }, + { + "severity": "info", + "evidence_method": "heuristic_match", + "exposure": "private_subnet", + "has_attack_path": true, + "has_drift": true, + "score": 0.26 + }, + { + "severity": "info", + "evidence_method": "heuristic_match", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": false, + "score": 0.08 + }, + { + "severity": "info", + "evidence_method": "heuristic_match", + "exposure": "requires_iam_auth", + "has_attack_path": false, + "has_drift": true, + "score": 0.1 + }, + { + "severity": "info", + "evidence_method": "heuristic_match", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": false, + "score": 0.16 + }, + { + "severity": "info", + "evidence_method": "heuristic_match", + "exposure": "requires_iam_auth", + "has_attack_path": true, + "has_drift": true, + "score": 0.21 + }, + { + "severity": "info", + "evidence_method": "heuristic_match", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": false, + "score": 0.04 + }, + { + "severity": "info", + "evidence_method": "heuristic_match", + "exposure": "requires_admin", + "has_attack_path": false, + "has_drift": true, + "score": 0.05 + }, + { + "severity": "info", + "evidence_method": "heuristic_match", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": false, + "score": 0.08 + }, + { + "severity": "info", + "evidence_method": "heuristic_match", + "exposure": "requires_admin", + "has_attack_path": true, + "has_drift": true, + "score": 0.1 + } +] diff --git a/go/scripts/gen_golden.py b/go/scripts/gen_golden.py new file mode 100644 index 0000000..5e99e3b --- /dev/null +++ b/go/scripts/gen_golden.py @@ -0,0 +1,831 @@ +#!/usr/bin/env python3 +"""Committed golden-fixture generator for the cloudsecurity-af Go port (RECON). + +This script is the SINGLE SOURCE OF TRUTH for two kinds of fixture under +``go/internal/agents/recon/testdata/``: + +``golden/*.txt`` + The exact prompt strings the PYTHON recon agents hand to ``app.harness``. + They are captured by binding a recording fake to the agent's ``app`` + parameter and driving the real coroutine, so a fixture can only change when + the Python builder or the prompt template changes. The Go golden test + renders the same inputs through ``BuildIaCReaderPrompt`` / + ``BuildResourceGraphBuilderPrompt`` / ``BuildCloudConnectorPrompt`` / + ``BuildDriftDetectorPrompt`` and compares byte-for-byte. + +``../hunt/testdata/golden/*.txt`` and ``../util/testdata/golden/*.txt`` + The same treatment for the HUNT phase: the seven hunters' full harness + prompts, and the three text blocks + ``_utils.build_graph_context_for_hunter`` returns for a handful of keyword + /file cases. Both are driven off ONE committed fixture pair, + ``internal/agents/util/testdata/fixture/{graph,inventory}.json``, which is + copied into the hunt package's testdata so each Go package's tests are + self-contained. + +``python/inventory.json`` and ``python/graph.json`` + The output of the real ``parse_terraform_directory`` and + ``build_graph_from_inventory`` over ``tests/fixtures/vulnerable_infra``. + They are the ground truth the Go parser is asserted against — structurally, + field by field, with the documented expression-rendering divergence + enumerated explicitly in the Go test rather than papered over. + +REPRODUCE (from the repo root): + + PYTHONPATH=src ~/.agentfield/packages/cloudsecurity-af/venv/bin/python \ + go/scripts/gen_golden.py + +The script is deterministic and idempotent: rerunning overwrites the fixtures +with identical bytes unless a Python builder, a prompt template or the parser +changed — which is exactly the signal the Go tests exist to catch. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import shutil +import sys +import tempfile + +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_SRC = os.path.join(_REPO_ROOT, "src") +if os.path.isdir(_SRC) and _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cloudsecurity_af.agents._utils import build_graph_context_for_hunter # noqa: E402 +from cloudsecurity_af.agents.hunt.compliance_hunter import run_compliance_hunter # noqa: E402 +from cloudsecurity_af.agents.hunt.compute_hunter import run_compute_hunter # noqa: E402 +from cloudsecurity_af.agents.hunt.data_hunter import run_data_hunter # noqa: E402 +from cloudsecurity_af.agents.hunt.iam_hunter import run_iam_hunter # noqa: E402 +from cloudsecurity_af.agents.hunt.logging_hunter import run_logging_hunter # noqa: E402 +from cloudsecurity_af.agents.hunt.network_hunter import run_network_hunter # noqa: E402 +from cloudsecurity_af.agents.hunt.secrets_hunter import run_secrets_hunter # noqa: E402 +from cloudsecurity_af.agents.chain import path_constructor # noqa: E402 +from cloudsecurity_af.agents.chain.path_constructor import ChildInvestigation # noqa: E402 +from cloudsecurity_af.agents.prove import live_prover, static_prover # noqa: E402 +from cloudsecurity_af.agents.recon import cloud_connector, drift_detector, iac_reader # noqa: E402 +from cloudsecurity_af.agents.recon import resource_graph_builder # noqa: E402 +from cloudsecurity_af.agents.recon._graph_builder_fast import build_graph_from_inventory # noqa: E402 +from cloudsecurity_af.agents.recon._terraform_parser import parse_terraform_directory # noqa: E402 +from cloudsecurity_af.agents.remediate import fix_generator # noqa: E402 +from cloudsecurity_af.schemas.chain import AttackPath, AttackStep, BlastRadius # noqa: E402 +from cloudsecurity_af.schemas.hunt import AffectedResource, Confidence, RawFinding # noqa: E402 +from cloudsecurity_af.schemas.prove import ( # noqa: E402 + IaCDiff, + Proof, + ProofMethod, + RemediationSuggestion, + Verdict, + VerifiedFinding, +) +from cloudsecurity_af.schemas.recon import ( # noqa: E402 + ConfigDiff, + DriftedResource, + DriftReport, + ResourceGraph, + ResourceInventory, +) +from cloudsecurity_af.scoring import Severity # noqa: E402 + +_GO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TESTDATA = os.path.join(_GO_ROOT, "internal", "agents", "recon", "testdata") +GOLDEN = os.path.join(TESTDATA, "golden") +PYDATA = os.path.join(TESTDATA, "python") +TF_FIXTURE = os.path.join(_REPO_ROOT, "tests", "fixtures", "vulnerable_infra") + +# --- HUNT phase (assignment C4) --------------------------------------------- +UTIL_TESTDATA = os.path.join(_GO_ROOT, "internal", "agents", "util", "testdata") +UTIL_FIXTURE = os.path.join(UTIL_TESTDATA, "fixture") +UTIL_GOLDEN = os.path.join(UTIL_TESTDATA, "golden") +HUNT_TESTDATA = os.path.join(_GO_ROOT, "internal", "agents", "hunt", "testdata") +HUNT_FIXTURE = os.path.join(HUNT_TESTDATA, "fixture") +HUNT_GOLDEN = os.path.join(HUNT_TESTDATA, "golden") + +# The committed graph/inventory pair every HUNT fixture is derived from. It is +# hand-written (not parser output) so it can exercise the shapes that matter to +# build_graph_context_for_hunter: nested config_summary dicts whose key order is +# NOT alphabetical, None/bool/int/float/non-ASCII values, a string containing +# double quotes, an edge with no "type" (default "references"), an edge with an +# empty description, an edge with no description, a hunter with no matching +# edges at all, and providers that need dedup + sorting. +FIXTURE_GRAPH = os.path.join(UTIL_FIXTURE, "graph.json") +FIXTURE_INVENTORY = os.path.join(UTIL_FIXTURE, "inventory.json") +# A valid JSON document that is not an object -> both loads fall back to the +# empty default. +FIXTURE_NOT_AN_OBJECT = os.path.join(UTIL_FIXTURE, "not_an_object.json") +# A path that does not exist -> json.load raises -> same empty default. +FIXTURE_ABSENT = os.path.join(UTIL_FIXTURE, "absent.json") + +HUNT_REPO_PATH = "/fixture/repo" +HUNT_DEPTH = "standard" + +# (module name, coroutine) in the order reasoners/hunt.py registers them. +HUNTERS = [ + ("iam_hunter", run_iam_hunter), + ("network_hunter", run_network_hunter), + ("data_hunter", run_data_hunter), + ("secrets_hunter", run_secrets_hunter), + ("compute_hunter", run_compute_hunter), + ("logging_hunter", run_logging_hunter), + ("compliance_hunter", run_compliance_hunter), +] + +# build_graph_context_for_hunter cases, named after the Go sub-test that reads +# them. Each is (graph_path, inventory_path, domain_keywords). +GRAPH_CONTEXT_CASES = { + # The iam hunter's own keyword list: two matching nodes, one matching edge + # WITH a description, no 1-hop neighbors. + "iam": (FIXTURE_GRAPH, FIXTURE_INVENTORY, ["iam", "role", "policy"]), + # The data hunter's shape: two matching nodes, two matching edges (one with + # an empty description, one with no "type"), one 1-hop neighbor. + "data": (FIXTURE_GRAPH, FIXTURE_INVENTORY, ["s3", "bucket", "kms"]), + # What the compliance hunter's [""] reduces to: no keywords -> match all. + "all": (FIXTURE_GRAPH, FIXTURE_INVENTORY, [""]), + # Nothing matches: both "none matched" / "no edges matched" branches. + "nomatch": (FIXTURE_GRAPH, FIXTURE_INVENTORY, ["nonexistent_type_xyz"]), + # Unreadable files -> the bare `except Exception` default documents. + "missing_files": (FIXTURE_ABSENT, FIXTURE_ABSENT, ["iam"]), + # Readable but not a dict -> the isinstance re-check default documents. + "not_an_object": (FIXTURE_NOT_AN_OBJECT, FIXTURE_NOT_AN_OBJECT, ["iam"]), +} + +# Fixed, path-independent fixture inputs. They are deliberately NOT real +# temporary paths: a golden has to be reproducible on any machine. +REPO_PATH = "/fixture/repo" +INVENTORY_PATH = "/fixture/work/inventory.json" +IAC_GRAPH_PATH = "/fixture/work/graph.json" + +# cloud_config cases. +# A = CloudConfig().model_dump() — every default, two explicit Nones +# B = every field populated — multi-region, account, assume-role +# C = {} — the empty-dict edge case +CLOUD_CONFIG_CASES = { + "a": {"provider": "aws", "regions": ["us-east-1"], "account_id": None, "assume_role_arn": None}, + "b": { + "provider": "gcp", + "regions": ["us-central1", "europe-west1"], + "account_id": "123456789012", + "assume_role_arn": "arn:aws:iam::123456789012:role/cloudsecurity-scanner", + }, + "c": {}, +} + + +# --------------------------------------------------------------------------- +# Recording fake app +# --------------------------------------------------------------------------- +class _FakeResult: + """Mimics the harness result shape extract_harness_result reads.""" + + def __init__(self, parsed: object) -> None: + self.is_error = False + self.error_message = None + self.result = None + self.parsed = parsed + + +class _FakeApp: + """Records every harness prompt and answers with a schema-valid model.""" + + def __init__(self) -> None: + self.prompts: list[str] = [] + + async def harness(self, prompt: str, *, schema=None, cwd=None, **kwargs): # noqa: ANN001 + self.prompts.append(prompt) + return _FakeResult(schema.model_construct() if schema is not None else None) + + +def capture(coro_factory) -> str: + """Run one agent coroutine and return the single prompt it emitted.""" + app = _FakeApp() + asyncio.new_event_loop().run_until_complete(coro_factory(app)) + assert len(app.prompts) == 1, f"expected exactly one harness call, got {len(app.prompts)}" + return app.prompts[0] + + +def emit(directory: str, name: str, text: str) -> None: + os.makedirs(directory, exist_ok=True) + path = os.path.join(directory, name) + with open(path, "w", encoding="utf-8", newline="") as f: + f.write(text) + print(f" wrote {os.path.relpath(path, _GO_ROOT)} ({len(text.encode('utf-8'))} bytes)") + + +# --------------------------------------------------------------------------- +# Prompt goldens +# --------------------------------------------------------------------------- +def gen_prompts() -> None: + print("prompt goldens:") + + # iac_reader._harness_fallback(app, repo_path, work_dir) + emit( + GOLDEN, + "iac_reader_prompt.txt", + capture(lambda app: iac_reader._harness_fallback(app, REPO_PATH, "/fixture/work")), + ) + + # resource_graph_builder._harness_fallback(app, repo_path, inventory_path, work_dir) + emit( + GOLDEN, + "resource_graph_builder_prompt.txt", + capture( + lambda app: resource_graph_builder._harness_fallback( + app, REPO_PATH, INVENTORY_PATH, "/fixture/work" + ) + ), + ) + + for case, cfg in CLOUD_CONFIG_CASES.items(): + emit( + GOLDEN, + f"cloud_connector_prompt_{case}.txt", + capture(lambda app, cfg=cfg: cloud_connector.run_cloud_connector(app, cfg)), + ) + + for case, cfg in CLOUD_CONFIG_CASES.items(): + emit( + GOLDEN, + f"drift_detector_prompt_{case}.txt", + capture( + lambda app, cfg=cfg: drift_detector.run_drift_detector(app, IAC_GRAPH_PATH, cfg) + ), + ) + + +# --------------------------------------------------------------------------- +# Parser / graph ground truth +# --------------------------------------------------------------------------- +def gen_parser_ground_truth() -> None: + print("parser ground truth:") + work = tempfile.mkdtemp(prefix="cloudsecurity-gen-golden-") + try: + inv_path, total, iac_type = parse_terraform_directory(TF_FIXTURE, work) + graph_path, nodes, edges = build_graph_from_inventory(inv_path, work) + + os.makedirs(PYDATA, exist_ok=True) + shutil.copyfile(inv_path, os.path.join(PYDATA, "inventory.json")) + shutil.copyfile(graph_path, os.path.join(PYDATA, "graph.json")) + + # A tiny sidecar so the Go test asserts the scalar returns too, without + # hard-coding them in two places. + summary = { + "total_resources": total, + "iac_type": iac_type, + "total_nodes": nodes, + "total_edges": edges, + } + emit(PYDATA, "summary.json", json.dumps(summary, indent=2) + "\n") + print(f" wrote python/inventory.json ({total} resources, iac_type={iac_type})") + print(f" wrote python/graph.json ({nodes} nodes, {edges} edges)") + finally: + shutil.rmtree(work, ignore_errors=True) + + +def gen_expressions_ground_truth() -> None: + """Parse the Go-tree-only expression-coverage fixture with the Python parser. + + tests/fixtures/vulnerable_infra exercises the shapes that actually occur in + the benchmark repo; testdata/expressions/main.tf additionally covers every + HCL expression KIND (interpolation, heredoc, conditional, function call, + unary minus, null, quoted object keys, labeled/repeated nested blocks, + variable/output/provider/module blocks). Committing the Python output for it + is what lets the Go test assert "every CONSTANT leaf is identical" instead of + hand-transcribing expected values. + """ + print("expression ground truth:") + fixture = os.path.join(TESTDATA, "expressions") + work = tempfile.mkdtemp(prefix="cloudsecurity-gen-golden-expr-") + try: + inv_path, total, _ = parse_terraform_directory(fixture, work) + shutil.copyfile(inv_path, os.path.join(PYDATA, "expressions_inventory.json")) + print(f" wrote python/expressions_inventory.json ({total} resources)") + finally: + shutil.rmtree(work, ignore_errors=True) + + +def gen_tf_fixture_copy() -> None: + """Copy the Terraform fixture into the Go tree so `go test` is self-contained.""" + print("terraform fixture:") + dest = os.path.join(TESTDATA, "vulnerable_infra") + os.makedirs(dest, exist_ok=True) + for name in sorted(os.listdir(TF_FIXTURE)): + src = os.path.join(TF_FIXTURE, name) + if os.path.isfile(src): + shutil.copyfile(src, os.path.join(dest, name)) + print(f" copied vulnerable_infra/{name}") + + +# --------------------------------------------------------------------------- +# HUNT phase: shared fixture, graph-context goldens, hunter prompt goldens +# --------------------------------------------------------------------------- +class _RecordingApp: + """Records the full harness kwargs and answers with a defaulted model.""" + + def __init__(self) -> None: + self.calls: list[dict] = [] + + async def harness(self, prompt: str, *, schema=None, cwd=None, project_dir=None, **kwargs): # noqa: ANN001 + self.calls.append( + {"prompt": prompt, "schema": schema.__name__ if schema else None, "cwd": cwd, "project_dir": project_dir} + ) + return _FakeResult(schema.model_construct() if schema is not None else None) + + +def gen_hunt_fixture_copy() -> None: + """Mirror the util fixture into the hunt package so both are self-contained.""" + print("hunt fixture:") + os.makedirs(HUNT_FIXTURE, exist_ok=True) + for name in sorted(os.listdir(UTIL_FIXTURE)): + src_path = os.path.join(UTIL_FIXTURE, name) + if os.path.isfile(src_path): + shutil.copyfile(src_path, os.path.join(HUNT_FIXTURE, name)) + print(f" copied hunt/testdata/fixture/{name}") + + +def gen_graph_context_goldens() -> None: + """Capture build_graph_context_for_hunter's three blocks for each case.""" + print("graph-context goldens:") + for case, (graph_path, inventory_path, keywords) in GRAPH_CONTEXT_CASES.items(): + nodes, stats, edges = build_graph_context_for_hunter(graph_path, inventory_path, keywords) + emit(UTIL_GOLDEN, f"{case}_nodes.txt", nodes) + emit(UTIL_GOLDEN, f"{case}_stats.txt", stats) + emit(UTIL_GOLDEN, f"{case}_edges.txt", edges) + + +def gen_hunt_prompts() -> None: + """Capture each hunter's full harness prompt, options and returned model.""" + print("hunt prompt goldens:") + options: dict[str, dict] = {} + results: dict[str, dict] = {} + for module, runner in HUNTERS: + app = _RecordingApp() + result = asyncio.new_event_loop().run_until_complete( + runner(app, HUNT_REPO_PATH, FIXTURE_GRAPH, FIXTURE_INVENTORY, HUNT_DEPTH) + ) + assert len(app.calls) == 1, f"{module}: expected one harness call, got {len(app.calls)}" + call = app.calls[0] + emit(HUNT_GOLDEN, f"{module}_prompt.txt", call["prompt"]) + options[module] = {"schema": call["schema"], "cwd": call["cwd"], "project_dir": call["project_dir"]} + results[module] = result.model_dump() + emit(HUNT_GOLDEN, "harness_options.json", json.dumps(options, indent=2) + "\n") + emit(HUNT_GOLDEN, "empty_result.json", json.dumps(results, indent=2) + "\n") + + +# --------------------------------------------------------------------------- +# CHAIN / PROVE / REMEDIATE goldens +# --------------------------------------------------------------------------- +# +# The three phases below are prompt-only agents: each renders a template by +# string substitution and hands the result to the harness. Their fixtures are +# therefore (a) an ``inputs.json`` holding the exact pydantic models the Python +# builder was driven with, so the Go test binds the SAME values instead of +# transcribing them, and (b) one ``*.txt`` per case holding the prompt Python +# produced. +# +# Two fixture-design rules keep the goldens honest about the port's two +# documented JSON divergences instead of hiding them: +# +# * every ``Any``-typed leaf (ConfigDiff.iac_value / .live_value) is a string, +# float, bool or None — never an int, because Go's encoding/json decodes a +# JSON int into float64 and would render "1" as "1.0". A dedicated Go unit +# test pins that divergence explicitly. +# * every free-form dict (DriftedResource.iac_config / .live_config) has +# ALPHABETICALLY ORDERED keys, because a Go map has no insertion order and +# the port sorts. A dedicated Go unit test pins that one too. + +CHAIN_TESTDATA = os.path.join(_GO_ROOT, "internal", "agents", "chain", "testdata") +CHAIN_GOLDEN = os.path.join(CHAIN_TESTDATA, "golden") +PROVE_GOLDEN = os.path.join(_GO_ROOT, "internal", "agents", "prove", "testdata", "golden") +REMEDIATE_GOLDEN = os.path.join(_GO_ROOT, "internal", "agents", "remediate", "testdata", "golden") + +# A resource graph exercising: a node the findings touch, a 1-hop neighbour, an +# unrelated node, an edge that survives, an edge with one endpoint outside the +# relevant set, an edge between two unrelated nodes, a non-dict edge entry, a +# clusters value that is passed through untouched, and a top-level key that the +# filter drops. +CHAIN_GRAPH = { + "nodes": [ + { + "resource_id": "aws_iam_role.admin", + "resource_type": "aws_iam_role", + "file_path": "main.tf", + "config_summary": {"assume_role_policy": "*", "name": "admin"}, + }, + { + "resource_id": "aws_s3_bucket.data", + "resource_type": "aws_s3_bucket", + "file_path": "data.tf", + "config_summary": {}, + }, + { + "resource_id": "aws_kms_key.unrelated", + "resource_type": "aws_kms_key", + "file_path": "kms.tf", + "config_summary": {"enable_key_rotation": False}, + }, + "not-a-dict-node", + {"resource_type": "aws_vpc.no_id"}, + ], + "edges": [ + { + "source": "aws_iam_role.admin", + "target": "aws_s3_bucket.data", + "type": "data_access", + "description": "role can read the bucket", + }, + {"source": "aws_s3_bucket.data", "target": "aws_kms_key.unrelated", "type": "encryption"}, + {"source": "unrelated.a", "target": "unrelated.b", "type": "references"}, + "not-a-dict-edge", + {"target": "aws_s3_bucket.data"}, + ], + "clusters": [{"name": "prod", "members": ["aws_iam_role.admin"]}], + "generated_by": "gen_golden.py", +} + +REPO = "/fixture/repo" + + +def _chain_findings_pair() -> list[RawFinding]: + return [ + RawFinding( + id="finding-1", + hunter_strategy="iam", + title="Role trusts * & assumes admin", + description="aws_iam_role.admin has a wildcard trust policy.", + category="overprivilege", + resources=[ + AffectedResource( + resource_id="aws_iam_role.admin", + resource_type="aws_iam_role", + attribute="assume_role_policy", + current_value='{"Principal": "*"}', + recommended_value="scoped principal", + ) + ], + estimated_severity=Severity.CRITICAL, + confidence=Confidence.HIGH, + iac_file="main.tf", + iac_line=12, + config_snippet='resource "aws_iam_role" "admin" {\n assume_role_policy = "*"\n}', + benchmark_id="CIS-1.16", + fingerprint="fp-iam-1", + ), + RawFinding( + id="finding-2", + hunter_strategy="network", + title="Security group open to 0.0.0.0/0 — pörté 22", + description="Ingress from anywhere.", + category="public_exposure", + resources=[], + estimated_severity=Severity.HIGH, + confidence=Confidence.MEDIUM, + iac_file="network.tf", + iac_line=0, + config_snippet="", + benchmark_id=None, + fingerprint="fp-net-2", + ), + ] + + +def _chain_findings_single() -> list[RawFinding]: + # No resources and no iac_file: finding_resources is EMPTY, so nothing in the + # graph is relevant. Exercises the empty-filter path. + return [ + RawFinding( + id="finding-3", + hunter_strategy="logging", + title="No CloudTrail", + description="", + category="missing_logging", + resources=[], + estimated_severity=Severity.LOW, + confidence=Confidence.LOW, + iac_file="", + iac_line=0, + config_snippet="", + fingerprint="fp-log-3", + ) + ] + + +def _drift_report() -> DriftReport: + return DriftReport( + drifted_resources=[ + DriftedResource( + resource_id="aws_s3_bucket.data", + resource_type="aws_s3_bucket", + iac_config={"acl": "private", "versioning": True}, + live_config={"acl": "public-read", "versioning": False}, + diffs=[ + ConfigDiff( + attribute="acl", + iac_value="private", + live_value="public-read", + security_impact="bucket became world readable", + ), + ConfigDiff(attribute="retention_days", iac_value=30.0, live_value=None), + ConfigDiff(attribute="mfa_delete", iac_value=True, live_value=False), + ], + security_relevant=True, + significance="critical", + ) + ], + iac_only_resources=["aws_kms_key.unrelated"], + cloud_only_resources=[], + ) + + +def _attack_path() -> AttackPath: + return AttackPath( + id="path-1", + title="Wildcard role -> public bucket", + description="An anonymous principal assumes the admin role and reads the data lake.", + steps=[ + AttackStep( + step_number=1, + resource_id="aws_iam_role.admin", + resource_type="aws_iam_role", + action="sts:AssumeRole as any principal", + permission_used="assume_role_policy: *", + description="", + ), + AttackStep( + step_number=2, + resource_id="aws_s3_bucket.data", + resource_type="aws_s3_bucket", + action="s3:GetObject", + permission_used="role policy allows s3:*", + description="Exfiltrate the data lake.", + ), + ], + entry_point="aws_iam_role.admin", + target="aws_s3_bucket.data", + findings_involved=["finding-1", "finding-2"], + combined_severity=Severity.CRITICAL, + blast_radius=BlastRadius( + data_stores_reachable=["aws_s3_bucket.data"], + compute_reachable=[], + estimated_data_volume="~2 TB", + services_affected=["s3", "iam"], + ), + ) + + +def _investigations() -> list[ChildInvestigation]: + return [ + ChildInvestigation( + title="Wildcard trust to data lake", + rationale="finding-1 and finding-2 share a network path.", + findings_involved=["finding-1", "finding-2"], + child_prompt=( + "\n\n Verify whether an anonymous attacker can assume aws_iam_role.admin\n" + " and then read aws_s3_bucket.data. Evidence required per hop.\t \n\n" + ), + ), + ChildInvestigation( + title="Isolated logging gap", + rationale="", + findings_involved=[], + child_prompt="Check whether the missing CloudTrail hides the pivot.", + ), + ] + + +def _verified_full() -> VerifiedFinding: + return VerifiedFinding( + id="verified-1", + title="Role trusts * & assumes admin", + verdict=Verdict.CONFIRMED, + severity=Severity.CRITICAL, + category="overprivilege", + resources=[ + AffectedResource( + resource_id="aws_iam_role.admin", + resource_type="aws_iam_role", + attribute="assume_role_policy", + current_value='{"Principal": "*"}', + recommended_value="scoped principal", + ) + ], + attack_path=_attack_path(), + drift=_drift_report().drifted_resources[0], + proof=Proof( + method=ProofMethod.STATIC_ANALYSIS, + evidence=["main.tf:12 assume_role_policy allows *"], + scripts_executed=[], + verification_tier="static", + ), + compliance_mappings=["CIS-1.16", "SOC2-CC6.1"], + risk_score=9.25, + remediation=RemediationSuggestion( + finding_id="verified-1", + description="Scope the trust policy.", + diffs=[ + IaCDiff( + file_path="main.tf", + original_lines=' assume_role_policy = "*"', + patched_lines=' assume_role_policy = data.aws_iam_policy_document.scoped.json', + start_line=12, + end_line=12, + ) + ], + breaking_change=False, + downtime_estimate="none", + effort="trivial", + alternative_approaches=["Use a permission boundary"], + ), + sarif_rule_id="cloudsecurity/iam/overprivilege", + sarif_security_severity=9.0, + iac_file="main.tf", + iac_line=12, + config_snippet='resource "aws_iam_role" "admin" {\n assume_role_policy = "*"\n}', + description="aws_iam_role.admin has a wildcard trust policy.", + fingerprint="fp-iam-1", + hunter_strategy="iam", + drop_reason=None, + ) + + +def _verified_bare() -> VerifiedFinding: + # Every optional at its default: exercises `null`, `[]`, `{}` and the + # float-zero rendering ("0.0", which Go's encoding/json would write as "0"). + return VerifiedFinding( + id="verified-2", + title="", + verdict=Verdict.INCONCLUSIVE, + severity=Severity.INFO, + category="", + fingerprint="fp-bare-2", + ) + + +def gen_chain_goldens() -> None: + print("chain goldens:") + os.makedirs(CHAIN_GOLDEN, exist_ok=True) + + graph_text = json.dumps(CHAIN_GRAPH, indent=2) + emit(CHAIN_GOLDEN, "graph.json", graph_text) + emit(CHAIN_GOLDEN, "graph_not_object.json", json.dumps([1, 2], indent=2)) + + graph_path = os.path.join(CHAIN_GOLDEN, "graph.json") + not_object_path = os.path.join(CHAIN_GOLDEN, "graph_not_object.json") + missing_path = os.path.join(CHAIN_GOLDEN, "does-not-exist.json") + + pair = _chain_findings_pair() + single = _chain_findings_single() + drift = _drift_report() + investigations = _investigations() + + emit( + CHAIN_GOLDEN, + "inputs.json", + json.dumps( + { + "findings_pair": [f.model_dump(mode="json") for f in pair], + "findings_single": [f.model_dump(mode="json") for f in single], + "drift_report": drift.model_dump(mode="json"), + "investigations": [i.model_dump(mode="json") for i in investigations], + }, + indent=2, + ), + ) + + template = path_constructor.PROMPT_PATH.read_text(encoding="utf-8") + + # a: two findings, a real graph, a drift report. + emit( + CHAIN_GOLDEN, + "parent_prompt_a.txt", + path_constructor._build_parent_prompt( + template=template, + findings=pair, + resource_graph_path=graph_path, + drift_report=drift, + max_paths=5, + max_children=3, + ), + ) + # b: the graph file does not exist and there is no drift report. + emit( + CHAIN_GOLDEN, + "parent_prompt_b.txt", + path_constructor._build_parent_prompt( + template=template, + findings=pair, + resource_graph_path=missing_path, + drift_report=None, + max_paths=1, + max_children=1, + ), + ) + # c: the graph file's top level is a list, and the finding touches nothing. + emit( + CHAIN_GOLDEN, + "parent_prompt_c.txt", + path_constructor._build_parent_prompt( + template=template, + findings=single, + resource_graph_path=not_object_path, + drift_report=None, + max_paths=2, + max_children=4, + ), + ) + + emit(CHAIN_GOLDEN, "child_prompt_a.txt", path_constructor._child_prompt(investigations[0], 5)) + emit(CHAIN_GOLDEN, "child_prompt_b.txt", path_constructor._child_prompt(investigations[1], 1)) + + +def gen_prove_goldens() -> None: + print("prove goldens:") + os.makedirs(PROVE_GOLDEN, exist_ok=True) + + full, bare = _chain_findings_pair() + path = _attack_path() + + emit( + PROVE_GOLDEN, + "inputs.json", + json.dumps( + { + "finding_full": full.model_dump(mode="json"), + "finding_bare": bare.model_dump(mode="json"), + "attack_path": path.model_dump(mode="json"), + "repo_path": REPO, + }, + indent=2, + ), + ) + + emit( + PROVE_GOLDEN, + "static_prompt_a.txt", + capture(lambda app: static_prover.run_static_prover(app, REPO, full, path, 1)), + ) + emit( + PROVE_GOLDEN, + "static_prompt_b.txt", + capture(lambda app: static_prover.run_static_prover(app, REPO, bare, None, 2)), + ) + emit( + PROVE_GOLDEN, + "live_prompt_a.txt", + capture(lambda app: live_prover.run_live_prover(app, REPO, full, path, 2)), + ) + emit( + PROVE_GOLDEN, + "live_prompt_b.txt", + capture(lambda app: live_prover.run_live_prover(app, REPO, bare, None, 3)), + ) + + +def gen_remediate_goldens() -> None: + print("remediate goldens:") + os.makedirs(REMEDIATE_GOLDEN, exist_ok=True) + + full = _verified_full() + bare = _verified_bare() + + emit( + REMEDIATE_GOLDEN, + "inputs.json", + json.dumps( + { + "verified_full": full.model_dump(mode="json"), + "verified_bare": bare.model_dump(mode="json"), + "repo_path": REPO, + }, + indent=2, + ), + ) + + emit( + REMEDIATE_GOLDEN, + "fix_prompt_a.txt", + capture(lambda app: fix_generator.run_fix_generator(app, REPO, full)), + ) + emit( + REMEDIATE_GOLDEN, + "fix_prompt_b.txt", + capture(lambda app: fix_generator.run_fix_generator(app, REPO, bare)), + ) + + +def main() -> None: + gen_tf_fixture_copy() + gen_parser_ground_truth() + gen_expressions_ground_truth() + gen_prompts() + gen_hunt_fixture_copy() + gen_graph_context_goldens() + gen_hunt_prompts() + gen_chain_goldens() + gen_prove_goldens() + gen_remediate_goldens() + print("done") + + +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..b0d02cb --- /dev/null +++ b/go/scripts/gen_golden_output.py @@ -0,0 +1,665 @@ +#!/usr/bin/env python3 +"""Committed golden generator for internal/output and internal/pyfmt (Dumps). + +Standalone sibling of ``gen_golden.py`` (which is owned by the RECON/HUNT +assignments and is left untouched here on purpose — the two scripts write +disjoint trees and can be run in either order). + +REPRODUCE (from the repo root of the worktree): + + PYTHONPATH=src ~/.agentfield/packages/cloudsecurity-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. + +It writes two families of fixture: + +``internal/output/testdata/.json`` + ``testdata/golden/.*`` + Three CloudSecurityScanResult fixtures and, for each, the four artifacts + ``src/cloudsecurity_af/output`` produces from it: + + .sarif.json generate_sarif(result) + .full.json generate_json(result, pretty=True) + .full_compact.json generate_json(result, pretty=False) + .summary.json generate_summary_json(result) + .report.md generate_report(result) + + The Go test loads the SAME ``.json`` into schemas.CloudSecurityScanResult + and diffs its own output against those bytes, so the fixture is the shared + input and neither side re-implements the other. + +``internal/pyfmt/testdata/models_fixture.json`` + ``testdata/golden/dumps_*.txt`` + ``json.dumps(Model(**sub_fixture).model_dump(), indent=2)`` and the compact + spelling, for four real pydantic models plus one plain JSON document. This + is the parity gate for ``pyfmt.Dumps`` / ``pyfmt.DumpsCompact`` (DESIGN §2b). + +The fixture files are dumped with ``sort_keys=False``, so the file preserves the +dict order the literals below declare — which is the order the LIVE orchestrator +produces: + +* ``by_severity`` seeded ``{s.value: 0 for s in Severity}`` -> critical, + high, medium, low, info (orchestrator.py:165) +* ``cost_breakdown`` seeded from ``_PHASE_ORDER`` -> recon, hunt, chain, prove, + remediate (orchestrator.py:54,67) + +The Go port reproduces exactly those two orders from +``schemas.BySeverityOrder()`` / ``schemas.CostBreakdownOrder`` instead of +sorting, so the goldens are a real order check rather than a neutralised one. + +``metadata`` has no knowable order (it is free-form), so the port sorts it and +the fixtures below spell it in sorted order. The ``scan_result_edge`` fixture's +``cost_breakdown`` carries keys OUTSIDE ``_PHASE_ORDER``, which the live path +cannot produce; it is written known-phases-first-then-sorted, the deterministic +tail the port falls back to. + +Deterministic and idempotent: every input is a fixed literal (no clock, no uuid), +so rerunning overwrites the fixtures with identical bytes unless +``src/cloudsecurity_af/output/**`` or a schema changed. +""" + +from __future__ import annotations + +import json +import os +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +# Make `cloudsecurity_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") + +from cloudsecurity_af.output.json_output import generate_json, generate_summary_json # noqa: E402 +from cloudsecurity_af.output.report import generate_report # noqa: E402 +from cloudsecurity_af.output.sarif import generate_sarif # noqa: E402 +from cloudsecurity_af.schemas.chain import AttackPath, AttackStep, BlastRadius, ChainResult # noqa: E402 +from cloudsecurity_af.schemas.hunt import AffectedResource # noqa: E402 +from cloudsecurity_af.schemas.output import CloudSecurityScanResult, ScanMetrics # noqa: E402 +from cloudsecurity_af.schemas.prove import ( # noqa: E402 + IaCDiff, + Proof, + ProofMethod, + RemediationSuggestion, + Verdict, + VerifiedFinding, +) +from cloudsecurity_af.schemas.recon import ConfigDiff, DriftedResource # noqa: E402 +from cloudsecurity_af.scoring import Severity # noqa: E402 + +_OUTPUT_TESTDATA = "internal/output/testdata" +_OUTPUT_GOLDEN = "internal/output/testdata/golden" +_PYFMT_TESTDATA = "internal/pyfmt/testdata" +_PYFMT_GOLDEN = "internal/pyfmt/testdata/golden" + +# A fixed instant, so the fixture (and every artifact that interpolates it) is +# reproducible. Microseconds are non-zero on purpose: both the isoformat() +# spelling used by report.py/sarif.py ("+00:00") and the model_dump_json() +# spelling used by json_output.py ("Z") must carry the six fractional digits. +_TIMESTAMP = datetime(2026, 5, 6, 7, 8, 9, 123456, tzinfo=timezone.utc) +# The empty fixture's clock has ZERO microseconds, which is the other branch of +# both spellings (no fractional part at all). +_TIMESTAMP_WHOLE = datetime(2026, 5, 6, 7, 8, 9, tzinfo=timezone.utc) +# A non-UTC offset, which pydantic renders as "+05:30" rather than "Z". +_TIMESTAMP_OFFSET = datetime(2026, 5, 6, 7, 8, 9, 500000, tzinfo=timezone(timedelta(hours=5, minutes=30))) + + +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", newline="") + print(f"wrote {rel_path} ({len(text.encode('utf-8'))} bytes)") + + +# --------------------------------------------------------------------------- +# Fixture 1 — "scan_result": the ordinary, fully populated scan. +# --------------------------------------------------------------------------- +def _scan_result() -> CloudSecurityScanResult: + path = AttackPath( + id="path-1", + title="Public ALB to customer PII bucket", + description="An internet-facing load balancer reaches a role that can read the PII bucket.", + steps=[ + AttackStep( + step_number=1, + resource_id="aws_lb.public", + resource_type="aws_lb", + action="Reach the listener from the internet", + permission_used="ingress 0.0.0.0/0:443", + description="The security group allows the world.", + ), + AttackStep( + step_number=2, + resource_id="aws_iam_role.app", + resource_type="aws_iam_role", + action="Assume the task role", + permission_used="sts:AssumeRole", + ), + AttackStep( + step_number=3, + resource_id="aws_s3_bucket.pii", + resource_type="aws_s3_bucket", + action="Read every object", + permission_used="s3:GetObject", + ), + ], + entry_point="aws_lb.public", + target="aws_s3_bucket.pii", + findings_involved=["finding-net-1", "finding-iam-1"], + combined_severity=Severity.CRITICAL, + blast_radius=BlastRadius( + data_stores_reachable=["aws_s3_bucket.pii", "aws_rds_cluster.main"], + compute_reachable=["aws_ecs_service.api"], + estimated_data_volume="~400 GB", + services_affected=["s3", "rds", "ecs"], + ), + ) + + drift = DriftedResource( + resource_id="aws_s3_bucket.pii", + resource_type="aws_s3_bucket", + iac_config={"acl": "private", "versioning": True}, + live_config={"acl": "public-read", "versioning": False}, + diffs=[ + ConfigDiff( + attribute="acl", + iac_value="private", + live_value="public-read", + security_impact="Bucket is world-readable in the account.", + ) + ], + security_relevant=True, + significance="critical", + ) + + iam = VerifiedFinding( + id="finding-iam-1", + title="Wildcard IAM policy on the task role", + verdict=Verdict.CONFIRMED, + severity=Severity.CRITICAL, + category="overprivilege", + resources=[ + AffectedResource( + resource_id="aws_iam_role_policy.app", + resource_type="aws_iam_role_policy", + attribute="policy.Statement[0].Action", + current_value='"*"', + recommended_value='["s3:GetObject"]', + ) + ], + attack_path=path, + drift=None, + proof=Proof( + method=ProofMethod.STATIC_ANALYSIS, + evidence=["policy document grants Action:* on Resource:*"], + scripts_executed=["grep -rn 'Action' iam.tf"], + verification_tier="static", + ), + compliance_mappings=["CIS-AWS-1.16", "SOC2-CC6.1"], + risk_score=9.5, + remediation=RemediationSuggestion( + finding_id="finding-iam-1", + description="Scope the policy to the two objects the service actually reads.", + diffs=[ + IaCDiff( + file_path="iam.tf", + original_lines=' Action = "*"', + patched_lines=' Action = ["s3:GetObject"]', + start_line=41, + end_line=41, + ) + ], + breaking_change=True, + downtime_estimate="seconds", + effort="moderate", + alternative_approaches=["Attach a permissions boundary instead."], + ), + sarif_rule_id="cloudsecurity/iam/overprivilege", + sarif_security_severity=9.5, + iac_file="iam.tf", + iac_line=41, + config_snippet='resource "aws_iam_role_policy" "app" {\n policy = jsonencode({ Action = "*" })\n}', + description="The task role can perform any action on any resource.", + fingerprint="fp-iam-1", + hunter_strategy="iam", + drop_reason=None, + ) + + net = VerifiedFinding( + id="finding-net-1", + title="Security group open to the internet", + verdict=Verdict.LIKELY, + severity=Severity.HIGH, + category="public_exposure", + resources=[], + attack_path=path, + drift=drift, + proof=Proof(method=ProofMethod.DRIFT_COMPARISON, verification_tier="live"), + compliance_mappings=["CIS-AWS-5.2"], + risk_score=7.25, + remediation=RemediationSuggestion( + finding_id="finding-net-1", + description="Restrict ingress to the corporate CIDR.", + breaking_change=False, + downtime_estimate=None, + effort="trivial", + ), + # Same rule id as the finding below, so the rule aggregation (max level, + # max precision, max security-severity, tag union) has something to do. + sarif_rule_id="cloudsecurity/network/public_exposure", + sarif_security_severity=7.2, + iac_file="network.tf", + iac_line=12, + config_snippet="", + description="0.0.0.0/0 on port 443.", + fingerprint="fp-net-1", + hunter_strategy="network", + ) + + net_low = VerifiedFinding( + id="finding-net-2", + title="Load balancer logs disabled", + verdict=Verdict.INCONCLUSIVE, + severity=Severity.LOW, + category="public_exposure", + proof=Proof(), + compliance_mappings=[], + risk_score=2.0, + sarif_rule_id="cloudsecurity/network/public_exposure", + sarif_security_severity=3.0, + iac_file="network.tf", + iac_line=88, + description="", + fingerprint="fp-net-2", + hunter_strategy="network", + ) + + dropped = VerifiedFinding( + id="finding-dropped", + title="Unused KMS key", + verdict=Verdict.NOT_EXPLOITABLE, + severity=Severity.MEDIUM, + category="encryption", + proof=Proof(), + risk_score=0.0, + sarif_rule_id="cloudsecurity/data/encryption", + sarif_security_severity=4.0, + iac_file="kms.tf", + iac_line=3, + description="The key has no grants.", + fingerprint="fp-dropped", + hunter_strategy="data", + drop_reason="not_exploitable", + ) + + return CloudSecurityScanResult( + repository="https://github.com/Agent-Field/vulnerable-infra", + commit_sha="0f1e2d3c4b5a69788796a5b4c3d2e1f000112233", + branch="main", + timestamp=_TIMESTAMP, + depth_profile="standard", + tier=2, + providers_detected=["aws", "gcp"], + findings=[iam, net, net_low, dropped], + attack_paths=[path], + total_resources_scanned=137, + total_raw_findings=19, + confirmed=1, + likely=1, + inconclusive=1, + not_exploitable=1, + noise_reduction_pct=78.94736842105263, + by_severity={"critical": 1, "high": 1, "medium": 1, "low": 1, "info": 0}, + drift_resources=3, + shadow_it_resources=1, + compliance_frameworks_checked=["CIS-AWS", "SOC2"], + compliance_gaps=["CIS-AWS-2.1.1 has no evidence"], + strategies_used=["iam", "network", "data"], + duration_seconds=412.6499999999999, + agent_invocations=23, + cost_usd=1.23456789, + cost_breakdown={"recon": 0.13456789, "hunt": 0.5, "chain": 0.2, "prove": 0.4}, + metadata={"harness": "aforge", "live_verified": True, "model": "minimax/minimax-m2.5"}, + sarif="", + ) + + +# --------------------------------------------------------------------------- +# Fixture 2 — "scan_result_empty": every "nothing to report" branch at once. +# --------------------------------------------------------------------------- +def _scan_result_empty() -> CloudSecurityScanResult: + return CloudSecurityScanResult( + repository="", + commit_sha="", + branch=None, + timestamp=_TIMESTAMP_WHOLE, + depth_profile="quick", + tier=1, + providers_detected=[], + findings=[], + attack_paths=[], + by_severity={}, + compliance_frameworks_checked=[], + strategies_used=[], + cost_breakdown={}, + metadata={}, + ) + + +# --------------------------------------------------------------------------- +# Fixture 3 — "scan_result_edge": escaping, float spellings and every guard. +# --------------------------------------------------------------------------- +def _scan_result_edge() -> CloudSecurityScanResult: + # An attack path with NO steps, NO findings_involved and an empty blast + # radius: the "- Steps:" header with nothing under it, and both blast-radius + # lines suppressed. + bare_path = AttackPath( + id="path-bare", + title='Path with "quotes" & ', + description="", + steps=[], + entry_point="", + target="", + findings_involved=[], + combined_severity=Severity.INFO, + blast_radius=BlastRadius(), + ) + + # No sarif_rule_id -> the "cloudsecurity/{strategy}/{category}" fallback, + # which here has an EMPTY last segment, so _rule_name falls back to + # "CloudSecurityRule". iac_line 0 is floored to 1; the empty iac_file + # becomes "unknown". + fallback = VerifiedFinding( + id="finding-fallback", + title="Unnamed rule", + verdict=Verdict.CONFIRMED, + severity=Severity.INFO, + category="", + proof=Proof(), + compliance_mappings=[], + # 1e-05: json.dumps renders it "1e-05", pydantic renders it "0.00001". + risk_score=1e-05, + sarif_rule_id="", + sarif_security_severity=-3.0, # clamped up to 0.0 + iac_file="", + iac_line=0, + config_snippet="", + description="", + fingerprint="fp-fallback", + hunter_strategy="", + ) + + # Shares the fallback rule id, and is LOWER on both ranks, so max_level and + # max_precision must keep the FIRST maximum rather than the last. + fallback_twin = VerifiedFinding( + id="finding-fallback-2", + title="Unnamed rule, second sighting", + verdict=Verdict.NOT_EXPLOITABLE, # dropped before rules are built + severity=Severity.CRITICAL, + category="", + proof=Proof(), + risk_score=-0.0, + sarif_rule_id="", + sarif_security_severity=99.0, # clamped down to 10.0 (if it survived) + hunter_strategy="", + fingerprint="fp-fallback-2", + ) + + # Escaping: non-ASCII (ensure_ascii vs raw UTF-8), an astral character + # (surrogate pair), HTML characters (Go escapes them, Python does not), a + # backslash, a quote and a tab. + escaped = VerifiedFinding( + id="finding-éscaped", + title='S3 bucket "public" — 世界 \U0001F680', + verdict=Verdict.LIKELY, + severity=Severity.MEDIUM, + category="public_exposure", + proof=Proof(evidence=[" & friends"]), + compliance_mappings=["CIS-AWS-2.1.1", "§5.2"], + # Ties for the report's :.2f / :.4f / :.1f renderings. + risk_score=2.675, + remediation=RemediationSuggestion( + finding_id="finding-éscaped", + description="Set `acl = \"private\"`.", + breaking_change=True, + downtime_estimate="", # falsy -> the Downtime line is suppressed + effort="trivial", + ), + sarif_rule_id="cloudsecurity/data/PUBLIC-exposure_v2", + sarif_security_severity=10.0, + iac_file="s3\\buckets.tf", + iac_line=7, + config_snippet='resource "aws_s3_bucket" "b" {\n\tacl = "public-read"\n}', + description="Bucket ACL is public-read.\tSee .", + fingerprint="fp-éscaped", + hunter_strategy="data", + ) + + # An unknown severity/verdict cannot exist (the enums are closed), so the + # ".get(..., default)" arms of _severity_to_level / _VERDICT_TO_PRECISION + # are unreachable from a validated model — noted rather than fixtured. + return CloudSecurityScanResult( + repository="repo/with spaces & ", + commit_sha="", + branch="", # falsy -> the report's "n/a" arm, like None + timestamp=_TIMESTAMP_OFFSET, + depth_profile="thorough", + tier=7, # neither 1 nor 2 -> "deep" + providers_detected=["azure"], + findings=[fallback, fallback_twin, escaped], + attack_paths=[bare_path], + total_resources_scanned=0, + total_raw_findings=0, + confirmed=2, + likely=1, + inconclusive=0, + not_exploitable=1, + # 1e16 renders "1e+16" in both spellings; 0.05 is a :.1f tie. + noise_reduction_pct=0.05, + by_severity={"critical": 1, "medium": 1, "info": 1}, + drift_resources=0, + shadow_it_resources=2, # only one of the two guards is non-zero + compliance_frameworks_checked=["CIS-AWS"], + compliance_gaps=[], + strategies_used=[], + duration_seconds=0.05, + agent_invocations=0, + cost_usd=0.00005, + cost_breakdown={"prove": 0.12345, "zzz": -0.0, "éphase": 1e16}, + metadata={"note": "tab\there", "ratio": 0.5, "unicode": "—"}, + sarif="", + ) + + +_SCAN_FIXTURES: dict[str, Any] = { + "scan_result": _scan_result, + "scan_result_empty": _scan_result_empty, + "scan_result_edge": _scan_result_edge, +} + + +def gen_output() -> None: + print("internal/output:") + for name, build in _SCAN_FIXTURES.items(): + # Round-trip through the fixture file so Python generates its goldens + # from EXACTLY the bytes the Go test will read. + fixture_text = json.dumps(json.loads(build().model_dump_json()), indent=2, sort_keys=False) + "\n" + _write(f"{_OUTPUT_TESTDATA}/{name}.json", fixture_text) + + result = CloudSecurityScanResult.model_validate_json(fixture_text) + _write(f"{_OUTPUT_GOLDEN}/{name}.sarif.json", generate_sarif(result)) + _write(f"{_OUTPUT_GOLDEN}/{name}.full.json", generate_json(result, pretty=True)) + _write(f"{_OUTPUT_GOLDEN}/{name}.full_compact.json", generate_json(result, pretty=False)) + _write(f"{_OUTPUT_GOLDEN}/{name}.summary.json", generate_summary_json(result)) + _write(f"{_OUTPUT_GOLDEN}/{name}.report.md", generate_report(result)) + + +# --------------------------------------------------------------------------- +# internal/pyfmt — json.dumps parity for pyfmt.Dumps / DumpsCompact +# --------------------------------------------------------------------------- +# One sub-object per model, plus a plain document. The Go test decodes the SAME +# sub-object into the identically named Go struct and renders it with +# pyfmt.Dumps; Python builds the model and renders model_dump(). +# +# No model here has a `datetime` field: json.dumps cannot serialise one, which +# is exactly why json_output.py goes through model_dump_json() instead. The +# datetime spelling is covered by the internal/output goldens. +_PYFMT_MODELS: dict[str, Any] = { + "VerifiedFinding": VerifiedFinding, + "AttackPath": AttackPath, + "ChainResult": ChainResult, + "ScanMetrics": ScanMetrics, +} + + +def _pyfmt_fixture() -> dict[str, Any]: + return { + # Nested models, optional pointers left at None, empty lists, an enum, + # unicode and a control character. + "VerifiedFinding": { + "id": "vf-1", + "title": "Bucket éxposed — \U0001F680", + "verdict": "likely", + "severity": "high", + "category": "public_exposure", + "resources": [ + { + "resource_id": "aws_s3_bucket.b", + "resource_type": "aws_s3_bucket", + "attribute": "acl", + "current_value": "public-read", + "recommended_value": "private", + } + ], + "attack_path": None, + "drift": None, + "proof": { + "method": "iam_simulation", + "evidence": [" & \"quote\"", "line\twith tab"], + "scripts_executed": [], + "verification_tier": "live", + }, + "compliance_mappings": [], + "risk_score": 7.25, + "remediation": None, + "sarif_rule_id": "cloudsecurity/data/public_exposure", + "sarif_security_severity": 10.0, + "iac_file": "s3.tf", + "iac_line": 12, + "config_snippet": "", + "description": "", + "fingerprint": "fp-1", + "hunter_strategy": "data", + "drop_reason": None, + }, + # A deeply nested list of models plus a defaulted sub-model. + "AttackPath": { + "id": "ap-1", + "title": "Path", + "description": "", + "steps": [ + { + "step_number": 1, + "resource_id": "a", + "resource_type": "t", + "action": "act", + "permission_used": "perm", + "description": "", + }, + { + "step_number": 2, + "resource_id": "b", + "resource_type": "t", + "action": "act2", + "permission_used": "perm2", + "description": "d", + }, + ], + "entry_point": "a", + "target": "b", + "findings_involved": ["f1", "f2"], + "combined_severity": "critical", + "blast_radius": { + "data_stores_reachable": [], + "compute_reachable": ["c1"], + "estimated_data_volume": None, + "services_affected": [], + }, + }, + # Empty list + the awkward float spellings. + "ChainResult": { + "attack_paths": [], + "total_paths_evaluated": 12, + "viable_paths": 0, + "chain_duration_seconds": 1e-05, + }, + # dict[str, float] (sorted-key deviation), bool, ints, and the float + # spellings Go's %v gets wrong. + "ScanMetrics": { + "duration_seconds": 1000000000000000.0, + "agent_invocations": 41, + "cost_usd": 1e16, + "cost_breakdown": {"a": 1.0, "b": 0.5, "c": -0.0, "z": 1e-05}, + "budget_exhausted": True, + "findings_not_verified": 0, + }, + # A plain JSON document (not a model): ints stay ints, floats keep their + # repr spelling, strings get ensure_ascii-escaped, containers nest. + "edge_cases": { + "int": 7, + "big_int": 1234567890123456789, + "float_integral": 1.0, + "float_tiny": 1e-05, + "float_huge": 1e16, + "float_neg_zero": -0.0, + "float_pi": 3.141592653589793, + "true": True, + "false": False, + "null": None, + "empty_list": [], + "empty_obj": {}, + "html": " & /", + "unicode": "héllo — 世界 \U0001F680", + "control": "a\tb\nc\u0000d\u007f", + "nested": {"list_of_obj": [{"k": 1}, {"k": 2}]}, + }, + } + + +def gen_pyfmt() -> None: + print("internal/pyfmt:") + fixture = _pyfmt_fixture() + _write(f"{_PYFMT_TESTDATA}/models_fixture.json", json.dumps(fixture, indent=2, sort_keys=True) + "\n") + + # Re-read so Python renders from exactly the bytes the Go test parses. + reloaded = json.loads( + (Path(_GO_ROOT) / _PYFMT_TESTDATA / "models_fixture.json").read_text(encoding="utf-8") + ) + for name, model in _PYFMT_MODELS.items(): + dumped = model(**reloaded[name]).model_dump() + _write(f"{_PYFMT_GOLDEN}/dumps_{name}_indent2.txt", json.dumps(dumped, indent=2)) + _write(f"{_PYFMT_GOLDEN}/dumps_{name}_compact.txt", json.dumps(dumped)) + + # The plain document is compared with sort_keys=True, because pyfmt.Dumps + # sorts Go map keys (its one documented ordering deviation) — the comparison + # is therefore about VALUE rendering, and the ordering deviation itself is + # pinned separately by TestDumpsMapKeysAreSorted. + doc = reloaded["edge_cases"] + _write(f"{_PYFMT_GOLDEN}/dumps_edge_cases_indent2.txt", json.dumps(doc, indent=2, sort_keys=True)) + _write(f"{_PYFMT_GOLDEN}/dumps_edge_cases_compact.txt", json.dumps(doc, sort_keys=True)) + + +def main() -> None: + gen_pyfmt() + gen_output() + print("done") + + +if __name__ == "__main__": + main() diff --git a/go/scripts/gen_model_keys.py b/go/scripts/gen_model_keys.py new file mode 100755 index 0000000..e588f26 --- /dev/null +++ b/go/scripts/gen_model_keys.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python +"""Emit the pydantic ground truth the Go schema-parity test asserts against. + +For every pydantic model reachable from ``src/cloudsecurity_af`` (every class in +``schemas/*.py`` plus the two models declared inside +``agents/chain/path_constructor.py``) this writes, into +``go/internal/schemas/testdata/model_keys.json``: + +* ``module`` — the defining Python module, so a reviewer can diff coverage. +* ``keys`` — ``list(Model(**minimal_required).model_dump().keys())`` in + declaration order. The Go test asserts the marshaled key SET of the + corresponding ``New()`` value matches exactly (no ``omitempty`` + anywhere: ``model_dump()`` emits every field). +* ``dump`` — ``jsonable_encoder(model_dump())`` of that same instance, i.e. + the exact JSON the control plane sees when a reasoner returns the model. + The Go test compares this value-by-value for every key not listed in + ``nondeterministic``, which pins every pydantic default (``"terraform"``, + ``["us-east-1"]``, ``"moderate"``, ``Severity.HIGH`` …). +* ``nondeterministic`` — fields whose default is ``uuid4()``/``datetime.now`` + and therefore cannot be compared by value. + +Regenerate with (never ``python3``, which lacks the deps): + + PYTHONPATH=/src ~/.agentfield/packages/cloudsecurity-af/venv/bin/python \ + go/scripts/gen_model_keys.py +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from fastapi.encoders import jsonable_encoder + +from cloudsecurity_af.agents.chain.path_constructor import ChildInvestigation, PathInvestigationPlan +from cloudsecurity_af.schemas.chain import AttackPath, AttackStep, BlastRadius, ChainResult +from cloudsecurity_af.schemas.hunt import AffectedResource, HuntResult, RawFinding +from cloudsecurity_af.schemas.input import CloudConfig, CloudSecurityInput +from cloudsecurity_af.schemas.output import CloudSecurityScanResult, ScanMetrics, ScanProgress +from cloudsecurity_af.schemas.prove import ( + IaCDiff, + Proof, + ProofMethod, + RemediationSuggestion, + Verdict, + VerifiedFinding, +) +from cloudsecurity_af.schemas.recon import ( + ConfigDiff, + DriftedResource, + DriftReport, + Module, + Output, + ProviderConfig, + ReconResult, + Resource, + ResourceGraph, + ResourceInventory, + Variable, +) +from cloudsecurity_af.schemas.views import FindingForChain, FindingForDedup, FindingForProver +from cloudsecurity_af.scoring import Severity + +# The fixed timestamp the Go test also uses for CloudSecurityScanResult, so the +# "timestamp" key can be compared by value instead of being skipped. +FIXED_TS = datetime(2026, 1, 2, 3, 4, 5, 123456, tzinfo=UTC) + +# Stand-in written into the fixture for every uuid4()-defaulted field. +UUID_PLACEHOLDER = "" + +# model -> (minimal required kwargs, nondeterministic field names). +# +# "minimal required" means exactly the fields pydantic refuses to default; every +# required string is passed as "" and every required int as 0 so the resulting +# dump is the pure default vector the Go New() constructor must match. +MODELS: list[tuple[str, type, dict[str, Any], list[str]]] = [ + # --- schemas/recon.py --- + ("Variable", Variable, {"name": ""}, []), + ("Output", Output, {"name": ""}, []), + ("ProviderConfig", ProviderConfig, {"name": ""}, []), + ("Module", Module, {"name": "", "source": ""}, []), + ( + "Resource", + Resource, + {"id": "", "type": "", "name": "", "provider": "", "file_path": ""}, + [], + ), + ("ResourceInventory", ResourceInventory, {"inventory_saved_path": ""}, []), + ("ResourceGraph", ResourceGraph, {"graph_saved_path": ""}, []), + ("ConfigDiff", ConfigDiff, {"attribute": ""}, []), + ("DriftedResource", DriftedResource, {"resource_id": "", "resource_type": ""}, []), + ("DriftReport", DriftReport, {}, []), + # ReconResult's `inventory`/`resource_graph` default_factories raise (both + # inner models have a required field), so the "minimal" instance must pass + # them explicitly. See doc.go "ReconResult" parity note. + ( + "ReconResult", + ReconResult, + { + "inventory": ResourceInventory(inventory_saved_path=""), + "resource_graph": ResourceGraph(graph_saved_path=""), + }, + [], + ), + # --- schemas/hunt.py --- + ( + "AffectedResource", + AffectedResource, + {"resource_id": "", "resource_type": "", "attribute": ""}, + [], + ), + ( + "RawFinding", + RawFinding, + {"hunter_strategy": "", "title": "", "description": "", "category": ""}, + ["id", "fingerprint"], + ), + ("HuntResult", HuntResult, {}, []), + # --- schemas/chain.py --- + ( + "AttackStep", + AttackStep, + { + "step_number": 0, + "resource_id": "", + "resource_type": "", + "action": "", + "permission_used": "", + }, + [], + ), + ("BlastRadius", BlastRadius, {}, []), + ( + "AttackPath", + AttackPath, + {"title": "", "description": "", "entry_point": "", "target": ""}, + ["id"], + ), + ("ChainResult", ChainResult, {}, []), + # --- schemas/prove.py --- + ("Proof", Proof, {}, []), + ( + "IaCDiff", + IaCDiff, + {"file_path": "", "original_lines": "", "patched_lines": ""}, + [], + ), + ("RemediationSuggestion", RemediationSuggestion, {"description": ""}, []), + ( + "VerifiedFinding", + VerifiedFinding, + { + "title": "", + "verdict": Verdict.CONFIRMED, + "severity": Severity.MEDIUM, + "category": "", + }, + ["id", "fingerprint"], + ), + # --- schemas/input.py --- + ("CloudConfig", CloudConfig, {}, []), + ("CloudSecurityInput", CloudSecurityInput, {"repo_url": ""}, []), + # --- schemas/output.py --- + ( + "CloudSecurityScanResult", + CloudSecurityScanResult, + { + "repository": "", + "commit_sha": "", + "timestamp": FIXED_TS, + "depth_profile": "", + "tier": 0, + }, + [], + ), + ( + "ScanProgress", + ScanProgress, + { + "phase": "", + "phase_progress": 0.0, + "agents_total": 0, + "agents_completed": 0, + "agents_running": 0, + "findings_so_far": 0, + "elapsed_seconds": 0.0, + "estimated_remaining_seconds": 0.0, + "cost_so_far_usd": 0.0, + }, + [], + ), + ( + "ScanMetrics", + ScanMetrics, + {"duration_seconds": 0.0, "agent_invocations": 0, "cost_usd": 0.0}, + [], + ), + # --- schemas/views.py --- + ( + "FindingForDedup", + FindingForDedup, + { + "id": "", + "fingerprint": "", + "title": "", + "iac_file": "", + "iac_line": 0, + "category": "", + "hunter_strategy": "", + "estimated_severity": "", + }, + [], + ), + ( + "FindingForProver", + FindingForProver, + { + "id": "", + "title": "", + "description": "", + "category": "", + "hunter_strategy": "", + "iac_file": "", + "iac_line": 0, + "config_snippet": "", + }, + [], + ), + ( + "FindingForChain", + FindingForChain, + {"id": "", "title": "", "description": "", "category": ""}, + [], + ), + # --- agents/chain/path_constructor.py (BaseModels outside schemas/) --- + ("ChildInvestigation", ChildInvestigation, {"title": "", "child_prompt": ""}, []), + ("PathInvestigationPlan", PathInvestigationPlan, {}, []), +] + + +def main() -> None: + out: dict[str, Any] = {} + for name, model, kwargs, nondet in MODELS: + instance = model(**kwargs) + dump = instance.model_dump() + encoded = jsonable_encoder(dump) + # Keep the committed fixture byte-stable across regenerations: uuid4() + # defaults are replaced by a placeholder. The Go test skips these keys + # for value comparison and instead asserts the Go default is a distinct, + # well-formed RFC 4122 v4 string. + for field in nondet: + encoded[field] = UUID_PLACEHOLDER + out[name] = { + "module": model.__module__, + "keys": list(dump.keys()), + "dump": encoded, + "nondeterministic": nondet, + } + target = Path(__file__).resolve().parent.parent / "internal" / "schemas" / "testdata" / "model_keys.json" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(out, indent=2, sort_keys=True) + "\n") + print(f"wrote {target} ({len(out)} models)") + + +if __name__ == "__main__": + main() diff --git a/go/scripts/gen_schemas.py b/go/scripts/gen_schemas.py new file mode 100644 index 0000000..6d6d99a --- /dev/null +++ b/go/scripts/gen_schemas.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Committed schema-fixture generator for the cloudsecurity-af Go port. + +This script is the SINGLE SOURCE OF TRUTH for the JSON-schema fixtures under +``go/internal/harnessx/testdata/schemas/``. It imports the REAL pydantic models +that this repo hands to ``app.harness(..., schema=...)`` and emits, for each +one, EXACTLY the schema the Python SDK would build from it — i.e. +``model_json_schema()`` — so the Go harness embeds that schema instead of +reflecting its Go destination struct with invopop. + +WHY THIS EXISTS +--------------- +The Go SDK (pinned ``sdk/go`` in ``go/go.mod``) validates every parsed harness +output against the schema map with a strict JSON-Schema validator +(``santhosh-tekuri/jsonschema/v5`` in ``harness/schema.go`` -> +``validateAgainstSchema``) and drives its schema-retry loop off validation +failures. An invopop-reflected schema marks EVERY field required, renders +pointer fields non-nullable and sets ``additionalProperties: false``. Pydantic +instead makes defaulted fields optional, ``X | None`` fields nullable, and +ignores extra keys — so Python-valid model output would be REJECTED by the Go +node: wasted retries, fallback outputs, lost findings. Embedding the pydantic +schema restores parity on those three axes. + +NO DEVIATIONS +------------- +Unlike the pr-af port, cloudsecurity-af has no ``BeforeValidator``-normalized +enums (``scoring.Severity`` and friends are plain ``str, Enum`` classes with no +coercion), so every fixture is a verbatim ``model_json_schema()`` dump. If a +coercing validator is ever added, relax the corresponding enum here and say so. + +FIXTURE NAMING +-------------- +``harnessx.SchemaFor[T]`` resolves a fixture by the Go destination type's NAME: +``testdata/schemas/.json``. The port contract requires Go struct names +to equal the pydantic class names exactly, so the dict below is keyed by the +pydantic class name and nothing else needs to stay in sync. + +The model list is the complete set of ``schema=`` call sites, enumerated with:: + + grep -rn 'schema=' src/ + + src/cloudsecurity_af/agents/recon/iac_reader.py:48 ResourceInventory + src/cloudsecurity_af/agents/recon/cloud_connector.py:33 ResourceInventory + src/cloudsecurity_af/agents/recon/resource_graph_builder.py:48 ResourceGraph + src/cloudsecurity_af/agents/recon/drift_detector.py:35 DriftReport + src/cloudsecurity_af/agents/hunt/iam_hunter.py:52 HuntResult + src/cloudsecurity_af/agents/hunt/network_hunter.py:72 HuntResult + src/cloudsecurity_af/agents/hunt/data_hunter.py:74 HuntResult + src/cloudsecurity_af/agents/hunt/secrets_hunter.py:66 HuntResult + src/cloudsecurity_af/agents/hunt/compute_hunter.py:68 HuntResult + src/cloudsecurity_af/agents/hunt/logging_hunter.py:52 HuntResult + src/cloudsecurity_af/agents/hunt/compliance_hunter.py:52 HuntResult + src/cloudsecurity_af/agents/chain/path_constructor.py:163 PathInvestigationPlan + src/cloudsecurity_af/agents/chain/path_constructor.py:180 AttackPath + src/cloudsecurity_af/agents/prove/static_prover.py:70 VerifiedFinding + src/cloudsecurity_af/agents/prove/live_prover.py:70 VerifiedFinding + src/cloudsecurity_af/agents/remediate/fix_generator.py:59 RemediationSuggestion + src/cloudsecurity_af/orchestrator.py:47 (_PhaseHarnessProxy passthrough) + +There are no ``app.ai(schema=...)`` call sites in this repo. + +REPRODUCE (from the repo root):: + + PYTHONPATH=src ~/.agentfield/packages/cloudsecurity-af/venv/bin/python go/scripts/gen_schemas.py + +Deterministic and idempotent: rerunning overwrites the fixtures with identical +bytes unless a pydantic model changed — exactly the signal the Go drift test +exists to catch. +""" + +from __future__ import annotations + +import json +import os +import sys +from typing import Any + +# Make `cloudsecurity_af` importable when run from the repo root without install. +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_SRC = os.path.join(_REPO_ROOT, "src") +if os.path.isdir(_SRC) and _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cloudsecurity_af.agents.chain.path_constructor import PathInvestigationPlan # noqa: E402 +from cloudsecurity_af.schemas.chain import AttackPath # noqa: E402 +from cloudsecurity_af.schemas.hunt import HuntResult # noqa: E402 +from cloudsecurity_af.schemas.prove import RemediationSuggestion, VerifiedFinding # noqa: E402 +from cloudsecurity_af.schemas.recon import DriftReport, ResourceGraph, ResourceInventory # noqa: E402 + +TESTDATA = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "internal", + "harnessx", + "testdata", + "schemas", +) + +# fixture basename (== the Go destination type name == the pydantic class name) +# -> the pydantic model. go:embed skips names beginning with "_" or "." unless +# the pattern uses the all: prefix, so every basename here must be plain. +MODELS: dict[str, Any] = { + "AttackPath": AttackPath, + "DriftReport": DriftReport, + "HuntResult": HuntResult, + "PathInvestigationPlan": PathInvestigationPlan, + "RemediationSuggestion": RemediationSuggestion, + "ResourceGraph": ResourceGraph, + "ResourceInventory": ResourceInventory, + "VerifiedFinding": VerifiedFinding, +} + + +def main() -> int: + os.makedirs(TESTDATA, exist_ok=True) + + written = [] + for name, model in sorted(MODELS.items()): + schema = model.model_json_schema() + path = os.path.join(TESTDATA, f"{name}.json") + # sort_keys keeps the bytes stable across pydantic versions that reorder + # their dict construction; indent=2 matches the repo's JSON style. + # + # It also makes the committed bytes match what the reader will emit. + # harnessx decodes this fixture into a map[string]any and the Go SDK + # renders THAT with json.MarshalIndent, which sorts map keys — so the + # JSON Schema block appended to every harness prompt is alphabetised in + # Go and in pydantic declaration order in Python. Same content, same + # byte length, different order; recorded as divergence 7 in + # go/README.md and in internal/harnessx/schema.go. Writing the fixture + # unsorted would only make the committed file disagree with the prompt + # as well. + payload = json.dumps(schema, indent=2, sort_keys=True) + "\n" + with open(path, "w", encoding="utf-8") as handle: + handle.write(payload) + written.append(os.path.relpath(path, _REPO_ROOT)) + + for path in written: + print(f"wrote {path}") + + # Fail loudly if a stale fixture is left behind after a model is removed: + # an orphan would still be embedded and silently used by SchemaFor. + expected = {f"{name}.json" for name in MODELS} + actual = {entry for entry in os.listdir(TESTDATA) if entry.endswith(".json")} + orphans = sorted(actual - expected) + if orphans: + print(f"ERROR: orphaned fixtures (delete them): {orphans}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/go/scripts/gen_scoring_matrix.py b/go/scripts/gen_scoring_matrix.py new file mode 100755 index 0000000..e20f3f3 --- /dev/null +++ b/go/scripts/gen_scoring_matrix.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python +"""Emit the exhaustive compute_risk_score() ground truth for the Go scoring test. + +Writes ``go/internal/scoring/testdata/risk_score_matrix.json``: every +(Severity x EvidenceMethod x Exposure x has_attack_path x has_drift) combination +— 5*6*5*2*2 = 600 rows — with the exact float Python's +``round(min(max(score, 0.0), 10.0), 2)`` produces. The Go test replays the +matrix through scoring.ComputeRiskScore and requires bit-identical float64s, +which is what pins the banker's-rounding helper. + +Regenerate with (never ``python3``, which lacks the deps): + + PYTHONPATH=/src ~/.agentfield/packages/cloudsecurity-af/venv/bin/python \ + go/scripts/gen_scoring_matrix.py +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from cloudsecurity_af.scoring import EvidenceMethod, Exposure, Severity, compute_risk_score + + +def main() -> None: + rows = [] + for severity in Severity: + for evidence in EvidenceMethod: + for exposure in Exposure: + for has_attack_path in (False, True): + for has_drift in (False, True): + rows.append( + { + "severity": severity.value, + "evidence_method": evidence.value, + "exposure": exposure.value, + "has_attack_path": has_attack_path, + "has_drift": has_drift, + "score": compute_risk_score( + severity, + evidence, + exposure, + has_attack_path=has_attack_path, + has_drift=has_drift, + ), + } + ) + target = Path(__file__).resolve().parent.parent / "internal" / "scoring" / "testdata" / "risk_score_matrix.json" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(rows, indent=2) + "\n") + print(f"wrote {target} ({len(rows)} rows)") + + +if __name__ == "__main__": + main() diff --git a/go/scripts/gen_strictify_golden.py b/go/scripts/gen_strictify_golden.py new file mode 100644 index 0000000..7128b91 --- /dev/null +++ b/go/scripts/gen_strictify_golden.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Golden generator for internal/aix.Strictify. + +Runs the REAL AgentField Python SDK function that `app.ai(schema=Model)` uses — +``agentfield.agent_ai._strictify_openai_schema`` — over the committed pydantic +schema fixtures, and writes its output to ``go/internal/aix/testdata/``. The Go +test then strictifies the same fixtures and compares. + +The inputs are the fixtures under ``go/internal/harnessx/testdata/schemas/``, +which gen_schemas.py writes with ``sort_keys=True``; that matters because the +strictifier's ``required`` list is ``list(props.keys())``, i.e. the input dict's +insertion order. With a sorted input, Python's order and the Go port's (sorted) +order are the same, so the goldens compare byte-for-byte after both sides are +re-dumped with sorted keys. + +REPRODUCE (from the repo root):: + + ~/.agentfield/packages/cloudsecurity-af/venv/bin/python go/scripts/gen_strictify_golden.py +""" + +from __future__ import annotations + +import json +import os +import sys + +from agentfield.agent_ai import _strictify_openai_schema + +_GO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SCHEMAS = os.path.join(_GO_ROOT, "internal", "harnessx", "testdata", "schemas") +TESTDATA = os.path.join(_GO_ROOT, "internal", "aix", "testdata") + +# One flat model and one with a deep $defs graph, which is where the recursion +# into $defs / properties / items / anyOf is actually exercised. +FIXTURES = ["PathInvestigationPlan", "HuntResult", "VerifiedFinding"] + + +def main() -> int: + os.makedirs(TESTDATA, exist_ok=True) + for name in FIXTURES: + src = os.path.join(SCHEMAS, f"{name}.json") + with open(src, encoding="utf-8") as handle: + schema = json.load(handle) + + strict = _strictify_openai_schema(schema) + + dst = os.path.join(TESTDATA, f"strict_{name}.json") + with open(dst, "w", encoding="utf-8") as handle: + handle.write(json.dumps(strict, indent=2, sort_keys=True) + "\n") + print(f"wrote {os.path.relpath(dst, _GO_ROOT)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 59fb14de590a42e58556132ab42b6b1bf72bbe58 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 20 Aug 2026 02:11:58 -0400 Subject: [PATCH 2/5] feat(go): port the cloudsecurity agents and output emitters The deterministic Terraform inventory parser (hcl/v2 port of the pyhcl2 walk, same inventory.json shape) with the harness fallback, the fast graph builder, cloud connector and drift detector, the 7 hunters over the shared graph-context assembly, the attack-path constructor, the static/live provers and the fix generator, plus the SARIF/JSON/Markdown emitters. Prompts and artifacts are golden-tested byte-for-byte against the Python implementations. Co-Authored-By: Claude Fable 5 --- go/internal/agents/chain/doc.go | 65 ++ go/internal/agents/chain/path_constructor.go | 521 ++++++++++ .../agents/chain/path_constructor_test.go | 721 +++++++++++++ .../chain/testdata/golden/child_prompt_a.txt | 9 + .../chain/testdata/golden/child_prompt_b.txt | 8 + .../agents/chain/testdata/golden/graph.json | 62 ++ .../testdata/golden/graph_not_object.json | 4 + .../agents/chain/testdata/golden/inputs.json | 118 +++ .../chain/testdata/golden/parent_prompt_a.txt | 165 +++ .../chain/testdata/golden/parent_prompt_b.txt | 94 ++ .../chain/testdata/golden/parent_prompt_c.txt | 82 ++ go/internal/agents/hunt/compliance_hunter.go | 39 + go/internal/agents/hunt/compute_hunter.go | 52 + go/internal/agents/hunt/data_hunter.go | 58 ++ go/internal/agents/hunt/doc.go | 49 + go/internal/agents/hunt/hunt.go | 122 +++ go/internal/agents/hunt/hunt_test.go | 473 +++++++++ go/internal/agents/hunt/iam_hunter.go | 44 + go/internal/agents/hunt/logging_hunter.go | 42 + go/internal/agents/hunt/network_hunter.go | 56 ++ go/internal/agents/hunt/secrets_hunter.go | 49 + .../agents/hunt/testdata/fixture/graph.json | 148 +++ .../hunt/testdata/fixture/inventory.json | 142 +++ .../hunt/testdata/fixture/not_an_object.json | 1 + .../golden/compliance_hunter_prompt.txt | 102 ++ .../testdata/golden/compute_hunter_prompt.txt | 88 ++ .../testdata/golden/data_hunter_prompt.txt | 89 ++ .../hunt/testdata/golden/empty_result.json | 65 ++ .../hunt/testdata/golden/harness_options.json | 37 + .../testdata/golden/iam_hunter_prompt.txt | 85 ++ .../testdata/golden/logging_hunter_prompt.txt | 82 ++ .../testdata/golden/network_hunter_prompt.txt | 84 ++ .../testdata/golden/secrets_hunter_prompt.txt | 91 ++ go/internal/agents/prove/doc.go | 75 ++ go/internal/agents/prove/prover.go | 215 ++++ go/internal/agents/prove/prover_test.go | 361 +++++++ .../agents/prove/testdata/golden/inputs.json | 82 ++ .../prove/testdata/golden/live_prompt_a.txt | 138 +++ .../prove/testdata/golden/live_prompt_b.txt | 88 ++ .../prove/testdata/golden/static_prompt_a.txt | 133 +++ .../prove/testdata/golden/static_prompt_b.txt | 83 ++ go/internal/agents/recon/agents_test.go | 369 +++++++ go/internal/agents/recon/cloud_connector.go | 68 ++ go/internal/agents/recon/cloudconfig.go | 78 ++ go/internal/agents/recon/doc.go | 60 ++ go/internal/agents/recon/drift_detector.go | 73 ++ go/internal/agents/recon/golden_test.go | 173 ++++ go/internal/agents/recon/graphfast.go | 435 ++++++++ .../agents/recon/graphfast_malformed_test.go | 166 +++ go/internal/agents/recon/graphfast_test.go | 372 +++++++ go/internal/agents/recon/iac_reader.go | 119 +++ .../agents/recon/pyfmt_helpers_test.go | 20 + .../agents/recon/resource_graph_builder.go | 89 ++ .../agents/recon/testdata/expressions/main.tf | 107 ++ .../golden/cloud_connector_prompt_a.txt | 49 + .../golden/cloud_connector_prompt_b.txt | 50 + .../golden/cloud_connector_prompt_c.txt | 42 + .../golden/drift_detector_prompt_a.txt | 60 ++ .../golden/drift_detector_prompt_b.txt | 61 ++ .../golden/drift_detector_prompt_c.txt | 53 + .../testdata/golden/iac_reader_prompt.txt | 33 + .../golden/resource_graph_builder_prompt.txt | 33 + .../python/expressions_inventory.json | 172 ++++ .../agents/recon/testdata/python/graph.json | 109 ++ .../recon/testdata/python/inventory.json | 140 +++ .../agents/recon/testdata/python/summary.json | 6 + .../recon/testdata/vulnerable_infra/main.tf | 89 ++ go/internal/agents/recon/tfparse.go | 818 +++++++++++++++ go/internal/agents/recon/tfparse_test.go | 952 ++++++++++++++++++ go/internal/agents/remediate/doc.go | 47 + go/internal/agents/remediate/fix_generator.go | 130 +++ .../agents/remediate/fix_generator_test.go | 296 ++++++ .../testdata/golden/fix_prompt_a.txt | 204 ++++ .../testdata/golden/fix_prompt_b.txt | 98 ++ .../remediate/testdata/golden/inputs.json | 163 +++ go/internal/agents/util/doc.go | 35 + go/internal/agents/util/graphcontext.go | 295 ++++++ go/internal/agents/util/graphcontext_test.go | 376 +++++++ go/internal/agents/util/path.go | 69 ++ go/internal/agents/util/path_test.go | 84 ++ go/internal/agents/util/pyvalue.go | 125 +++ go/internal/agents/util/pyvalue_test.go | 79 ++ .../agents/util/testdata/fixture/graph.json | 148 +++ .../util/testdata/fixture/inventory.json | 142 +++ .../util/testdata/fixture/not_an_object.json | 1 + .../agents/util/testdata/golden/all_edges.txt | 6 + .../agents/util/testdata/golden/all_nodes.txt | 19 + .../agents/util/testdata/golden/all_stats.txt | 9 + .../util/testdata/golden/data_edges.txt | 3 + .../util/testdata/golden/data_nodes.txt | 9 + .../util/testdata/golden/data_stats.txt | 9 + .../agents/util/testdata/golden/iam_edges.txt | 3 + .../agents/util/testdata/golden/iam_nodes.txt | 5 + .../agents/util/testdata/golden/iam_stats.txt | 9 + .../testdata/golden/missing_files_edges.txt | 2 + .../testdata/golden/missing_files_nodes.txt | 2 + .../testdata/golden/missing_files_stats.txt | 9 + .../util/testdata/golden/nomatch_edges.txt | 2 + .../util/testdata/golden/nomatch_nodes.txt | 2 + .../util/testdata/golden/nomatch_stats.txt | 9 + .../testdata/golden/not_an_object_edges.txt | 2 + .../testdata/golden/not_an_object_nodes.txt | 2 + .../testdata/golden/not_an_object_stats.txt | 9 + go/internal/output/golden_test.go | 171 ++++ go/internal/output/json_output.go | 185 ++++ go/internal/output/json_output_test.go | 305 ++++++ go/internal/output/pydantic.go | 469 +++++++++ go/internal/output/pydantic_test.go | 332 ++++++ go/internal/output/report.go | 241 +++++ go/internal/output/report_test.go | 403 ++++++++ go/internal/output/sarif.go | 404 ++++++++ go/internal/output/sarif_test.go | 423 ++++++++ .../testdata/golden/scan_result.full.json | 392 ++++++++ .../golden/scan_result.full_compact.json | 1 + .../testdata/golden/scan_result.report.md | 101 ++ .../testdata/golden/scan_result.sarif.json | 195 ++++ .../testdata/golden/scan_result.summary.json | 114 +++ .../golden/scan_result_edge.full.json | 161 +++ .../golden/scan_result_edge.full_compact.json | 1 + .../golden/scan_result_edge.report.md | 79 ++ .../golden/scan_result_edge.sarif.json | 150 +++ .../golden/scan_result_edge.summary.json | 93 ++ .../golden/scan_result_empty.full.json | 30 + .../scan_result_empty.full_compact.json | 1 + .../golden/scan_result_empty.report.md | 30 + .../golden/scan_result_empty.sarif.json | 20 + .../golden/scan_result_empty.summary.json | 31 + go/internal/output/testdata/scan_result.json | 392 ++++++++ .../output/testdata/scan_result_edge.json | 161 +++ .../output/testdata/scan_result_empty.json | 30 + 130 files changed, 17136 insertions(+) create mode 100644 go/internal/agents/chain/doc.go create mode 100644 go/internal/agents/chain/path_constructor.go create mode 100644 go/internal/agents/chain/path_constructor_test.go create mode 100644 go/internal/agents/chain/testdata/golden/child_prompt_a.txt create mode 100644 go/internal/agents/chain/testdata/golden/child_prompt_b.txt create mode 100644 go/internal/agents/chain/testdata/golden/graph.json create mode 100644 go/internal/agents/chain/testdata/golden/graph_not_object.json create mode 100644 go/internal/agents/chain/testdata/golden/inputs.json create mode 100644 go/internal/agents/chain/testdata/golden/parent_prompt_a.txt create mode 100644 go/internal/agents/chain/testdata/golden/parent_prompt_b.txt create mode 100644 go/internal/agents/chain/testdata/golden/parent_prompt_c.txt create mode 100644 go/internal/agents/hunt/compliance_hunter.go create mode 100644 go/internal/agents/hunt/compute_hunter.go create mode 100644 go/internal/agents/hunt/data_hunter.go create mode 100644 go/internal/agents/hunt/doc.go create mode 100644 go/internal/agents/hunt/hunt.go create mode 100644 go/internal/agents/hunt/hunt_test.go create mode 100644 go/internal/agents/hunt/iam_hunter.go create mode 100644 go/internal/agents/hunt/logging_hunter.go create mode 100644 go/internal/agents/hunt/network_hunter.go create mode 100644 go/internal/agents/hunt/secrets_hunter.go create mode 100644 go/internal/agents/hunt/testdata/fixture/graph.json create mode 100644 go/internal/agents/hunt/testdata/fixture/inventory.json create mode 100644 go/internal/agents/hunt/testdata/fixture/not_an_object.json create mode 100644 go/internal/agents/hunt/testdata/golden/compliance_hunter_prompt.txt create mode 100644 go/internal/agents/hunt/testdata/golden/compute_hunter_prompt.txt create mode 100644 go/internal/agents/hunt/testdata/golden/data_hunter_prompt.txt create mode 100644 go/internal/agents/hunt/testdata/golden/empty_result.json create mode 100644 go/internal/agents/hunt/testdata/golden/harness_options.json create mode 100644 go/internal/agents/hunt/testdata/golden/iam_hunter_prompt.txt create mode 100644 go/internal/agents/hunt/testdata/golden/logging_hunter_prompt.txt create mode 100644 go/internal/agents/hunt/testdata/golden/network_hunter_prompt.txt create mode 100644 go/internal/agents/hunt/testdata/golden/secrets_hunter_prompt.txt create mode 100644 go/internal/agents/prove/doc.go create mode 100644 go/internal/agents/prove/prover.go create mode 100644 go/internal/agents/prove/prover_test.go create mode 100644 go/internal/agents/prove/testdata/golden/inputs.json create mode 100644 go/internal/agents/prove/testdata/golden/live_prompt_a.txt create mode 100644 go/internal/agents/prove/testdata/golden/live_prompt_b.txt create mode 100644 go/internal/agents/prove/testdata/golden/static_prompt_a.txt create mode 100644 go/internal/agents/prove/testdata/golden/static_prompt_b.txt create mode 100644 go/internal/agents/recon/agents_test.go create mode 100644 go/internal/agents/recon/cloud_connector.go create mode 100644 go/internal/agents/recon/cloudconfig.go create mode 100644 go/internal/agents/recon/doc.go create mode 100644 go/internal/agents/recon/drift_detector.go create mode 100644 go/internal/agents/recon/golden_test.go create mode 100644 go/internal/agents/recon/graphfast.go create mode 100644 go/internal/agents/recon/graphfast_malformed_test.go create mode 100644 go/internal/agents/recon/graphfast_test.go create mode 100644 go/internal/agents/recon/iac_reader.go create mode 100644 go/internal/agents/recon/pyfmt_helpers_test.go create mode 100644 go/internal/agents/recon/resource_graph_builder.go create mode 100644 go/internal/agents/recon/testdata/expressions/main.tf create mode 100644 go/internal/agents/recon/testdata/golden/cloud_connector_prompt_a.txt create mode 100644 go/internal/agents/recon/testdata/golden/cloud_connector_prompt_b.txt create mode 100644 go/internal/agents/recon/testdata/golden/cloud_connector_prompt_c.txt create mode 100644 go/internal/agents/recon/testdata/golden/drift_detector_prompt_a.txt create mode 100644 go/internal/agents/recon/testdata/golden/drift_detector_prompt_b.txt create mode 100644 go/internal/agents/recon/testdata/golden/drift_detector_prompt_c.txt create mode 100644 go/internal/agents/recon/testdata/golden/iac_reader_prompt.txt create mode 100644 go/internal/agents/recon/testdata/golden/resource_graph_builder_prompt.txt create mode 100644 go/internal/agents/recon/testdata/python/expressions_inventory.json create mode 100644 go/internal/agents/recon/testdata/python/graph.json create mode 100644 go/internal/agents/recon/testdata/python/inventory.json create mode 100644 go/internal/agents/recon/testdata/python/summary.json create mode 100644 go/internal/agents/recon/testdata/vulnerable_infra/main.tf create mode 100644 go/internal/agents/recon/tfparse.go create mode 100644 go/internal/agents/recon/tfparse_test.go create mode 100644 go/internal/agents/remediate/doc.go create mode 100644 go/internal/agents/remediate/fix_generator.go create mode 100644 go/internal/agents/remediate/fix_generator_test.go create mode 100644 go/internal/agents/remediate/testdata/golden/fix_prompt_a.txt create mode 100644 go/internal/agents/remediate/testdata/golden/fix_prompt_b.txt create mode 100644 go/internal/agents/remediate/testdata/golden/inputs.json create mode 100644 go/internal/agents/util/doc.go create mode 100644 go/internal/agents/util/graphcontext.go create mode 100644 go/internal/agents/util/graphcontext_test.go create mode 100644 go/internal/agents/util/path.go create mode 100644 go/internal/agents/util/path_test.go create mode 100644 go/internal/agents/util/pyvalue.go create mode 100644 go/internal/agents/util/pyvalue_test.go create mode 100644 go/internal/agents/util/testdata/fixture/graph.json create mode 100644 go/internal/agents/util/testdata/fixture/inventory.json create mode 100644 go/internal/agents/util/testdata/fixture/not_an_object.json create mode 100644 go/internal/agents/util/testdata/golden/all_edges.txt create mode 100644 go/internal/agents/util/testdata/golden/all_nodes.txt create mode 100644 go/internal/agents/util/testdata/golden/all_stats.txt create mode 100644 go/internal/agents/util/testdata/golden/data_edges.txt create mode 100644 go/internal/agents/util/testdata/golden/data_nodes.txt create mode 100644 go/internal/agents/util/testdata/golden/data_stats.txt create mode 100644 go/internal/agents/util/testdata/golden/iam_edges.txt create mode 100644 go/internal/agents/util/testdata/golden/iam_nodes.txt create mode 100644 go/internal/agents/util/testdata/golden/iam_stats.txt create mode 100644 go/internal/agents/util/testdata/golden/missing_files_edges.txt create mode 100644 go/internal/agents/util/testdata/golden/missing_files_nodes.txt create mode 100644 go/internal/agents/util/testdata/golden/missing_files_stats.txt create mode 100644 go/internal/agents/util/testdata/golden/nomatch_edges.txt create mode 100644 go/internal/agents/util/testdata/golden/nomatch_nodes.txt create mode 100644 go/internal/agents/util/testdata/golden/nomatch_stats.txt create mode 100644 go/internal/agents/util/testdata/golden/not_an_object_edges.txt create mode 100644 go/internal/agents/util/testdata/golden/not_an_object_nodes.txt create mode 100644 go/internal/agents/util/testdata/golden/not_an_object_stats.txt create mode 100644 go/internal/output/golden_test.go create mode 100644 go/internal/output/json_output.go create mode 100644 go/internal/output/json_output_test.go create mode 100644 go/internal/output/pydantic.go create mode 100644 go/internal/output/pydantic_test.go create mode 100644 go/internal/output/report.go create mode 100644 go/internal/output/report_test.go create mode 100644 go/internal/output/sarif.go create mode 100644 go/internal/output/sarif_test.go create mode 100644 go/internal/output/testdata/golden/scan_result.full.json create mode 100644 go/internal/output/testdata/golden/scan_result.full_compact.json create mode 100644 go/internal/output/testdata/golden/scan_result.report.md create mode 100644 go/internal/output/testdata/golden/scan_result.sarif.json create mode 100644 go/internal/output/testdata/golden/scan_result.summary.json create mode 100644 go/internal/output/testdata/golden/scan_result_edge.full.json create mode 100644 go/internal/output/testdata/golden/scan_result_edge.full_compact.json create mode 100644 go/internal/output/testdata/golden/scan_result_edge.report.md create mode 100644 go/internal/output/testdata/golden/scan_result_edge.sarif.json create mode 100644 go/internal/output/testdata/golden/scan_result_edge.summary.json create mode 100644 go/internal/output/testdata/golden/scan_result_empty.full.json create mode 100644 go/internal/output/testdata/golden/scan_result_empty.full_compact.json create mode 100644 go/internal/output/testdata/golden/scan_result_empty.report.md create mode 100644 go/internal/output/testdata/golden/scan_result_empty.sarif.json create mode 100644 go/internal/output/testdata/golden/scan_result_empty.summary.json create mode 100644 go/internal/output/testdata/scan_result.json create mode 100644 go/internal/output/testdata/scan_result_edge.json create mode 100644 go/internal/output/testdata/scan_result_empty.json diff --git a/go/internal/agents/chain/doc.go b/go/internal/agents/chain/doc.go new file mode 100644 index 0000000..9cbe058 --- /dev/null +++ b/go/internal/agents/chain/doc.go @@ -0,0 +1,65 @@ +// Package chain ports src/cloudsecurity_af/agents/chain/** — the CHAIN phase, +// CloudSecurity AF's key differentiator. +// +// Python Go +// --------------------------------------------- -------------------------------- +// chain/path_constructor.run_path_constructor RunPathConstructor +// chain/path_constructor._build_parent_prompt BuildParentPrompt +// chain/path_constructor._child_prompt BuildChildPrompt +// chain/path_constructor._compact_finding compactFinding +// chain/path_constructor._filter_graph_for_... filterGraphForFindings +// +// RunPathConstructor is what internal/reasoners wraps as the +// `run_path_constructor` router reasoner; internal/phases drives it through +// app.Call from chain_phase, never in-process, exactly as Python does. +// +// # Shape of the phase +// +// CHAIN is meta-prompting: ONE parent harness call plans up to max_children +// "investigations" (each a free-text child prompt written by the model), then +// every investigation is dispatched as its OWN harness call that must return a +// single AttackPath. The children run concurrently and unbounded — Python's +// asyncio.gather with no semaphore — and a child that fails for any reason is +// silently dropped rather than failing the phase. Only the parent call can fail +// the phase. +// +// The two pydantic models the parent call is schema'd against +// (PathInvestigationPlan, ChildInvestigation) are declared in this Python module +// rather than in schemas/, but the Go port keeps them in internal/schemas: they +// cross a JSON boundary, so harnessx has to resolve their committed pydantic +// schema fixture by Go type name. See internal/schemas/pathplan.go. +// +// # Divergences from Python, in one place +// +// The three json.dumps sites here go through pyfmt.Dumps, which carries the +// port's json.dumps parity contract (ensure_ascii escaping, Python float repr, +// insertion-ordered pyfmt.Ordered objects). Its two documented deviations reach +// this package: +// +// 1. `Any`-typed leaves inside a DriftReport (ConfigDiff.iac_value, +// DriftedResource.iac_config values) that arrived as JSON numbers are +// float64 in Go, so an integer renders as "1.0" where Python renders "1". +// 2. A Go map has no insertion order, so a map[string]any inside a drift report +// is dumped with SORTED keys. Every object whose order Python actually +// controls (the compact-finding dicts, the filtered graph, the graph file +// read back from disk) is modeled as a pyfmt.Ordered — see pyload.go — and +// keeps its order. +// +// And one of this package's own: +// +// 3. Graph-file node/edge ids that are NOT strings are treated as "not a +// member" of the relevant-id set; Python would either match them (if some +// other id had the same non-string value) or raise TypeError (for an +// unhashable one). No graph this port can produce contains such an id. +// +// A fourth, latent one: pyfmt.Dumps renders a NIL Go slice as `null` where +// pydantic's default_factory=list guarantees `[]`. It cannot fire in the live +// DAG — every model reaching these builders crossed a control-plane JSON +// boundary and was re-seeded by its UnmarshalJSON — but a Go caller handing in a +// hand-built struct literal would see it. +// +// Everything else — the prompt bytes, the substitution ORDER, the temp-dir +// prefix, which harness calls get a project_dir (none do here), the extract +// agent names, the truncation rules, the duration rounding and the ChainResult +// key set — is byte-for-byte the Python behavior. +package chain diff --git a/go/internal/agents/chain/path_constructor.go b/go/internal/agents/chain/path_constructor.go new file mode 100644 index 0000000..97074f6 --- /dev/null +++ b/go/internal/agents/chain/path_constructor.go @@ -0,0 +1,521 @@ +package chain + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + "sync" + "time" + "unicode" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/harnessx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/prompts" + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// pathConstructorPromptPath is PROMPT_PATH in path_constructor.py, resolved +// against the embedded prompt tree instead of the installed package's prompts/ +// directory: +// +// PROMPT_PATH = Path(__file__).resolve().parents[2] / "prompts" / "chain" / "path_constructor.txt" +const pathConstructorPromptPath = "chain/path_constructor.txt" + +// pathConstructorTempPrefix is the tempfile.mkdtemp prefix in +// run_path_constructor. Both the parent call and every child call use this one +// directory as their cwd. +const pathConstructorTempPrefix = "cloudsecurity-chain-" + +// parentAgentName / childAgentName are the `agent_name` arguments Python passes +// to extract_harness_result; they appear verbatim in every error message and +// diagnostic line harnessx.Extract emits. +const ( + parentAgentName = "PathConstructor" + childAgentName = "PathConstructorChild" +) + +// RunPathConstructor ports run_path_constructor in +// src/cloudsecurity_af/agents/chain/path_constructor.py: +// +// started = time.perf_counter() +// if not findings or max_paths <= 0 or max_children <= 0: +// return ChainResult(attack_paths=[], total_paths_evaluated=0, +// viable_paths=0, chain_duration_seconds=0.0) +// prompt_template = PROMPT_PATH.read_text(encoding="utf-8") +// parent_prompt = _build_parent_prompt(...) +// harness_cwd = tempfile.mkdtemp(prefix="cloudsecurity-chain-") +// try: +// plan_result = await app.harness(prompt=parent_prompt, +// schema=PathInvestigationPlan, cwd=harness_cwd) +// plan = extract_harness_result(plan_result, PathInvestigationPlan, "PathConstructor") +// investigations = plan.investigations[:max_children] +// if not investigations: +// return ChainResult(..., chain_duration_seconds=round(time.perf_counter()-started, 3)) +// child_results = await asyncio.gather(*[_run_child(inv) for inv in investigations]) +// viable_paths = [p for p in child_results if p is not None][:max_paths] +// return ChainResult(attack_paths=viable_paths, +// total_paths_evaluated=len(investigations), +// viable_paths=len(viable_paths), +// chain_duration_seconds=round(time.perf_counter()-started, 3)) +// finally: +// shutil.rmtree(harness_cwd, ignore_errors=True) +// +// PYTHON PARITY — THE GUARD RETURNS A LITERAL 0.0. The `not findings or +// max_paths <= 0 or max_children <= 0` branch hard-codes +// chain_duration_seconds=0.0 rather than measuring; only the two later returns +// round the real elapsed time. That is reproduced exactly. +// +// PYTHON PARITY — NO project_dir. Unlike every prover and hunter call, the +// CHAIN harness calls pass only `cwd`; the model works from the JSON embedded +// in the prompt, not from the repository. harness.Options.ProjectDir is +// therefore left empty for both the parent and the children. +// +// That is SAFE for the concurrent children, and the reason is worth writing +// down because it is not obvious: the children all share one harnessCwd, and +// the SDK writes its schema output to a FIXED filename. The pinned SDK +// (sdk/go v0.1.131, harness/runner.go) resolves the output root as +// `ProjectDir or Cwd or "."` and then ALWAYS creates a per-run +// `.agentfield-out-*` directory under it whenever a schema is passed — +// harnessx.Run always passes one (harnessx.SchemaFor[T] never returns nil) — +// so each child gets its own output file and its own CleanupTempFiles target. +// The Python SDK does the same thing unconditionally +// (agentfield/harness/_runner.py: tempfile.mkdtemp(prefix=".agentfield-out-", +// dir=project_dir or resolved_cwd)). Adding ProjectDir here to "get isolation" +// would break the kwarg parity path_constructor_test.go pins without changing +// the root the model sees (every provider resolves `ProjectDir or Cwd`). +// +// PYTHON PARITY — CHILD FAILURES ARE SWALLOWED. `_run_child` wraps its harness +// call in `except Exception: return None`, so a child that errors, times out or +// returns an unparsable payload simply does not contribute a path — it never +// fails the phase. Only the parent call's failure propagates. +// +// CONCURRENCY. Python fans the children out with asyncio.gather and no +// semaphore, so up to max_children run at once and the phase waits for all of +// them. Go uses a WaitGroup writing into a pre-indexed slice, which preserves +// gather's ORDER guarantee: viable_paths is ordered by investigation index, not +// by completion. The handler ctx is passed through unchanged (it carries the +// execution context) but is never cancelled by this function: like gather, a +// failing child does not abort its siblings. +func RunPathConstructor( + ctx context.Context, + app appx.Harnesser, + findings []schemas.RawFinding, + resourceGraphPath string, + maxPaths int, + maxChildren int, + driftReport *schemas.DriftReport, +) (schemas.ChainResult, error) { + started := time.Now() + + if len(findings) == 0 || maxPaths <= 0 || maxChildren <= 0 { + result := schemas.NewChainResult() + result.ChainDurationSeconds = 0.0 + return result, nil + } + + parentPrompt, err := BuildParentPrompt(findings, resourceGraphPath, driftReport, maxPaths, maxChildren) + if err != nil { + // Python parity: PROMPT_PATH.read_text() raising FileNotFoundError + // surfaces as a failed reasoner, and it happens BEFORE mkdtemp. + return schemas.ChainResult{}, err + } + + harnessCwd, err := os.MkdirTemp("", pathConstructorTempPrefix) + if err != nil { + return schemas.ChainResult{}, fmt.Errorf("cloudsecurity chain: creating path-constructor work dir: %w", err) + } + // Python: `finally: shutil.rmtree(harness_cwd, ignore_errors=True)`. + defer func() { _ = os.RemoveAll(harnessCwd) }() // ignore_errors=True + + plan, err := harnessx.RunExtract[schemas.PathInvestigationPlan]( + ctx, app, parentPrompt, + harness.Options{Cwd: harnessCwd}, + parentAgentName, + ) + if err != nil { + return schemas.ChainResult{}, err + } + + // Python: `plan.investigations[:max_children]` — a slice past the end is + // the whole list, never an error. + investigations := plan.Investigations + if len(investigations) > maxChildren { + investigations = investigations[:maxChildren] + } + if len(investigations) == 0 { + result := schemas.NewChainResult() + result.ChainDurationSeconds = pyfmt.Round(time.Since(started).Seconds(), 3) + return result, nil + } + + // Python: `await asyncio.gather(*[_run_child(inv) for inv in investigations])`. + childResults := make([]*schemas.AttackPath, len(investigations)) + var wg sync.WaitGroup + for i := range investigations { + wg.Add(1) + go func(idx int, inv schemas.ChildInvestigation) { + defer wg.Done() + path, err := harnessx.RunExtract[schemas.AttackPath]( + ctx, app, BuildChildPrompt(inv, maxPaths), + harness.Options{Cwd: harnessCwd}, + childAgentName, + ) + if err != nil { + // Python: `except Exception: return None`. + return + } + childResults[idx] = &path + }(i, investigations[i]) + } + wg.Wait() + + // Python: `[path for path in child_results if path is not None][:max_paths]`. + viablePaths := make([]schemas.AttackPath, 0, len(childResults)) + for _, path := range childResults { + if path != nil { + viablePaths = append(viablePaths, *path) + } + } + if len(viablePaths) > maxPaths { + viablePaths = viablePaths[:maxPaths] + } + + result := schemas.NewChainResult() + result.AttackPaths = viablePaths + result.TotalPathsEvaluated = len(investigations) + // Python parity: viable_paths is counted AFTER the [:max_paths] truncation. + result.ViablePaths = len(viablePaths) + result.ChainDurationSeconds = pyfmt.Round(time.Since(started).Seconds(), 3) + return result, nil +} + +// BuildParentPrompt ports _build_parent_prompt. It is exported for the golden +// test, which compares it byte-for-byte against the string the Python builder +// emits. +// +// prompt = template +// prompt = prompt.replace("{{MAX_PATHS}}", str(max_paths)) +// prompt = prompt.replace("{{MAX_CHILDREN}}", str(max_children)) +// compact_findings = [_compact_finding(f) for f in findings] +// prompt = prompt.replace("{{FINDINGS_JSON}}", json.dumps(compact_findings, indent=2)) +// try: graph_data = json.load(open(resource_graph_path)) +// except: graph_data = {"nodes": [], "edges": [], "clusters": []} +// if not isinstance(graph_data, dict): graph_data = {"nodes": [], "edges": [], "clusters": []} +// prompt = prompt.replace("{{RESOURCE_GRAPH_JSON}}", +// json.dumps(_filter_graph_for_findings(graph_data, findings), indent=2)) +// drift_payload = drift_report.model_dump() if drift_report is not None else {} +// prompt = prompt.replace("{{DRIFT_REPORT_JSON}}", json.dumps(drift_payload, indent=2)) +// +// PYTHON PARITY — SUBSTITUTION ORDER IS LOAD-BEARING. The four replacements run +// in sequence over the SAME string, so a placeholder that appears inside an +// earlier substitution's value is itself substituted. (A finding titled +// "{{DRIFT_REPORT_JSON}}" really does get the drift report spliced into it.) +// The Go port keeps the order rather than doing one pass. +// +// The only error this can return is a missing embedded template; every other +// failure mode Python has here (an unreadable or malformed graph file) is +// swallowed into the empty-graph default, exactly as the try/except does. +func BuildParentPrompt( + findings []schemas.RawFinding, + resourceGraphPath string, + driftReport *schemas.DriftReport, + maxPaths int, + maxChildren int, +) (string, error) { + template, err := prompts.Load(pathConstructorPromptPath) + if err != nil { + return "", err + } + + prompt := template + prompt = strings.ReplaceAll(prompt, "{{MAX_PATHS}}", strconv.Itoa(maxPaths)) + prompt = strings.ReplaceAll(prompt, "{{MAX_CHILDREN}}", strconv.Itoa(maxChildren)) + + compactFindings := make([]any, 0, len(findings)) + for _, f := range findings { + compactFindings = append(compactFindings, compactFinding(f)) + } + prompt = strings.ReplaceAll(prompt, "{{FINDINGS_JSON}}", pyfmt.Dumps(compactFindings, 2)) + + filtered := filterGraphForFindings(loadGraphData(resourceGraphPath), findings) + prompt = strings.ReplaceAll(prompt, "{{RESOURCE_GRAPH_JSON}}", pyfmt.Dumps(filtered, 2)) + + // Python: `drift_report.model_dump() if drift_report is not None else {}`. + var driftPayload any = pyfmt.Ordered{} + if driftReport != nil { + driftPayload = *driftReport + } + prompt = strings.ReplaceAll(prompt, "{{DRIFT_REPORT_JSON}}", pyfmt.Dumps(driftPayload, 2)) + + return prompt, nil +} + +// BuildChildPrompt ports _child_prompt. Exported for the golden test. +// +// return ( +// f"{investigation.child_prompt.strip()}\n\n" +// "OUTPUT REQUIREMENTS:\n" +// "- Return a single JSON object matching AttackPath.\n" +// "- Only include a path if there is a coherent attacker progression across resources.\n" +// "- Use findings_involved IDs tied to the path steps.\n" +// "- Keep steps in strict step_number order starting at 1.\n" +// f"- The parent will keep at most {max_paths} final attack paths." +// ) +func BuildChildPrompt(investigation schemas.ChildInvestigation, maxPaths int) string { + return pyStrip(investigation.ChildPrompt) + "\n\n" + + "OUTPUT REQUIREMENTS:\n" + + "- Return a single JSON object matching AttackPath.\n" + + "- Only include a path if there is a coherent attacker progression across resources.\n" + + "- Use findings_involved IDs tied to the path steps.\n" + + "- Keep steps in strict step_number order starting at 1.\n" + + "- The parent will keep at most " + strconv.Itoa(maxPaths) + " final attack paths." +} + +// compactFinding ports _compact_finding: the 8-key projection of a RawFinding +// that goes into {{FINDINGS_JSON}}. +// +// {"id", "title", "category", "severity", "resources", "iac_file", "iac_line", +// "fingerprint"} +// +// Python parity: the key order below is the dict-literal order, which json.dumps +// preserves, so it is part of the prompt bytes. +// +// Python parity: `severity` is +// `f.estimated_severity.value if hasattr(f.estimated_severity, "value") else str(...)`. +// pydantic always yields the enum, so the branch is always `.value` — the Go +// Severity is already that string. +// +// Python parity: `resources` is `[r.resource_id for r in f.resources] if f.resources else []`, +// i.e. an empty list either way; a nil Go slice must still render as `[]`. +func compactFinding(f schemas.RawFinding) pyfmt.Ordered { + resources := make([]any, 0, len(f.Resources)) + for _, r := range f.Resources { + resources = append(resources, r.ResourceID) + } + return pyfmt.Ordered{ + {K: "id", V: f.ID}, + {K: "title", V: f.Title}, + {K: "category", V: f.Category}, + {K: "severity", V: f.EstimatedSeverity.String()}, + {K: "resources", V: resources}, + {K: "iac_file", V: f.IaCFile}, + {K: "iac_line", V: f.IaCLine}, + {K: "fingerprint", V: f.Fingerprint}, + } +} + +// emptyGraph is the `{"nodes": [], "edges": [], "clusters": []}` literal +// _build_parent_prompt falls back to when the graph file cannot be read or is +// not a JSON object. +func emptyGraph() pyfmt.Ordered { + return pyfmt.Ordered{ + {K: "nodes", V: []any{}}, + {K: "edges", V: []any{}}, + {K: "clusters", V: []any{}}, + } +} + +// loadGraphData ports the graph-file read inside _build_parent_prompt: any +// failure (missing file, permissions, malformed JSON) and any non-object +// top-level value collapse to the empty-graph default. +// +// The decode is order-preserving (pyfmt.Load) because the filtered nodes and +// edges are re-emitted verbatim into the prompt, and a Python dict would have +// kept the file's key order. +func loadGraphData(resourceGraphPath string) pyfmt.Ordered { + data, err := os.ReadFile(resourceGraphPath) + if err != nil { + return emptyGraph() + } + loaded, err := pyfmt.Load(data) + if err != nil { + return emptyGraph() + } + obj, ok := loaded.(pyfmt.Ordered) + if !ok { + // Python: `if not isinstance(graph_data, dict)`. + return emptyGraph() + } + return obj +} + +// filterGraphForFindings ports _filter_graph_for_findings: reduce the resource +// graph to the nodes the findings touch plus their 1-hop neighbours, and to the +// edges whose BOTH endpoints survive. +// +// finding_resources = {r.resource_id for f in findings for r in f.resources} | {f.iac_file if f.iac_file} +// neighbors = {other end of any edge with one end in finding_resources} +// relevant_ids = finding_resources | neighbors +// nodes = [n for n in graph["nodes"] if n.get("resource_id") in relevant_ids] +// edges = [e for e in graph["edges"] if e.get("source") in relevant_ids and e.get("target") in relevant_ids] +// return {"nodes": nodes, "edges": edges, "clusters": graph.get("clusters", [])} +// +// PYTHON PARITY — TWO DIFFERENT `.get` DEFAULTS. The neighbour pass reads +// `edge.get("source", "")` (a missing key becomes the empty string, which DOES +// match when some finding has an empty resource_id) while the edge filter reads +// `edge.get("source")` (a missing key becomes None, which matches only if some +// edge already put None in the id set). Both are reproduced. +// +// PYTHON PARITY — `clusters` IS PASSED THROUGH UNTOUCHED, whatever its type, +// including when it is absent (then `[]`). +// +// PYTHON PARITY — IDS ARE COMPARED BY VALUE, NOT AS STRINGS. `relevant_ids` is +// a plain Python set, so a non-string endpoint (JSON null, a number, a bool) +// joins it and then MATCHES in the node and edge filters. The neighbour pass +// +// src, tgt = edge.get("source", ""), edge.get("target", "") +// if src in finding_resources: neighbors.add(tgt) +// +// adds the OTHER endpoint unconditionally, without inspecting its type. That is +// reachable: graph.json is written by the deterministic graphfast builder on the +// happy path (always string ids), but resource_graph_builder falls back to the +// harness on error and prompts/recon/resource_graph_builder.txt tells the model +// to author graph.json itself. Verified against the repo venv with +// +// graph {"nodes":[{"resource_id":"a","resource_type":"t"}, +// {"resource_id":null,"resource_type":"nullid"}], +// "edges":[{"source":"a","target":null,"type":"e1"}, +// {"source":null,"target":"a","type":"e2"}], +// "clusters":[]} +// finding resources=[AffectedResource(resource_id="a")] +// +// -> BOTH nodes and BOTH edges survive. pyfmt.KeySet supplies the by-value +// membership (and documents the two divergences it keeps: iteration order, +// which this function never observes, and unhashable list/dict ids, which +// Python rejects with TypeError). +// +// Reproducing that must NOT be done by collapsing a non-string endpoint onto +// "", which is a MEMBER of the id set whenever some finding carries an empty +// resource_id (`resource_id: str` has no min_length, so a model can and does +// emit ""). Verified against the venv with +// edges=[{"source": 5, "target": "aws_s3_bucket.logs"}] and one finding whose +// resource_id is "": Python filters everything out, while collapsing `5` to "" +// splices aws_s3_bucket.logs into {{RESOURCE_GRAPH_JSON}} — a prompt byte +// difference. endpointOrEmpty keeps "absent" and "present but not a str" apart. +func filterGraphForFindings(graphData pyfmt.Ordered, findings []schemas.RawFinding) pyfmt.Ordered { + // finding_resources is a set of Python strs — RawFinding.resources[].resource_id + // and RawFinding.iac_file are both `str` in pydantic. + findingResources := pyfmt.NewKeySet() + for _, f := range findings { + for _, r := range f.Resources { + findingResources.Add(r.ResourceID) + } + if f.IaCFile != "" { + findingResources.Add(f.IaCFile) + } + } + + rawEdges := listField(graphData, "edges") + + // relevant_ids = finding_resources | neighbors — a NEW set, so the + // neighbour additions below must not leak back into finding_resources. + relevantIDs := findingResources.Clone() + for _, edge := range rawEdges { + edgeObj, ok := edge.(pyfmt.Ordered) + if !ok { + // Python: `if not isinstance(edge, dict): continue`. + continue + } + // Python: `src, tgt = edge.get("source", ""), edge.get("target", "")`, + // then each membership hit adds the OPPOSITE endpoint's raw value. + src := endpointOrEmpty(edgeObj, "source") + tgt := endpointOrEmpty(edgeObj, "target") + if findingResources.Has(src) { + relevantIDs.Add(tgt) + } + if findingResources.Has(tgt) { + relevantIDs.Add(src) + } + } + + rawNodes := listField(graphData, "nodes") + filteredNodes := make([]any, 0, len(rawNodes)) + for _, node := range rawNodes { + nodeObj, ok := node.(pyfmt.Ordered) + if !ok { + continue + } + // Python: `n.get("resource_id") in relevant_ids` — no default, so a + // missing key is None, which matches only if None is in the set. + if relevantIDs.Has(dictGet(nodeObj, "resource_id")) { + filteredNodes = append(filteredNodes, node) + } + } + + filteredEdges := make([]any, 0, len(rawEdges)) + for _, edge := range rawEdges { + edgeObj, ok := edge.(pyfmt.Ordered) + if !ok { + continue + } + if relevantIDs.Has(dictGet(edgeObj, "source")) && relevantIDs.Has(dictGet(edgeObj, "target")) { + filteredEdges = append(filteredEdges, edge) + } + } + + clusters, ok := graphData.Get("clusters") + if !ok { + clusters = []any{} + } + + return pyfmt.Ordered{ + {K: "nodes", V: filteredNodes}, + {K: "edges", V: filteredEdges}, + {K: "clusters", V: clusters}, + } +} + +// listField ports `x = graph_data.get(key, []); if not isinstance(x, list): x = []`. +func listField(obj pyfmt.Ordered, key string) []any { + v, ok := obj.Get(key) + if !ok { + return nil + } + list, ok := v.([]any) + if !ok { + return nil + } + return list +} + +// dictGet is `d.get(key)` with NO default: an absent key yields Python None, +// which is a distinct set key from "" and from the string "None". +func dictGet(obj pyfmt.Ordered, key string) any { + v, ok := obj.Get(key) + if !ok { + return nil + } + return v +} + +// endpointOrEmpty is the neighbour pass's `edge.get(key, "")`. The default is +// substituted ONLY for an absent key; a present non-string value (5, null, a +// list) is returned as-is, because Python compares it by value rather than +// coercing it. The two cases behave differently: "" IS a member of the id set +// whenever a finding carries an empty resource_id, while 5 and None are members +// only if the graph itself put them there. +func endpointOrEmpty(obj pyfmt.Ordered, key string) any { + v, ok := obj.Get(key) + if !ok { + return "" + } + return v +} + +// pyStrip reproduces Python's str.strip() with no argument, which trims every +// character whose Py_UNICODE_ISSPACE is true. +// +// That set is Go's unicode.IsSpace PLUS U+001C..U+001F (the FILE, GROUP, RECORD +// and UNIT separators), which Python counts as whitespace and Go does not. The +// child prompt is model-authored free text, so the difference is reachable in +// principle; handling it costs one predicate. +func pyStrip(s string) string { + return strings.TrimFunc(s, func(r rune) bool { + return unicode.IsSpace(r) || (r >= 0x1c && r <= 0x1f) + }) +} diff --git a/go/internal/agents/chain/path_constructor_test.go b/go/internal/agents/chain/path_constructor_test.go new file mode 100644 index 0000000..c6d46e4 --- /dev/null +++ b/go/internal/agents/chain/path_constructor_test.go @@ -0,0 +1,721 @@ +package chain + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +// chainInputs mirrors go/scripts/gen_golden.py's chain inputs.json. Binding the +// SAME pydantic dumps the Python builder was driven with is what makes the +// golden comparison meaningful — the Go test never transcribes a fixture by +// hand. +type chainInputs struct { + FindingsPair []schemas.RawFinding `json:"findings_pair"` + FindingsSingle []schemas.RawFinding `json:"findings_single"` + DriftReport schemas.DriftReport `json:"drift_report"` + Investigations []schemas.ChildInvestigation `json:"investigations"` +} + +func loadInputs(t *testing.T) chainInputs { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", "golden", "inputs.json")) + if err != nil { + t.Fatalf("read inputs.json: %v", err) + } + var in chainInputs + if err := json.Unmarshal(raw, &in); err != nil { + t.Fatalf("decode inputs.json: %v", err) + } + return in +} + +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", name, err) + } + return string(raw) +} + +func goldenPath(name string) string { return filepath.Join("testdata", "golden", name) } + +// --------------------------------------------------------------------------- +// Prompt goldens — the bytes that reach the model +// --------------------------------------------------------------------------- + +// TestBuildParentPrompt_Golden pins _build_parent_prompt byte-for-byte across +// the three interesting graph-file states: a real graph, a missing file, and a +// file whose top-level JSON value is not an object. +func TestBuildParentPrompt_Golden(t *testing.T) { + in := loadInputs(t) + drift := in.DriftReport + + cases := []struct { + name string + findings []schemas.RawFinding + graphPath string + drift *schemas.DriftReport + maxPaths int + maxChildren int + want string + }{ + {"a_real_graph_with_drift", in.FindingsPair, goldenPath("graph.json"), &drift, 5, 3, "parent_prompt_a.txt"}, + {"b_missing_graph_no_drift", in.FindingsPair, goldenPath("does-not-exist.json"), nil, 1, 1, "parent_prompt_b.txt"}, + {"c_graph_is_a_list", in.FindingsSingle, goldenPath("graph_not_object.json"), nil, 2, 4, "parent_prompt_c.txt"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := BuildParentPrompt(tc.findings, tc.graphPath, tc.drift, tc.maxPaths, tc.maxChildren) + if err != nil { + t.Fatalf("BuildParentPrompt: %v", err) + } + if want := golden(t, tc.want); got != want { + t.Errorf("parent prompt differs from Python\n%s", firstDiff(got, want)) + } + }) + } +} + +// TestBuildChildPrompt_Golden pins _child_prompt, including the .strip() of the +// model-authored child prompt and the trailing max_paths sentence. +func TestBuildChildPrompt_Golden(t *testing.T) { + in := loadInputs(t) + cases := []struct { + name string + inv schemas.ChildInvestigation + maxPaths int + want string + }{ + {"a_whitespace_wrapped", in.Investigations[0], 5, "child_prompt_a.txt"}, + {"b_plain", in.Investigations[1], 1, "child_prompt_b.txt"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := BuildChildPrompt(tc.inv, tc.maxPaths) + if want := golden(t, tc.want); got != want { + t.Errorf("child prompt differs from Python\n%s", firstDiff(got, want)) + } + }) + } +} + +// TestBuildParentPrompt_SubstitutionOrder pins the parity quirk that the four +// replacements run in sequence over the same accumulating string, so a +// placeholder embedded in an earlier value IS substituted afterwards. +func TestBuildParentPrompt_SubstitutionOrder(t *testing.T) { + f := schemas.NewRawFinding() + f.ID = "f-1" + // {{MAX_CHILDREN}} is substituted BEFORE the findings JSON is spliced in, + // so a title containing it survives; {{DRIFT_REPORT_JSON}} is substituted + // AFTER, so a title containing it gets the drift report spliced in. + f.Title = "{{MAX_CHILDREN}} and {{DRIFT_REPORT_JSON}}" + f.IaCFile = "main.tf" + + got, err := BuildParentPrompt([]schemas.RawFinding{f}, goldenPath("does-not-exist.json"), nil, 5, 3) + if err != nil { + t.Fatalf("BuildParentPrompt: %v", err) + } + if !strings.Contains(got, `"title": "{{MAX_CHILDREN}} and {}"`) { + t.Errorf("expected the later placeholder to be substituted inside the findings JSON and the earlier one to survive; got:\n%s", got) + } +} + +// --------------------------------------------------------------------------- +// _compact_finding +// --------------------------------------------------------------------------- + +// TestCompactFinding_KeyOrderAndProjection asserts the 8-key projection, its +// dict-literal key ORDER (json.dumps preserves it, so it is prompt bytes) and +// the `resources` / `severity` conversions. +func TestCompactFinding_KeyOrderAndProjection(t *testing.T) { + f := schemas.NewRawFinding() + f.ID = "f-1" + f.Title = "t" + f.Category = "overprivilege" + f.EstimatedSeverity = "critical" + f.Resources = []schemas.AffectedResource{{ResourceID: "a"}, {ResourceID: "b"}} + f.IaCFile = "main.tf" + f.IaCLine = 12 + f.Fingerprint = "fp" + + got := compactFinding(f) + wantKeys := []string{"id", "title", "category", "severity", "resources", "iac_file", "iac_line", "fingerprint"} + if len(got) != len(wantKeys) { + t.Fatalf("compactFinding produced %d keys, want %d: %v", len(got), len(wantKeys), got) + } + for i, k := range wantKeys { + if got[i].K != k { + t.Errorf("key %d = %q, want %q", i, got[i].K, k) + } + } + if v, _ := got.Get("severity"); v != "critical" { + t.Errorf("severity = %v, want the enum VALUE %q", v, "critical") + } + if v, _ := got.Get("resources"); fmt.Sprint(v) != "[a b]" { + t.Errorf("resources = %v, want the resource_id list", v) + } + if v, _ := got.Get("iac_line"); v != 12 { + t.Errorf("iac_line = %v (%T), want the int 12", v, v) + } +} + +// TestCompactFinding_NoResourcesRendersEmptyList pins that a finding with no +// resources dumps `[]`, not `null` — Python's `... if f.resources else []`. +func TestCompactFinding_NoResourcesRendersEmptyList(t *testing.T) { + f := schemas.RawFinding{ID: "f"} // Resources is a nil slice + if got := pyfmt.Dumps(compactFinding(f), 0); !strings.Contains(got, `"resources": []`) { + t.Errorf("nil Resources rendered as %s, want an empty list", got) + } +} + +// --------------------------------------------------------------------------- +// _filter_graph_for_findings +// --------------------------------------------------------------------------- + +func mustLoadOrdered(t *testing.T, text string) pyfmt.Ordered { + t.Helper() + v, err := pyfmt.Load([]byte(text)) + if err != nil { + t.Fatalf("pyLoad: %v", err) + } + o, ok := v.(pyfmt.Ordered) + if !ok { + t.Fatalf("pyLoad produced %T, want an object", v) + } + return o +} + +// TestFilterGraphForFindings_NeighborExpansion covers the whole contract: +// finding resources and iac_files seed the relevant set, one hop of edge +// traversal expands it, nodes and edges are filtered against it, non-dict +// entries are skipped, and `clusters` passes through untouched. +func TestFilterGraphForFindings_NeighborExpansion(t *testing.T) { + graph := mustLoadOrdered(t, `{ + "nodes": [ + {"resource_id": "role", "kind": "seed"}, + {"resource_id": "bucket", "kind": "neighbor"}, + {"resource_id": "kms", "kind": "far"}, + "not-a-dict", + {"kind": "no-id"} + ], + "edges": [ + {"source": "role", "target": "bucket"}, + {"source": "bucket", "target": "kms"}, + {"source": "x", "target": "y"}, + "not-a-dict" + ], + "clusters": [{"name": "prod"}], + "dropped": 1 + }`) + + f := schemas.NewRawFinding() + f.Resources = []schemas.AffectedResource{{ResourceID: "role"}} + + got := filterGraphForFindings(graph, []schemas.RawFinding{f}) + + if len(got) != 3 || got[0].K != "nodes" || got[1].K != "edges" || got[2].K != "clusters" { + t.Fatalf("result keys = %v, want exactly nodes/edges/clusters in that order", got) + } + // relevant = {role} + neighbours of role = {bucket}. "kms" is two hops out. + nodes := got[0].V.([]any) + if len(nodes) != 2 { + t.Fatalf("kept %d nodes, want 2 (role, bucket): %v", len(nodes), nodes) + } + // The bucket->kms edge has one endpoint outside the relevant set. + edges := got[1].V.([]any) + if len(edges) != 1 { + t.Fatalf("kept %d edges, want 1 (role->bucket): %v", len(edges), edges) + } + if dump := pyfmt.Dumps(got[2].V, 0); dump != `[{"name": "prod"}]` { + t.Errorf("clusters = %s, want the input value passed through untouched", dump) + } +} + +// TestFilterGraphForFindings_IaCFileIsASeed pins that a finding's iac_file +// joins the relevant-id set — the graph's node ids and the finding's file path +// live in the SAME set in Python. +func TestFilterGraphForFindings_IaCFileIsASeed(t *testing.T) { + graph := mustLoadOrdered(t, `{"nodes": [{"resource_id": "main.tf"}], "edges": []}`) + f := schemas.NewRawFinding() + f.IaCFile = "main.tf" + + got := filterGraphForFindings(graph, []schemas.RawFinding{f}) + if nodes := got[0].V.([]any); len(nodes) != 1 { + t.Errorf("iac_file did not seed the relevant set: %v", nodes) + } +} + +// TestFilterGraphForFindings_EmptyIaCFileIsNotASeed pins the `if f.iac_file:` +// guard: the empty string must NOT enter the set, or every edge with a missing +// `source` would match through the `.get("source", "")` default. +func TestFilterGraphForFindings_EmptyIaCFileIsNotASeed(t *testing.T) { + graph := mustLoadOrdered(t, `{"nodes": [{"resource_id": ""}], "edges": [{"target": "x"}]}`) + f := schemas.NewRawFinding() // IaCFile == "" + + got := filterGraphForFindings(graph, []schemas.RawFinding{f}) + if nodes := got[0].V.([]any); len(nodes) != 0 { + t.Errorf("the empty iac_file seeded the relevant set: %v", nodes) + } +} + +// TestFilterGraphForFindings_MissingKeysAndTypes covers the two `.get` defaults +// that differ between the neighbour pass and the edge filter, and the +// missing-`clusters` fallback. +func TestFilterGraphForFindings_MissingKeysAndTypes(t *testing.T) { + // "edges" is not a list and "nodes" is missing entirely. + graph := mustLoadOrdered(t, `{"edges": "nope"}`) + f := schemas.NewRawFinding() + f.IaCFile = "main.tf" + + got := filterGraphForFindings(graph, []schemas.RawFinding{f}) + if dump := pyfmt.Dumps(got, 0); dump != `{"nodes": [], "edges": [], "clusters": []}` { + t.Errorf("degenerate graph produced %s", dump) + } +} + +// TestFilterGraphForFindings_EdgeFilterUsesNoDefault pins the asymmetry: an edge +// missing `source` participates in NEIGHBOUR discovery through the `""` default +// but can never survive the edge FILTER, which uses `.get("source")` -> None. +func TestFilterGraphForFindings_EdgeFilterUsesNoDefault(t *testing.T) { + graph := mustLoadOrdered(t, `{"nodes": [], "edges": [{"target": "role"}]}`) + f := schemas.NewRawFinding() + f.Resources = []schemas.AffectedResource{{ResourceID: "role"}} + + got := filterGraphForFindings(graph, []schemas.RawFinding{f}) + if edges := got[1].V.([]any); len(edges) != 0 { + t.Errorf("an edge with no source survived the filter: %v", edges) + } +} + +// TestFilterGraphForFindings_NonStringEndpointIsNotTheEmptyString pins the +// difference between "the key is absent" and "the key is present but not a +// string" in the neighbour pass. +// +// Python: `src = edge.get("source", "")` substitutes "" only for an ABSENT key; +// a present `5` stays 5, and `5 in finding_resources` (a set of str) is False. +// Verified against the repo venv on _filter_graph_for_findings with +// +// graph {"nodes":[{"resource_id":"aws_s3_bucket.logs"}], +// "edges":[{"source":5,"target":"aws_s3_bucket.logs"}]} +// finding resources=[AffectedResource(resource_id="")] +// +// -> {"nodes": [], "edges": [], "clusters": []}. +// +// Collapsing the non-string endpoint to "" instead makes it MATCH the empty +// resource_id and splices aws_s3_bucket.logs into {{RESOURCE_GRAPH_JSON}} in +// the CHAIN parent prompt. +func TestFilterGraphForFindings_NonStringEndpointIsNotTheEmptyString(t *testing.T) { + graph := mustLoadOrdered(t, `{"nodes": [{"resource_id": "aws_s3_bucket.logs"}], "edges": [{"source": 5, "target": "aws_s3_bucket.logs"}], "clusters": []}`) + f := schemas.NewRawFinding() + // `resource_id: str` carries no min_length in either schema, so a model + // may emit "" — that is what makes the collapse observable. + f.Resources = []schemas.AffectedResource{{ResourceID: ""}} + + got := filterGraphForFindings(graph, []schemas.RawFinding{f}) + if dump := pyfmt.Dumps(got, 0); dump != `{"nodes": [], "edges": [], "clusters": []}` { + t.Errorf("filtered graph = %s, want the empty graph Python produces", dump) + } +} + +// The other half of the same rule, also run against the venv: an ABSENT +// `source` key DOES take the "" default and therefore does promote its target. +func TestFilterGraphForFindings_AbsentEndpointKeyStillTakesTheEmptyDefault(t *testing.T) { + graph := mustLoadOrdered(t, `{"nodes": [{"resource_id": "aws_s3_bucket.logs"}], "edges": [{"target": "aws_s3_bucket.logs"}], "clusters": []}`) + f := schemas.NewRawFinding() + f.Resources = []schemas.AffectedResource{{ResourceID: ""}} + + got := filterGraphForFindings(graph, []schemas.RawFinding{f}) + if dump := pyfmt.Dumps(got, 0); dump != `{"nodes": [{"resource_id": "aws_s3_bucket.logs"}], "edges": [], "clusters": []}` { + t.Errorf("filtered graph = %s, want the node Python keeps", dump) + } +} + +// --------------------------------------------------------------------------- +// RunPathConstructor +// --------------------------------------------------------------------------- + +const planJSON = `{"investigations": [ + {"title": "one", "rationale": "", "findings_involved": [], "child_prompt": "a"}, + {"title": "two", "rationale": "", "findings_involved": [], "child_prompt": "b"}, + {"title": "three", "rationale": "", "findings_involved": [], "child_prompt": "c"} +]}` + +func attackPathJSON(id string) string { + return `{"id": "` + id + `", "title": "` + id + `", "description": "", "entry_point": "e", "target": "t"}` +} + +// scriptedApp answers the parent call with plan and every child call with the +// JSON that child(prompt) returns; a nil/error return drops that child. +func scriptedApp(plan string, child func(prompt string) (string, error)) *appx.Fake { + f := &appx.Fake{} + f.HarnessFn = appx.HarnessJSON(func(prompt string, _ harness.Options) (json.RawMessage, error) { + if strings.Contains(prompt, "You are the CHAIN parent harness") { + return json.RawMessage(plan), nil + } + out, err := child(prompt) + if err != nil { + return nil, err + } + return json.RawMessage(out), nil + }) + return f +} + +// TestRunPathConstructor_GuardReturnsZeroDuration covers the three short-circuit +// conditions. All three return a literal 0.0 duration and MUST NOT touch the +// harness or create a temp dir. +func TestRunPathConstructor_GuardReturnsZeroDuration(t *testing.T) { + in := loadInputs(t) + cases := []struct { + name string + findings []schemas.RawFinding + maxPaths int + maxChildren int + }{ + {"no_findings", nil, 5, 3}, + {"max_paths_zero", in.FindingsPair, 0, 3}, + {"max_children_zero", in.FindingsPair, 5, 0}, + {"max_paths_negative", in.FindingsPair, -1, 3}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + app := &appx.Fake{} // unscripted: any harness call fails the test + got, err := RunPathConstructor(context.Background(), app, tc.findings, goldenPath("graph.json"), tc.maxPaths, tc.maxChildren, nil) + if err != nil { + t.Fatalf("RunPathConstructor: %v", err) + } + if len(app.Harnesses) != 0 { + t.Errorf("guard branch made %d harness calls, want 0", len(app.Harnesses)) + } + if got.TotalPathsEvaluated != 0 || got.ViablePaths != 0 || len(got.AttackPaths) != 0 { + t.Errorf("guard branch returned %+v, want an empty ChainResult", got) + } + if got.ChainDurationSeconds != 0.0 { + t.Errorf("chain_duration_seconds = %v, want the literal 0.0", got.ChainDurationSeconds) + } + if dump, err := json.Marshal(got.AttackPaths); err != nil || string(dump) != "[]" { + t.Errorf("attack_paths marshaled as %s, want []", dump) + } + }) + } +} + +// TestRunPathConstructor_ParentHarnessOptions pins the parent call's cwd prefix +// and — the parity detail that separates CHAIN from every other phase — that it +// passes NO project_dir. +func TestRunPathConstructor_ParentHarnessOptions(t *testing.T) { + in := loadInputs(t) + app := scriptedApp(`{"investigations": []}`, nil) + + if _, err := RunPathConstructor(context.Background(), app, in.FindingsPair, goldenPath("graph.json"), 5, 3, nil); err != nil { + t.Fatalf("RunPathConstructor: %v", err) + } + if len(app.Harnesses) != 1 { + t.Fatalf("made %d harness calls, want 1", len(app.Harnesses)) + } + opts := app.Harnesses[0].Opts + if base := filepath.Base(opts.Cwd); !strings.HasPrefix(base, pathConstructorTempPrefix) { + t.Errorf("cwd = %q, want a tempdir named %q*", opts.Cwd, pathConstructorTempPrefix) + } + if opts.ProjectDir != "" { + t.Errorf("project_dir = %q, want empty (Python passes only cwd)", opts.ProjectDir) + } + if _, err := os.Stat(opts.Cwd); !os.IsNotExist(err) { + t.Errorf("temp dir %q survived the call (stat err = %v)", opts.Cwd, err) + } +} + +// TestRunPathConstructor_ParentFailurePropagates: only the parent call can fail +// the phase, and it does so with extract_harness_result's exact message. +func TestRunPathConstructor_ParentFailurePropagates(t *testing.T) { + in := loadInputs(t) + app := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return nil, errors.New("boom") + })} + + _, err := RunPathConstructor(context.Background(), app, in.FindingsPair, goldenPath("graph.json"), 5, 3, nil) + if err == nil { + t.Fatal("expected the parent harness failure to propagate") + } + if want := "PathConstructor harness error: boom"; err.Error() != want { + t.Errorf("error = %q, want %q", err, want) + } +} + +// TestRunPathConstructor_NoInvestigations returns an empty result but WITH a +// measured (rounded) duration, unlike the guard branch. +func TestRunPathConstructor_NoInvestigations(t *testing.T) { + in := loadInputs(t) + app := scriptedApp(`{"investigations": []}`, nil) + + got, err := RunPathConstructor(context.Background(), app, in.FindingsPair, goldenPath("graph.json"), 5, 3, nil) + if err != nil { + t.Fatalf("RunPathConstructor: %v", err) + } + if got.TotalPathsEvaluated != 0 || got.ViablePaths != 0 || len(got.AttackPaths) != 0 { + t.Errorf("got %+v, want an empty ChainResult", got) + } + if got.ChainDurationSeconds < 0 { + t.Errorf("chain_duration_seconds = %v, want a measured duration", got.ChainDurationSeconds) + } + if r := pyfmt.Round(got.ChainDurationSeconds, 3); r != got.ChainDurationSeconds { + t.Errorf("chain_duration_seconds = %v, want it rounded to 3 dp", got.ChainDurationSeconds) + } +} + +// TestRunPathConstructor_FansOutChildren covers the happy path end to end: the +// investigations are truncated to max_children, every child gets the prompt +// _child_prompt builds in the parent's cwd, and the counters line up. +func TestRunPathConstructor_FansOutChildren(t *testing.T) { + in := loadInputs(t) + app := scriptedApp(planJSON, func(prompt string) (string, error) { + return attackPathJSON(strings.SplitN(prompt, "\n", 2)[0]), nil + }) + + got, err := RunPathConstructor(context.Background(), app, in.FindingsPair, goldenPath("graph.json"), 5, 2, nil) + if err != nil { + t.Fatalf("RunPathConstructor: %v", err) + } + // max_children = 2 truncates the 3-investigation plan. + if got.TotalPathsEvaluated != 2 { + t.Errorf("total_paths_evaluated = %d, want 2 (truncated to max_children)", got.TotalPathsEvaluated) + } + if got.ViablePaths != 2 || len(got.AttackPaths) != 2 { + t.Fatalf("viable_paths = %d / %d paths, want 2", got.ViablePaths, len(got.AttackPaths)) + } + // gather preserves ORDER: path i comes from investigation i. + if got.AttackPaths[0].ID != "a" || got.AttackPaths[1].ID != "b" { + t.Errorf("paths = %q/%q, want them ordered by investigation index", got.AttackPaths[0].ID, got.AttackPaths[1].ID) + } + if len(app.Harnesses) != 3 { + t.Fatalf("made %d harness calls, want 1 parent + 2 children", len(app.Harnesses)) + } + parentCwd := app.Harnesses[0].Opts.Cwd + for i, call := range app.Harnesses[1:] { + if call.Opts.Cwd != parentCwd { + t.Errorf("child %d ran in %q, want the parent's cwd %q", i, call.Opts.Cwd, parentCwd) + } + if call.Opts.ProjectDir != "" { + t.Errorf("child %d got project_dir %q, want empty", i, call.Opts.ProjectDir) + } + if !strings.HasSuffix(call.Prompt, "- The parent will keep at most 5 final attack paths.") { + t.Errorf("child %d prompt is not the _child_prompt output:\n%s", i, call.Prompt) + } + } +} + +// TestRunPathConstructor_FailingChildrenAreDropped pins `except Exception: +// return None` — a child failure removes only that path and never fails the +// phase, and the surviving paths keep their investigation order. +func TestRunPathConstructor_FailingChildrenAreDropped(t *testing.T) { + in := loadInputs(t) + app := scriptedApp(planJSON, func(prompt string) (string, error) { + switch { + case strings.HasPrefix(prompt, "a"): + return "", errors.New("harness exploded") + case strings.HasPrefix(prompt, "b"): + return `{"not": "an attack path"`, nil // unparsable + } + return attackPathJSON("c"), nil + }) + + got, err := RunPathConstructor(context.Background(), app, in.FindingsPair, goldenPath("graph.json"), 5, 3, nil) + if err != nil { + t.Fatalf("child failures must not fail the phase: %v", err) + } + if got.TotalPathsEvaluated != 3 { + t.Errorf("total_paths_evaluated = %d, want 3 (all investigations were attempted)", got.TotalPathsEvaluated) + } + if got.ViablePaths != 1 || len(got.AttackPaths) != 1 || got.AttackPaths[0].ID != "c" { + t.Errorf("got %d viable paths %+v, want only the third child's", got.ViablePaths, got.AttackPaths) + } +} + +// TestRunPathConstructor_TruncatesToMaxPaths pins that viable_paths is counted +// AFTER the [:max_paths] slice, not before. +func TestRunPathConstructor_TruncatesToMaxPaths(t *testing.T) { + in := loadInputs(t) + app := scriptedApp(planJSON, func(prompt string) (string, error) { + return attackPathJSON(strings.SplitN(prompt, "\n", 2)[0]), nil + }) + + got, err := RunPathConstructor(context.Background(), app, in.FindingsPair, goldenPath("graph.json"), 2, 3, nil) + if err != nil { + t.Fatalf("RunPathConstructor: %v", err) + } + if got.TotalPathsEvaluated != 3 { + t.Errorf("total_paths_evaluated = %d, want 3", got.TotalPathsEvaluated) + } + if got.ViablePaths != 2 || len(got.AttackPaths) != 2 { + t.Errorf("viable_paths = %d / %d paths, want 2 (max_paths)", got.ViablePaths, len(got.AttackPaths)) + } + if got.AttackPaths[0].ID != "a" || got.AttackPaths[1].ID != "b" { + t.Errorf("kept %q/%q, want the FIRST max_paths in investigation order", got.AttackPaths[0].ID, got.AttackPaths[1].ID) + } +} + +// TestRunPathConstructor_ChildrenRunConcurrently pins the gather fan-out: CHAIN +// has NO semaphore, so all max_children children are in flight at once. +func TestRunPathConstructor_ChildrenRunConcurrently(t *testing.T) { + in := loadInputs(t) + + const children = 3 + var mu sync.Mutex + release := make(chan struct{}) + arrived := 0 + + app := &appx.Fake{} + app.HarnessFn = appx.HarnessJSON(func(prompt string, _ harness.Options) (json.RawMessage, error) { + if strings.Contains(prompt, "You are the CHAIN parent harness") { + return json.RawMessage(planJSON), nil + } + mu.Lock() + arrived++ + all := arrived == children + mu.Unlock() + if all { + close(release) + } + select { + case <-release: + case <-time.After(5 * time.Second): + return nil, errors.New("children did not run concurrently") + } + return json.RawMessage(attackPathJSON("p")), nil + }) + + got, err := RunPathConstructor(context.Background(), app, in.FindingsPair, goldenPath("graph.json"), 5, children, nil) + if err != nil { + t.Fatalf("RunPathConstructor: %v", err) + } + if got.ViablePaths != children { + t.Fatalf("viable_paths = %d, want %d", got.ViablePaths, children) + } + if peak := app.MaxConcurrentHarness(); peak != children { + t.Errorf("peak concurrent harness calls = %d, want %d (asyncio.gather with no semaphore)", peak, children) + } +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// firstDiff renders the first differing line of two strings, which is far more +// readable than dumping two multi-KB prompts. +func firstDiff(got, want string) string { + g, w := strings.Split(got, "\n"), strings.Split(want, "\n") + for i := 0; i < len(g) && i < len(w); i++ { + if g[i] != w[i] { + return fmt.Sprintf("first difference at line %d:\n go: %q\n python: %q", i+1, g[i], w[i]) + } + } + return fmt.Sprintf("line counts differ: go %d lines, python %d lines", len(g), len(w)) +} + +// TestFilterGraphForFindings_NullEndpointJoinsTheIDSet pins that ids are +// compared BY VALUE, not as strings. +// +// Python's neighbour pass adds the opposite endpoint unconditionally +// (`if src in finding_resources: neighbors.add(tgt)`), so a JSON `null` target +// lands in relevant_ids and then matches both `n.get("resource_id") in +// relevant_ids` and the two-endpoint edge test. Ground truth from the repo venv +// on _filter_graph_for_findings with the graph below and one finding whose only +// resource_id is "a": +// +// {"nodes": [{"resource_id": "a", "resource_type": "t"}, +// {"resource_id": null, "resource_type": "nullid"}], +// "edges": [{"source": "a", "target": null, "type": "e1"}, +// {"source": null, "target": "a", "type": "e2"}], +// "clusters": []} +// +// i.e. the input unchanged. Dropping the null-id node and both edges (what a +// map[string]bool id set does) changes the {{RESOURCE_GRAPH_JSON}} splice in +// the CHAIN parent prompt by two nodes' worth of text. +func TestFilterGraphForFindings_NullEndpointJoinsTheIDSet(t *testing.T) { + const graphJSON = `{"nodes": [{"resource_id": "a", "resource_type": "t"}, {"resource_id": null, "resource_type": "nullid"}], "edges": [{"source": "a", "target": null, "type": "e1"}, {"source": null, "target": "a", "type": "e2"}], "clusters": []}` + graph := mustLoadOrdered(t, graphJSON) + + f := schemas.NewRawFinding() + f.Resources = []schemas.AffectedResource{{ResourceID: "a"}} + + got := filterGraphForFindings(graph, []schemas.RawFinding{f}) + if dump := pyfmt.Dumps(got, 0); dump != graphJSON { + t.Errorf("filtered graph = %s,\n want %s", dump, graphJSON) + } +} + +// A NUMERIC endpoint is promoted the same way, and its node survives even +// though `5` is not a string. Venv ground truth for the graph below with one +// finding whose only resource_id is "a": +// +// {"nodes": [{"resource_id": "a"}, {"resource_id": 5}], +// "edges": [{"source": "a", "target": 5}], "clusters": []} +func TestFilterGraphForFindings_NumericEndpointJoinsTheIDSet(t *testing.T) { + const graphJSON = `{"nodes": [{"resource_id": "a"}, {"resource_id": 5}], "edges": [{"source": "a", "target": 5}], "clusters": []}` + graph := mustLoadOrdered(t, graphJSON) + + f := schemas.NewRawFinding() + f.Resources = []schemas.AffectedResource{{ResourceID: "a"}} + + got := filterGraphForFindings(graph, []schemas.RawFinding{f}) + if dump := pyfmt.Dumps(got, 0); dump != graphJSON { + t.Errorf("filtered graph = %s,\n want %s", dump, graphJSON) + } +} + +// Once Python None is in relevant_ids, a node with NO resource_id key matches +// too, because `n.get("resource_id")` is None for it. Venv ground truth: +// +// {"nodes": [{"resource_id": "a"}, {"kind": "no-id"}], +// "edges": [{"source": "a", "target": null}], "clusters": []} +func TestFilterGraphForFindings_NoneInTheSetKeepsIDLessNodes(t *testing.T) { + const graphJSON = `{"nodes": [{"resource_id": "a"}, {"kind": "no-id"}], "edges": [{"source": "a", "target": null}], "clusters": []}` + graph := mustLoadOrdered(t, graphJSON) + + f := schemas.NewRawFinding() + f.Resources = []schemas.AffectedResource{{ResourceID: "a"}} + + got := filterGraphForFindings(graph, []schemas.RawFinding{f}) + if dump := pyfmt.Dumps(got, 0); dump != graphJSON { + t.Errorf("filtered graph = %s,\n want %s", dump, graphJSON) + } +} + +// Python hashes True == 1 into one set slot, so promoting `true` also keeps a +// node whose resource_id is the int 1. Venv ground truth for +// nodes=[{"resource_id": true}, {"resource_id": 1}], +// edges=[{"source": "a", "target": true}] and a finding with resource_id "a": +// +// {"nodes": [{"resource_id": true}, {"resource_id": 1}], +// "edges": [{"source": "a", "target": true}], "clusters": []} +func TestFilterGraphForFindings_BoolAndIntShareASetSlot(t *testing.T) { + const graphJSON = `{"nodes": [{"resource_id": true}, {"resource_id": 1}], "edges": [{"source": "a", "target": true}], "clusters": []}` + graph := mustLoadOrdered(t, graphJSON) + + f := schemas.NewRawFinding() + f.Resources = []schemas.AffectedResource{{ResourceID: "a"}} + + got := filterGraphForFindings(graph, []schemas.RawFinding{f}) + if dump := pyfmt.Dumps(got, 0); dump != graphJSON { + t.Errorf("filtered graph = %s,\n want %s", dump, graphJSON) + } +} diff --git a/go/internal/agents/chain/testdata/golden/child_prompt_a.txt b/go/internal/agents/chain/testdata/golden/child_prompt_a.txt new file mode 100644 index 0000000..dd026dc --- /dev/null +++ b/go/internal/agents/chain/testdata/golden/child_prompt_a.txt @@ -0,0 +1,9 @@ +Verify whether an anonymous attacker can assume aws_iam_role.admin + and then read aws_s3_bucket.data. Evidence required per hop. + +OUTPUT REQUIREMENTS: +- Return a single JSON object matching AttackPath. +- Only include a path if there is a coherent attacker progression across resources. +- Use findings_involved IDs tied to the path steps. +- Keep steps in strict step_number order starting at 1. +- The parent will keep at most 5 final attack paths. \ No newline at end of file diff --git a/go/internal/agents/chain/testdata/golden/child_prompt_b.txt b/go/internal/agents/chain/testdata/golden/child_prompt_b.txt new file mode 100644 index 0000000..cc0148a --- /dev/null +++ b/go/internal/agents/chain/testdata/golden/child_prompt_b.txt @@ -0,0 +1,8 @@ +Check whether the missing CloudTrail hides the pivot. + +OUTPUT REQUIREMENTS: +- Return a single JSON object matching AttackPath. +- Only include a path if there is a coherent attacker progression across resources. +- Use findings_involved IDs tied to the path steps. +- Keep steps in strict step_number order starting at 1. +- The parent will keep at most 1 final attack paths. \ No newline at end of file diff --git a/go/internal/agents/chain/testdata/golden/graph.json b/go/internal/agents/chain/testdata/golden/graph.json new file mode 100644 index 0000000..a87afa4 --- /dev/null +++ b/go/internal/agents/chain/testdata/golden/graph.json @@ -0,0 +1,62 @@ +{ + "nodes": [ + { + "resource_id": "aws_iam_role.admin", + "resource_type": "aws_iam_role", + "file_path": "main.tf", + "config_summary": { + "assume_role_policy": "*", + "name": "admin" + } + }, + { + "resource_id": "aws_s3_bucket.data", + "resource_type": "aws_s3_bucket", + "file_path": "data.tf", + "config_summary": {} + }, + { + "resource_id": "aws_kms_key.unrelated", + "resource_type": "aws_kms_key", + "file_path": "kms.tf", + "config_summary": { + "enable_key_rotation": false + } + }, + "not-a-dict-node", + { + "resource_type": "aws_vpc.no_id" + } + ], + "edges": [ + { + "source": "aws_iam_role.admin", + "target": "aws_s3_bucket.data", + "type": "data_access", + "description": "role can read the bucket" + }, + { + "source": "aws_s3_bucket.data", + "target": "aws_kms_key.unrelated", + "type": "encryption" + }, + { + "source": "unrelated.a", + "target": "unrelated.b", + "type": "references" + }, + "not-a-dict-edge", + { + "target": "aws_s3_bucket.data" + } + ], + "clusters": [ + { + "name": "prod", + "members": [ + "aws_iam_role.admin" + ] + } + ], + "generated_by": "gen_golden.py" +} \ No newline at end of file diff --git a/go/internal/agents/chain/testdata/golden/graph_not_object.json b/go/internal/agents/chain/testdata/golden/graph_not_object.json new file mode 100644 index 0000000..6396400 --- /dev/null +++ b/go/internal/agents/chain/testdata/golden/graph_not_object.json @@ -0,0 +1,4 @@ +[ + 1, + 2 +] \ No newline at end of file diff --git a/go/internal/agents/chain/testdata/golden/inputs.json b/go/internal/agents/chain/testdata/golden/inputs.json new file mode 100644 index 0000000..796c543 --- /dev/null +++ b/go/internal/agents/chain/testdata/golden/inputs.json @@ -0,0 +1,118 @@ +{ + "findings_pair": [ + { + "id": "finding-1", + "hunter_strategy": "iam", + "title": "Role trusts * & assumes admin", + "description": "aws_iam_role.admin has a wildcard trust policy.", + "category": "overprivilege", + "resources": [ + { + "resource_id": "aws_iam_role.admin", + "resource_type": "aws_iam_role", + "attribute": "assume_role_policy", + "current_value": "{\"Principal\": \"*\"}", + "recommended_value": "scoped principal" + } + ], + "estimated_severity": "critical", + "confidence": "high", + "iac_file": "main.tf", + "iac_line": 12, + "config_snippet": "resource \"aws_iam_role\" \"admin\" {\n assume_role_policy = \"*\"\n}", + "benchmark_id": "CIS-1.16", + "fingerprint": "fp-iam-1" + }, + { + "id": "finding-2", + "hunter_strategy": "network", + "title": "Security group open to 0.0.0.0/0 \u2014 p\u00f6rt\u00e9 22", + "description": "Ingress from anywhere.", + "category": "public_exposure", + "resources": [], + "estimated_severity": "high", + "confidence": "medium", + "iac_file": "network.tf", + "iac_line": 0, + "config_snippet": "", + "benchmark_id": null, + "fingerprint": "fp-net-2" + } + ], + "findings_single": [ + { + "id": "finding-3", + "hunter_strategy": "logging", + "title": "No CloudTrail", + "description": "", + "category": "missing_logging", + "resources": [], + "estimated_severity": "low", + "confidence": "low", + "iac_file": "", + "iac_line": 0, + "config_snippet": "", + "benchmark_id": null, + "fingerprint": "fp-log-3" + } + ], + "drift_report": { + "drifted_resources": [ + { + "resource_id": "aws_s3_bucket.data", + "resource_type": "aws_s3_bucket", + "iac_config": { + "acl": "private", + "versioning": true + }, + "live_config": { + "acl": "public-read", + "versioning": false + }, + "diffs": [ + { + "attribute": "acl", + "iac_value": "private", + "live_value": "public-read", + "security_impact": "bucket became world readable" + }, + { + "attribute": "retention_days", + "iac_value": 30.0, + "live_value": null, + "security_impact": null + }, + { + "attribute": "mfa_delete", + "iac_value": true, + "live_value": false, + "security_impact": null + } + ], + "security_relevant": true, + "significance": "critical" + } + ], + "iac_only_resources": [ + "aws_kms_key.unrelated" + ], + "cloud_only_resources": [] + }, + "investigations": [ + { + "title": "Wildcard trust to data lake", + "rationale": "finding-1 and finding-2 share a network path.", + "findings_involved": [ + "finding-1", + "finding-2" + ], + "child_prompt": "\n\n Verify whether an anonymous attacker can assume aws_iam_role.admin\n and then read aws_s3_bucket.data. Evidence required per hop.\t \n\n" + }, + { + "title": "Isolated logging gap", + "rationale": "", + "findings_involved": [], + "child_prompt": "Check whether the missing CloudTrail hides the pivot." + } + ] +} \ No newline at end of file diff --git a/go/internal/agents/chain/testdata/golden/parent_prompt_a.txt b/go/internal/agents/chain/testdata/golden/parent_prompt_a.txt new file mode 100644 index 0000000..9103906 --- /dev/null +++ b/go/internal/agents/chain/testdata/golden/parent_prompt_a.txt @@ -0,0 +1,165 @@ +ROLE: +You are the CHAIN parent harness in CloudSecurity AF. + +OBJECTIVE: +Construct attack-path investigations by clustering related findings and crafting high-signal child prompts. + +INPUTS: +- max_paths: 5 +- max_children: 3 +- findings: +[ + { + "id": "finding-1", + "title": "Role trusts * & assumes admin", + "category": "overprivilege", + "severity": "critical", + "resources": [ + "aws_iam_role.admin" + ], + "iac_file": "main.tf", + "iac_line": 12, + "fingerprint": "fp-iam-1" + }, + { + "id": "finding-2", + "title": "Security group open to 0.0.0.0/0 \u2014 p\u00f6rt\u00e9 22", + "category": "public_exposure", + "severity": "high", + "resources": [], + "iac_file": "network.tf", + "iac_line": 0, + "fingerprint": "fp-net-2" + } +] +- resource graph: +{ + "nodes": [ + { + "resource_id": "aws_iam_role.admin", + "resource_type": "aws_iam_role", + "file_path": "main.tf", + "config_summary": { + "assume_role_policy": "*", + "name": "admin" + } + }, + { + "resource_id": "aws_s3_bucket.data", + "resource_type": "aws_s3_bucket", + "file_path": "data.tf", + "config_summary": {} + } + ], + "edges": [ + { + "source": "aws_iam_role.admin", + "target": "aws_s3_bucket.data", + "type": "data_access", + "description": "role can read the bucket" + } + ], + "clusters": [ + { + "name": "prod", + "members": [ + "aws_iam_role.admin" + ] + } + ] +} +- drift report: +{ + "drifted_resources": [ + { + "resource_id": "aws_s3_bucket.data", + "resource_type": "aws_s3_bucket", + "iac_config": { + "acl": "private", + "versioning": true + }, + "live_config": { + "acl": "public-read", + "versioning": false + }, + "diffs": [ + { + "attribute": "acl", + "iac_value": "private", + "live_value": "public-read", + "security_impact": "bucket became world readable" + }, + { + "attribute": "retention_days", + "iac_value": 30.0, + "live_value": null, + "security_impact": null + }, + { + "attribute": "mfa_delete", + "iac_value": true, + "live_value": false, + "security_impact": null + } + ], + "security_relevant": true, + "significance": "critical" + } + ], + "iac_only_resources": [ + "aws_kms_key.unrelated" + ], + "cloud_only_resources": [] +} + +TASK: +1) Identify clusters of related misconfigurations that can realistically be chained by an attacker. +2) Use resource graph topology and drift context to separate exploitable chains from isolated findings. +3) Prioritize clusters with clear entry points, privilege escalation potential, lateral movement, and sensitive targets. +4) Produce up to max_children investigations. For each investigation, write a specific child prompt that asks a child harness to test: + - starting resource and attacker foothold, + - intermediate pivots and permissions/configurations abused, + - final target and blast radius. +5) Child prompts must reference concrete finding IDs, resource IDs, and relevant graph edges from input. +6) Child prompts must explicitly ask the child to return one AttackPath JSON object. + +GRAPH-AWARE PLANNING INSTRUCTIONS: +1) Build candidate chains by walking trust, execution, network_path, and data_access relationships. +2) Favor chains where edge directionality aligns with realistic attacker movement. +3) Penalize chains that require contradictory assumptions or unavailable access prerequisites. +4) Incorporate drift_report signals when live drift makes IaC-only paths stronger or weaker. +5) Include compensating controls in rationale so child prompts can verify or refute exploitability. + +CHILD PROMPT QUALITY RULES: +1) Each child prompt must include explicit attacker starting assumptions. +2) Each child prompt must enumerate expected pivot sequence in order. +3) Each child prompt must specify required evidence for confirming each hop. +4) Each child prompt must request concrete impacted resources and business-impact framing. +5) Each child prompt must ask for confidence and uncertainty notes when hops are conditional. + +PRIORITIZATION HEURISTICS: +1) Rank higher when chains cross trust boundaries (internet to private, low-privilege to admin, account-to-account). +2) Rank higher when path length is short and prerequisites are low complexity. +3) Rank higher when endpoints contain sensitive data, control-plane access, or broad automation privileges. +4) Rank lower when chain requires speculative assumptions unsupported by graph/finding evidence. +5) Keep portfolio diversity across identity, network, data, and compute abuse patterns. + +OUTPUT: +Return JSON matching PathInvestigationPlan: +{ + "investigations": [ + { + "title": "...", + "rationale": "...", + "findings_involved": ["finding-id-1", "finding-id-2"], + "child_prompt": "specific prompt for the child harness" + } + ] +} + +CONSTRAINTS: +- Do not invent findings or resources not present in inputs. +- Focus on realistic exploit chains, not speculative trivia. +- Prefer fewer, higher-confidence investigations. +- Keep JSON schema fidelity exact for PathInvestigationPlan. +- No markdown fences or prose outside JSON. diff --git a/go/internal/agents/chain/testdata/golden/parent_prompt_b.txt b/go/internal/agents/chain/testdata/golden/parent_prompt_b.txt new file mode 100644 index 0000000..07ea93d --- /dev/null +++ b/go/internal/agents/chain/testdata/golden/parent_prompt_b.txt @@ -0,0 +1,94 @@ +ROLE: +You are the CHAIN parent harness in CloudSecurity AF. + +OBJECTIVE: +Construct attack-path investigations by clustering related findings and crafting high-signal child prompts. + +INPUTS: +- max_paths: 1 +- max_children: 1 +- findings: +[ + { + "id": "finding-1", + "title": "Role trusts * & assumes admin", + "category": "overprivilege", + "severity": "critical", + "resources": [ + "aws_iam_role.admin" + ], + "iac_file": "main.tf", + "iac_line": 12, + "fingerprint": "fp-iam-1" + }, + { + "id": "finding-2", + "title": "Security group open to 0.0.0.0/0 \u2014 p\u00f6rt\u00e9 22", + "category": "public_exposure", + "severity": "high", + "resources": [], + "iac_file": "network.tf", + "iac_line": 0, + "fingerprint": "fp-net-2" + } +] +- resource graph: +{ + "nodes": [], + "edges": [], + "clusters": [] +} +- drift report: +{} + +TASK: +1) Identify clusters of related misconfigurations that can realistically be chained by an attacker. +2) Use resource graph topology and drift context to separate exploitable chains from isolated findings. +3) Prioritize clusters with clear entry points, privilege escalation potential, lateral movement, and sensitive targets. +4) Produce up to max_children investigations. For each investigation, write a specific child prompt that asks a child harness to test: + - starting resource and attacker foothold, + - intermediate pivots and permissions/configurations abused, + - final target and blast radius. +5) Child prompts must reference concrete finding IDs, resource IDs, and relevant graph edges from input. +6) Child prompts must explicitly ask the child to return one AttackPath JSON object. + +GRAPH-AWARE PLANNING INSTRUCTIONS: +1) Build candidate chains by walking trust, execution, network_path, and data_access relationships. +2) Favor chains where edge directionality aligns with realistic attacker movement. +3) Penalize chains that require contradictory assumptions or unavailable access prerequisites. +4) Incorporate drift_report signals when live drift makes IaC-only paths stronger or weaker. +5) Include compensating controls in rationale so child prompts can verify or refute exploitability. + +CHILD PROMPT QUALITY RULES: +1) Each child prompt must include explicit attacker starting assumptions. +2) Each child prompt must enumerate expected pivot sequence in order. +3) Each child prompt must specify required evidence for confirming each hop. +4) Each child prompt must request concrete impacted resources and business-impact framing. +5) Each child prompt must ask for confidence and uncertainty notes when hops are conditional. + +PRIORITIZATION HEURISTICS: +1) Rank higher when chains cross trust boundaries (internet to private, low-privilege to admin, account-to-account). +2) Rank higher when path length is short and prerequisites are low complexity. +3) Rank higher when endpoints contain sensitive data, control-plane access, or broad automation privileges. +4) Rank lower when chain requires speculative assumptions unsupported by graph/finding evidence. +5) Keep portfolio diversity across identity, network, data, and compute abuse patterns. + +OUTPUT: +Return JSON matching PathInvestigationPlan: +{ + "investigations": [ + { + "title": "...", + "rationale": "...", + "findings_involved": ["finding-id-1", "finding-id-2"], + "child_prompt": "specific prompt for the child harness" + } + ] +} + +CONSTRAINTS: +- Do not invent findings or resources not present in inputs. +- Focus on realistic exploit chains, not speculative trivia. +- Prefer fewer, higher-confidence investigations. +- Keep JSON schema fidelity exact for PathInvestigationPlan. +- No markdown fences or prose outside JSON. diff --git a/go/internal/agents/chain/testdata/golden/parent_prompt_c.txt b/go/internal/agents/chain/testdata/golden/parent_prompt_c.txt new file mode 100644 index 0000000..1ad58d9 --- /dev/null +++ b/go/internal/agents/chain/testdata/golden/parent_prompt_c.txt @@ -0,0 +1,82 @@ +ROLE: +You are the CHAIN parent harness in CloudSecurity AF. + +OBJECTIVE: +Construct attack-path investigations by clustering related findings and crafting high-signal child prompts. + +INPUTS: +- max_paths: 2 +- max_children: 4 +- findings: +[ + { + "id": "finding-3", + "title": "No CloudTrail", + "category": "missing_logging", + "severity": "low", + "resources": [], + "iac_file": "", + "iac_line": 0, + "fingerprint": "fp-log-3" + } +] +- resource graph: +{ + "nodes": [], + "edges": [], + "clusters": [] +} +- drift report: +{} + +TASK: +1) Identify clusters of related misconfigurations that can realistically be chained by an attacker. +2) Use resource graph topology and drift context to separate exploitable chains from isolated findings. +3) Prioritize clusters with clear entry points, privilege escalation potential, lateral movement, and sensitive targets. +4) Produce up to max_children investigations. For each investigation, write a specific child prompt that asks a child harness to test: + - starting resource and attacker foothold, + - intermediate pivots and permissions/configurations abused, + - final target and blast radius. +5) Child prompts must reference concrete finding IDs, resource IDs, and relevant graph edges from input. +6) Child prompts must explicitly ask the child to return one AttackPath JSON object. + +GRAPH-AWARE PLANNING INSTRUCTIONS: +1) Build candidate chains by walking trust, execution, network_path, and data_access relationships. +2) Favor chains where edge directionality aligns with realistic attacker movement. +3) Penalize chains that require contradictory assumptions or unavailable access prerequisites. +4) Incorporate drift_report signals when live drift makes IaC-only paths stronger or weaker. +5) Include compensating controls in rationale so child prompts can verify or refute exploitability. + +CHILD PROMPT QUALITY RULES: +1) Each child prompt must include explicit attacker starting assumptions. +2) Each child prompt must enumerate expected pivot sequence in order. +3) Each child prompt must specify required evidence for confirming each hop. +4) Each child prompt must request concrete impacted resources and business-impact framing. +5) Each child prompt must ask for confidence and uncertainty notes when hops are conditional. + +PRIORITIZATION HEURISTICS: +1) Rank higher when chains cross trust boundaries (internet to private, low-privilege to admin, account-to-account). +2) Rank higher when path length is short and prerequisites are low complexity. +3) Rank higher when endpoints contain sensitive data, control-plane access, or broad automation privileges. +4) Rank lower when chain requires speculative assumptions unsupported by graph/finding evidence. +5) Keep portfolio diversity across identity, network, data, and compute abuse patterns. + +OUTPUT: +Return JSON matching PathInvestigationPlan: +{ + "investigations": [ + { + "title": "...", + "rationale": "...", + "findings_involved": ["finding-id-1", "finding-id-2"], + "child_prompt": "specific prompt for the child harness" + } + ] +} + +CONSTRAINTS: +- Do not invent findings or resources not present in inputs. +- Focus on realistic exploit chains, not speculative trivia. +- Prefer fewer, higher-confidence investigations. +- Keep JSON schema fidelity exact for PathInvestigationPlan. +- No markdown fences or prose outside JSON. diff --git a/go/internal/agents/hunt/compliance_hunter.go b/go/internal/agents/hunt/compliance_hunter.go new file mode 100644 index 0000000..b56b611 --- /dev/null +++ b/go/internal/agents/hunt/compliance_hunter.go @@ -0,0 +1,39 @@ +package hunt + +import ( + "context" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// complianceHunter ports the four module-level values of +// src/cloudsecurity_af/agents/hunt/compliance_hunter.py. +// +// Domain keywords, verbatim from the Python call: +// +// '' +// +// Python parity: that single EMPTY string is dropped by the +// `[keyword.lower() for keyword in domain_keywords if keyword]` filter in +// build_graph_context_for_hunter, leaving no keywords at all — which is the +// code path where _matches() returns True unconditionally. The compliance +// hunter therefore sees EVERY node and EVERY edge in the graph, by design. +var complianceHunter = hunter{ + promptPath: "hunt/compliance.txt", + keywords: []string{""}, + agentName: "compliance_hunter", + strategy: "compliance", +} + +// RunComplianceHunter ports run_compliance_hunter in +// src/cloudsecurity_af/agents/hunt/compliance_hunter.py. +// +// It is registered as the `run_compliance_hunter` router reasoner (see +// internal/reasoners) and reached from hunt_phase through app.Call, with the +// same four kwargs: repo_path, resource_graph_path, inventory_path, depth. +// +// See hunter.run in hunt.go for the shared body and its parity notes. +func RunComplianceHunter(ctx context.Context, app appx.Harnesser, repoPath, resourceGraphPath, inventoryPath, depth string) (schemas.HuntResult, error) { + return complianceHunter.run(ctx, app, repoPath, resourceGraphPath, inventoryPath, depth) +} diff --git a/go/internal/agents/hunt/compute_hunter.go b/go/internal/agents/hunt/compute_hunter.go new file mode 100644 index 0000000..a6027bc --- /dev/null +++ b/go/internal/agents/hunt/compute_hunter.go @@ -0,0 +1,52 @@ +package hunt + +import ( + "context" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// computeHunter ports the four module-level values of +// src/cloudsecurity_af/agents/hunt/compute_hunter.py. +// +// Domain keywords, verbatim from the Python call: +// +// 'ec2', 'ecs', 'eks', 'lambda', 'fargate', 'instance', +// 'container', 'node_group', 'auto_scaling', 'launch_template', +// 'ecr', 'repository', 'ebs_volume', 'ebs_snapshot', +// 'volume_attachment' +var computeHunter = hunter{ + promptPath: "hunt/compute.txt", + keywords: []string{ + "ec2", + "ecs", + "eks", + "lambda", + "fargate", + "instance", + "container", + "node_group", + "auto_scaling", + "launch_template", + "ecr", + "repository", + "ebs_volume", + "ebs_snapshot", + "volume_attachment", + }, + agentName: "compute_hunter", + strategy: "compute", +} + +// RunComputeHunter ports run_compute_hunter in +// src/cloudsecurity_af/agents/hunt/compute_hunter.py. +// +// It is registered as the `run_compute_hunter` router reasoner (see +// internal/reasoners) and reached from hunt_phase through app.Call, with the +// same four kwargs: repo_path, resource_graph_path, inventory_path, depth. +// +// See hunter.run in hunt.go for the shared body and its parity notes. +func RunComputeHunter(ctx context.Context, app appx.Harnesser, repoPath, resourceGraphPath, inventoryPath, depth string) (schemas.HuntResult, error) { + return computeHunter.run(ctx, app, repoPath, resourceGraphPath, inventoryPath, depth) +} diff --git a/go/internal/agents/hunt/data_hunter.go b/go/internal/agents/hunt/data_hunter.go new file mode 100644 index 0000000..e46a706 --- /dev/null +++ b/go/internal/agents/hunt/data_hunter.go @@ -0,0 +1,58 @@ +package hunt + +import ( + "context" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// dataHunter ports the four module-level values of +// src/cloudsecurity_af/agents/hunt/data_hunter.py. +// +// Domain keywords, verbatim from the Python call: +// +// 's3', 'rds', 'dynamodb', 'ebs', 'efs', 'redshift', 'aurora', +// 'bucket', 'database', 'storage', 'backup', 'snapshot', +// 'encryption', 'db_instance', 'db_option', 'db_parameter', +// 'db_subnet', 'neptune', 'elasticsearch', 'es_domain', 'kms' +var dataHunter = hunter{ + promptPath: "hunt/data.txt", + keywords: []string{ + "s3", + "rds", + "dynamodb", + "ebs", + "efs", + "redshift", + "aurora", + "bucket", + "database", + "storage", + "backup", + "snapshot", + "encryption", + "db_instance", + "db_option", + "db_parameter", + "db_subnet", + "neptune", + "elasticsearch", + "es_domain", + "kms", + }, + agentName: "data_hunter", + strategy: "data", +} + +// RunDataHunter ports run_data_hunter in +// src/cloudsecurity_af/agents/hunt/data_hunter.py. +// +// It is registered as the `run_data_hunter` router reasoner (see +// internal/reasoners) and reached from hunt_phase through app.Call, with the +// same four kwargs: repo_path, resource_graph_path, inventory_path, depth. +// +// See hunter.run in hunt.go for the shared body and its parity notes. +func RunDataHunter(ctx context.Context, app appx.Harnesser, repoPath, resourceGraphPath, inventoryPath, depth string) (schemas.HuntResult, error) { + return dataHunter.run(ctx, app, repoPath, resourceGraphPath, inventoryPath, depth) +} diff --git a/go/internal/agents/hunt/doc.go b/go/internal/agents/hunt/doc.go new file mode 100644 index 0000000..a1d6f57 --- /dev/null +++ b/go/internal/agents/hunt/doc.go @@ -0,0 +1,49 @@ +// Package hunt ports src/cloudsecurity_af/agents/hunt/** — the seven +// domain-specialized HUNT agents. +// +// Python Go +// ---------------------------------------- ---------------------- +// hunt/iam_hunter.run_iam_hunter RunIAMHunter +// hunt/network_hunter.run_network_hunter RunNetworkHunter +// hunt/data_hunter.run_data_hunter RunDataHunter +// hunt/secrets_hunter.run_secrets_hunter RunSecretsHunter +// hunt/compute_hunter.run_compute_hunter RunComputeHunter +// hunt/logging_hunter.run_logging_hunter RunLoggingHunter +// hunt/compliance_hunter.run_compliance_hunter RunComplianceHunter +// +// internal/reasoners wraps these as the `run_iam_hunter` … `run_compliance_hunter` +// router reasoners; internal/phases drives them through app.Call under a +// semaphore, never in-process, exactly as hunt_phase does in Python. +// +// # The seven files are one function +// +// Every Python hunter file is a byte-for-byte copy of the others apart from +// four values: the prompt template path, the graph-context domain keywords, the +// agent name used in harness error messages, and the strategy label stamped +// onto strategies_run. hunter (hunt.go) captures exactly those four; each +// _hunter.go holds only its own literals plus the exported entry point, +// so the file layout still maps 1:1 onto the Python package. +// +// # What a hunter does +// +// 1. Read its prompt template (Python does this at CALL time from the +// installed package's prompts/ tree; Go serves it from internal/prompts, +// which is embedded in the binary). +// 2. Turn the RECON artifacts (graph.json, inventory.json) into three text +// blocks filtered to its domain — util.BuildGraphContextForHunter. +// 3. Interpolate the six placeholders, IN PYTHON'S ORDER (see buildPrompt). +// 4. Run the harness with schema=HuntResult, cwd = the RESOLVED repo path, +// project_dir = the repo path as given. +// 5. Backfill total_raw / deduplicated_count from len(findings) when the model +// left them at 0, and overwrite strategies_run with its own single label. +// +// There is no per-hunter post-processing beyond step 5: fingerprints, +// cross-hunter dedup and category handling all live in reasoners/phases.py +// (Go: internal/phases), not here. +// +// # No temp directory +// +// Unlike the RECON agents, a hunter does NOT create a work dir: it runs the +// harness with cwd = str(Path(repo_path).resolve()) and lets the SDK place the +// output file under project_dir. Nothing here to clean up. +package hunt diff --git a/go/internal/agents/hunt/hunt.go b/go/internal/agents/hunt/hunt.go new file mode 100644 index 0000000..94eff09 --- /dev/null +++ b/go/internal/agents/hunt/hunt.go @@ -0,0 +1,122 @@ +package hunt + +import ( + "context" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/agents/util" + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/harnessx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/prompts" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// hunter is the whole of what differs between the seven Python hunter files. +type hunter struct { + // promptPath is PROMPT_PATH, as a path into the embedded prompt tree. + // Python: Path(__file__).resolve().parents[2] / "prompts" / "hunt" / ".txt", + // i.e. src/cloudsecurity_af/prompts/hunt/.txt. + promptPath string + // keywords is the domain_keywords list handed to + // build_graph_context_for_hunter. Order is irrelevant to the result (it is + // an any() over substring tests) but is kept verbatim for reviewability. + keywords []string + // agentName is the string extract_harness_result reports errors under — + // "iam_hunter", "network_hunter", … — and therefore appears verbatim in + // the reasoner's error. It is the PYTHON MODULE-ish name, not the strategy. + agentName string + // strategy is the single label written into strategies_run. + strategy string +} + +// run is the body every run_*_hunter shares. +// +// Ports (with iam as the example) src/cloudsecurity_af/agents/hunt/iam_hunter.py: +// +// prompt_template = PROMPT_PATH.read_text(encoding="utf-8") +// resource_graph_summary, inventory_stats, relevant_edges = build_graph_context_for_hunter( +// resource_graph_path, inventory_path, [...]) +// recon_context = f"{resource_graph_summary}\n\n{relevant_edges}\n\nINVENTORY STATS:\n{inventory_stats}" +// prompt = prompt_template.replace(...)... +// harness_cwd = str(Path(repo_path).resolve()) +// result = await app.harness(prompt=prompt, schema=HuntResult, cwd=harness_cwd, project_dir=repo_path) +// parsed = extract_harness_result(result, HuntResult, "iam_hunter") +// findings = parsed.findings +// return parsed.model_copy(update={ +// "total_raw": parsed.total_raw or len(findings), +// "deduplicated_count": parsed.deduplicated_count or len(findings), +// "strategies_run": ["iam"], +// }) +func (h hunter) run(ctx context.Context, app appx.Harnesser, repoPath, resourceGraphPath, inventoryPath, depth string) (schemas.HuntResult, error) { + prompt, err := h.buildPrompt(repoPath, resourceGraphPath, inventoryPath, depth) + if err != nil { + // Python parity: PROMPT_PATH.read_text() is inside the coroutine, so a + // missing template fails THIS reasoner rather than the process. The + // template is embedded here, so this is unreachable in practice. + return schemas.HuntResult{}, err + } + + parsed, err := harnessx.RunExtract[schemas.HuntResult]( + ctx, app, prompt, + harness.Options{ + // Python parity: cwd is the RESOLVED repo path while project_dir + // is the path exactly as the caller passed it. The two are only + // the same string when the caller already resolved it (the + // orchestrator does; a hand-written .call need not). + Cwd: util.ResolvePath(repoPath), + ProjectDir: repoPath, + }, + h.agentName, + ) + if err != nil { + return schemas.HuntResult{}, err + } + + // model_copy(update=...) is a SHALLOW copy: findings is the same list + // object. A Go struct copy shares the same backing array, which matches. + out := parsed + findings := parsed.Findings + // Python `x or y`: 0 is falsy, so only a ZERO count is backfilled. A + // negative count from a misbehaving model is truthy and survives. + if out.TotalRaw == 0 { + out.TotalRaw = len(findings) + } + if out.DeduplicatedCount == 0 { + out.DeduplicatedCount = len(findings) + } + out.StrategiesRun = []string{h.strategy} + return out, nil +} + +// buildPrompt renders the harness prompt. Split out from run so the golden test +// can compare it byte-for-byte against the string the Python builder emits. +func (h hunter) buildPrompt(repoPath, resourceGraphPath, inventoryPath, depth string) (string, error) { + template, err := prompts.Load(h.promptPath) + if err != nil { + return "", err + } + + resourceGraphSummary, inventoryStats, relevantEdges := util.BuildGraphContextForHunter( + resourceGraphPath, inventoryPath, h.keywords) + + reconContext := resourceGraphSummary + "\n\n" + relevantEdges + "\n\nINVENTORY STATS:\n" + inventoryStats + + // Python parity: the replacements are CHAINED in this exact order, and + // str.replace has no count limit. Order is observable — a value + // substituted early is itself scanned by the later replacements, so a + // resource whose config_summary literally contained "{{RELEVANT_EDGES}}" + // would have it expanded. Reproduced rather than reordered. + // + // Python parity: {{RECON_CONTEXT}} appears in NO hunt template, so the + // last replacement is a no-op today. It is kept because dropping it would + // silently change behavior the moment a template gains the placeholder. + prompt := strings.ReplaceAll(template, "{{REPO_PATH}}", repoPath) + prompt = strings.ReplaceAll(prompt, "{{DEPTH}}", depth) + prompt = strings.ReplaceAll(prompt, "{{RESOURCE_GRAPH_SUMMARY}}", resourceGraphSummary) + prompt = strings.ReplaceAll(prompt, "{{INVENTORY_STATS}}", inventoryStats) + prompt = strings.ReplaceAll(prompt, "{{RELEVANT_EDGES}}", relevantEdges) + prompt = strings.ReplaceAll(prompt, "{{RECON_CONTEXT}}", reconContext) + return prompt, 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..da9dc1f --- /dev/null +++ b/go/internal/agents/hunt/hunt_test.go @@ -0,0 +1,473 @@ +package hunt + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/harnessx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// The fixture inputs go/scripts/gen_golden.py used to capture every golden in +// testdata/golden. HUNT_REPO_PATH is absolute and non-existent on purpose: it +// makes str(Path(repo_path).resolve()) a no-op, so the goldens are +// machine-independent. +const ( + fixtureRepoPath = "/fixture/repo" + fixtureDepth = "standard" + fixtureGraph = "testdata/fixture/graph.json" + fixtureInventory = "testdata/fixture/inventory.json" +) + +// hunterFn is the shape all seven exported entry points share. +type hunterFn func(context.Context, appx.Harnesser, string, string, string, string) (schemas.HuntResult, error) + +// allHunters is the registration order of reasoners/hunt.py. The Go table is +// itself the parity assertion: a hunter added to Python without a Go entry (or +// with the wrong prompt/agent name/strategy) fails TestHunterSpecs. +var allHunters = []struct { + // module is the Python file name, which is ALSO the agent name + // extract_harness_result reports errors under and the golden basename. + module string + spec hunter + run hunterFn + strategy string + prompt string +}{ + {"iam_hunter", iamHunter, RunIAMHunter, "iam", "hunt/iam.txt"}, + {"network_hunter", networkHunter, RunNetworkHunter, "network", "hunt/network.txt"}, + {"data_hunter", dataHunter, RunDataHunter, "data", "hunt/data.txt"}, + {"secrets_hunter", secretsHunter, RunSecretsHunter, "secrets", "hunt/secrets.txt"}, + {"compute_hunter", computeHunter, RunComputeHunter, "compute", "hunt/compute.txt"}, + {"logging_hunter", loggingHunter, RunLoggingHunter, "logging", "hunt/logging.txt"}, + {"compliance_hunter", complianceHunter, RunComplianceHunter, "compliance", "hunt/compliance.txt"}, +} + +// emptyHuntResult is what a schema-valid but empty model returns. +const emptyHuntResult = `{"findings": [], "total_raw": 0, "deduplicated_count": 0, ` + + `"strategies_run": [], "hunt_duration_seconds": 0.0}` + +func fakeReturning(body string) *appx.Fake { + return &appx.Fake{ + HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return json.RawMessage(body), nil + }), + } +} + +// --------------------------------------------------------------------------- +// Prompt goldens +// --------------------------------------------------------------------------- + +// TestHunterPrompts_MatchThePythonGoldens is the byte-for-byte parity gate on +// everything that reaches the LLM: the template, the six chained replacements, +// and the three graph-context blocks util.BuildGraphContextForHunter produced. +// The goldens are captured by driving the REAL Python coroutines over the same +// fixture (go/scripts/gen_golden.py: gen_hunt_prompts). +func TestHunterPrompts_MatchThePythonGoldens(t *testing.T) { + for _, h := range allHunters { + h := h + t.Run(h.module, func(t *testing.T) { + app := fakeReturning(emptyHuntResult) + if _, err := h.run(context.Background(), app, fixtureRepoPath, fixtureGraph, fixtureInventory, fixtureDepth); err != nil { + t.Fatalf("%s: %v", h.module, err) + } + if len(app.Harnesses) != 1 { + t.Fatalf("%s: expected exactly one harness call, got %d", h.module, len(app.Harnesses)) + } + want := readGolden(t, h.module+"_prompt.txt") + if got := app.Harnesses[0].Prompt; got != want { + t.Errorf("%s prompt differs from Python\n--- got ---\n%s\n--- want ---\n%s", h.module, got, want) + } + }) + } +} + +// Every placeholder the template declares must be gone once the prompt is +// rendered — the Go counterpart of tests/test_utils.py::TestHuntPromptPlaceholders, +// which only checks that the placeholders EXIST in the template (that half +// lives in internal/prompts). +func TestHunterPrompts_LeaveNoPlaceholderBehind(t *testing.T) { + placeholders := []string{ + "{{RESOURCE_GRAPH_SUMMARY}}", "{{INVENTORY_STATS}}", "{{RELEVANT_EDGES}}", + "{{REPO_PATH}}", "{{DEPTH}}", "{{RECON_CONTEXT}}", + } + for _, h := range allHunters { + prompt, err := h.spec.buildPrompt(fixtureRepoPath, fixtureGraph, fixtureInventory, fixtureDepth) + if err != nil { + t.Fatalf("%s: %v", h.module, err) + } + for _, placeholder := range placeholders { + if strings.Contains(prompt, placeholder) { + t.Errorf("%s: rendered prompt still contains %s", h.module, placeholder) + } + } + // The two scalar substitutions must actually have landed. + if !strings.Contains(prompt, fixtureRepoPath) { + t.Errorf("%s: rendered prompt does not mention the repo path", h.module) + } + if !strings.Contains(prompt, "Depth profile: "+fixtureDepth) { + t.Errorf("%s: rendered prompt does not carry the depth", h.module) + } + } +} + +// The graph context is filtered per hunter, so each prompt must carry its own +// domain's resources and drop the others'. +func TestHunterPrompts_CarryTheirOwnDomainSlice(t *testing.T) { + cases := []struct { + module string + present []string + absent []string + }{ + {"iam_hunter", []string{"aws_iam_role.admin", "aws_iam_policy.broad"}, []string{"aws_vpc.main", "aws_cloudtrail.audit"}}, + {"network_hunter", []string{"aws_vpc.main", "aws_subnet.private"}, []string{"aws_iam_role.admin", "aws_kms_key.master"}}, + {"data_hunter", []string{"aws_s3_bucket.data", "aws_kms_key.master"}, []string{"aws_iam_role.admin", "aws_vpc.main"}}, + {"secrets_hunter", []string{"aws_secretsmanager_secret.db", "aws_kms_key.master", "aws_instance.web"}, []string{"aws_vpc.main"}}, + {"compute_hunter", []string{"aws_instance.web"}, []string{"aws_vpc.main", "aws_iam_role.admin"}}, + {"logging_hunter", []string{"aws_cloudtrail.audit"}, []string{"aws_iam_role.admin", "aws_s3_bucket.data"}}, + // The compliance hunter's [""] reduces to no keywords, so it sees the + // whole graph. + {"compliance_hunter", []string{ + "aws_iam_role.admin", "aws_vpc.main", "aws_s3_bucket.data", + "aws_instance.web", "aws_cloudtrail.audit", "aws_secretsmanager_secret.db", + }, nil}, + } + for _, tc := range cases { + prompt := readGolden(t, tc.module+"_prompt.txt") + for _, want := range tc.present { + if !strings.Contains(prompt, want) { + t.Errorf("%s: prompt is missing %s", tc.module, want) + } + } + for _, unwanted := range tc.absent { + if strings.Contains(prompt, unwanted) { + t.Errorf("%s: prompt unexpectedly contains %s", tc.module, unwanted) + } + } + } +} + +// --------------------------------------------------------------------------- +// Harness invocation +// --------------------------------------------------------------------------- + +// Python: app.harness(prompt=..., schema=HuntResult, cwd=str(Path(repo_path).resolve()), +// project_dir=repo_path). The captured Python kwargs live in +// testdata/golden/harness_options.json. +func TestHunters_HarnessOptionsMatchPython(t *testing.T) { + var want map[string]struct { + Schema string `json:"schema"` + Cwd string `json:"cwd"` + ProjectDir string `json:"project_dir"` + } + decodeGolden(t, "harness_options.json", &want) + + huntResultSchema := harnessx.SchemaFor[schemas.HuntResult]() + + for _, h := range allHunters { + app := fakeReturning(emptyHuntResult) + if _, err := h.run(context.Background(), app, fixtureRepoPath, fixtureGraph, fixtureInventory, fixtureDepth); err != nil { + t.Fatalf("%s: %v", h.module, err) + } + expected, ok := want[h.module] + if !ok { + t.Fatalf("%s: no captured Python options", h.module) + } + got := app.Harnesses[0] + if got.Opts.Cwd != expected.Cwd { + t.Errorf("%s: Cwd = %q, want %q", h.module, got.Opts.Cwd, expected.Cwd) + } + if got.Opts.ProjectDir != expected.ProjectDir { + t.Errorf("%s: ProjectDir = %q, want %q", h.module, got.Opts.ProjectDir, expected.ProjectDir) + } + if expected.Schema != "HuntResult" { + t.Fatalf("%s: Python passed schema=%s, expected HuntResult", h.module, expected.Schema) + } + if got.Schema == nil || got.Schema["title"] != huntResultSchema["title"] { + t.Errorf("%s: harness did not receive the HuntResult schema (%v)", h.module, got.Schema) + } + // Nothing else is set here — provider/model/max_turns come from the + // agent's default HarnessConfig, exactly as in Python. + if got.Opts.Provider != "" || got.Opts.Model != "" || got.Opts.MaxTurns != 0 { + t.Errorf("%s: unexpected per-call harness overrides: %+v", h.module, got.Opts) + } + } +} + +// Cwd is the RESOLVED repo path while ProjectDir is the raw argument; they +// differ whenever the caller passes something relative or symlinked. +func TestHunters_CwdIsResolvedButProjectDirIsNot(t *testing.T) { + dir := t.TempDir() + root, err := filepath.EvalSymlinks(dir) + if err != nil { + t.Fatalf("resolving the temp dir: %v", err) + } + real := filepath.Join(root, "repo") + if err := os.MkdirAll(real, 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + link := filepath.Join(root, "repo-link") + if err := os.Symlink("repo", link); err != nil { + t.Fatalf("symlink: %v", err) + } + + app := fakeReturning(emptyHuntResult) + if _, err := RunIAMHunter(context.Background(), app, link, fixtureGraph, fixtureInventory, fixtureDepth); err != nil { + t.Fatalf("RunIAMHunter: %v", err) + } + opts := app.Harnesses[0].Opts + if opts.Cwd != real { + t.Errorf("Cwd = %q, want the resolved %q", opts.Cwd, real) + } + if opts.ProjectDir != link { + t.Errorf("ProjectDir = %q, want the raw %q", opts.ProjectDir, link) + } +} + +// --------------------------------------------------------------------------- +// Result post-processing (the model_copy(update=...) block) +// --------------------------------------------------------------------------- + +// The captured Python return for an all-defaults model: counts stay 0 (0 or 0 +// is 0) and strategies_run is replaced by the hunter's own single label. +func TestHunters_EmptyResultMatchesPython(t *testing.T) { + var want map[string]json.RawMessage + decodeGolden(t, "empty_result.json", &want) + + for _, h := range allHunters { + app := fakeReturning(emptyHuntResult) + got, err := h.run(context.Background(), app, fixtureRepoPath, fixtureGraph, fixtureInventory, fixtureDepth) + if err != nil { + t.Fatalf("%s: %v", h.module, err) + } + gotJSON, err := json.Marshal(got) + if err != nil { + t.Fatalf("%s: marshal: %v", h.module, err) + } + var gotAny, wantAny any + if err := json.Unmarshal(gotJSON, &gotAny); err != nil { + t.Fatalf("%s: %v", h.module, err) + } + if err := json.Unmarshal(want[h.module], &wantAny); err != nil { + t.Fatalf("%s: %v", h.module, err) + } + if !jsonEqual(gotAny, wantAny) { + t.Errorf("%s: model_dump differs\n got: %s\nwant: %s", h.module, gotJSON, want[h.module]) + } + } +} + +// `parsed.total_raw or len(findings)` — a ZERO count is backfilled from the +// finding count; a non-zero one is left alone. +func TestHunters_ZeroCountsAreBackfilledFromTheFindings(t *testing.T) { + body := `{"findings": [` + rawFinding("f1") + `,` + rawFinding("f2") + `], + "total_raw": 0, "deduplicated_count": 0, "strategies_run": ["bogus"], + "hunt_duration_seconds": 1.5}` + app := fakeReturning(body) + got, err := RunIAMHunter(context.Background(), app, fixtureRepoPath, fixtureGraph, fixtureInventory, fixtureDepth) + if err != nil { + t.Fatalf("RunIAMHunter: %v", err) + } + if got.TotalRaw != 2 { + t.Errorf("TotalRaw = %d, want 2", got.TotalRaw) + } + if got.DeduplicatedCount != 2 { + t.Errorf("DeduplicatedCount = %d, want 2", got.DeduplicatedCount) + } + // strategies_run is REPLACED, never merged. + if len(got.StrategiesRun) != 1 || got.StrategiesRun[0] != "iam" { + t.Errorf("StrategiesRun = %v, want [iam]", got.StrategiesRun) + } + // Everything else survives the model_copy untouched. + if got.HuntDurationSeconds != 1.5 { + t.Errorf("HuntDurationSeconds = %v, want 1.5", got.HuntDurationSeconds) + } + if len(got.Findings) != 2 || got.Findings[0].ID != "f1" { + t.Errorf("findings were not preserved: %+v", got.Findings) + } +} + +func TestHunters_NonZeroCountsSurvive(t *testing.T) { + body := `{"findings": [` + rawFinding("f1") + `], "total_raw": 9, + "deduplicated_count": 7, "strategies_run": [], "hunt_duration_seconds": 0.0}` + app := fakeReturning(body) + got, err := RunNetworkHunter(context.Background(), app, fixtureRepoPath, fixtureGraph, fixtureInventory, fixtureDepth) + if err != nil { + t.Fatalf("RunNetworkHunter: %v", err) + } + if got.TotalRaw != 9 || got.DeduplicatedCount != 7 { + t.Errorf("counts = (%d, %d), want (9, 7)", got.TotalRaw, got.DeduplicatedCount) + } + if len(got.StrategiesRun) != 1 || got.StrategiesRun[0] != "network" { + t.Errorf("StrategiesRun = %v, want [network]", got.StrategiesRun) + } +} + +// Python parity: `x or y` only replaces FALSY values, so a negative count from +// a misbehaving model is truthy and is kept verbatim. +func TestHunters_NegativeCountsAreTruthyAndKept(t *testing.T) { + body := `{"findings": [` + rawFinding("f1") + `], "total_raw": -1, + "deduplicated_count": -2, "strategies_run": [], "hunt_duration_seconds": 0.0}` + app := fakeReturning(body) + got, err := RunDataHunter(context.Background(), app, fixtureRepoPath, fixtureGraph, fixtureInventory, fixtureDepth) + if err != nil { + t.Fatalf("RunDataHunter: %v", err) + } + if got.TotalRaw != -1 || got.DeduplicatedCount != -2 { + t.Errorf("counts = (%d, %d), want (-1, -2)", got.TotalRaw, got.DeduplicatedCount) + } +} + +// Each hunter stamps its OWN strategy label, and only that one. +func TestHunters_StrategyLabels(t *testing.T) { + for _, h := range allHunters { + app := fakeReturning(emptyHuntResult) + got, err := h.run(context.Background(), app, fixtureRepoPath, fixtureGraph, fixtureInventory, fixtureDepth) + if err != nil { + t.Fatalf("%s: %v", h.module, err) + } + if len(got.StrategiesRun) != 1 || got.StrategiesRun[0] != h.strategy { + t.Errorf("%s: StrategiesRun = %v, want [%s]", h.module, got.StrategiesRun, h.strategy) + } + } +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +// extract_harness_result raises RuntimeError(f"{agent_name} harness error: {msg}") +// and the agent_name is the PYTHON MODULE name, not the strategy. +func TestHunters_HarnessErrorCarriesTheAgentName(t *testing.T) { + for _, h := range allHunters { + app := &appx.Fake{ + HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return nil, errBoom{} + }), + } + _, err := h.run(context.Background(), app, fixtureRepoPath, fixtureGraph, fixtureInventory, fixtureDepth) + if err == nil { + t.Fatalf("%s: expected an error", h.module) + } + if want := h.module + " harness error: boom"; err.Error() != want { + t.Errorf("%s: error = %q, want %q", h.module, err.Error(), want) + } + } +} + +// A missing RECON artifact is NOT an error: build_graph_context_for_hunter +// swallows it and the hunter still runs, with an empty context. +func TestHunters_MissingReconArtifactsStillProduceAPrompt(t *testing.T) { + app := fakeReturning(emptyHuntResult) + got, err := RunComputeHunter(context.Background(), app, + fixtureRepoPath, "testdata/fixture/absent.json", "testdata/fixture/absent.json", fixtureDepth) + if err != nil { + t.Fatalf("RunComputeHunter: %v", err) + } + if len(got.StrategiesRun) != 1 || got.StrategiesRun[0] != "compute" { + t.Errorf("StrategiesRun = %v", got.StrategiesRun) + } + prompt := app.Harnesses[0].Prompt + for _, want := range []string{ + " - none matched this hunter domain", + " - no edges matched this hunter domain", + "Total resources: 0", + } { + if !strings.Contains(prompt, want) { + t.Errorf("prompt is missing %q", want) + } + } +} + +// --------------------------------------------------------------------------- +// Spec table +// --------------------------------------------------------------------------- + +// The four values that distinguish the seven Python files, asserted against the +// Python literals. Keeping them in one place is what lets hunt.go hold a single +// shared body without losing per-hunter fidelity. +func TestHunterSpecs(t *testing.T) { + for _, h := range allHunters { + if h.spec.promptPath != h.prompt { + t.Errorf("%s: promptPath = %q, want %q", h.module, h.spec.promptPath, h.prompt) + } + if h.spec.agentName != h.module { + t.Errorf("%s: agentName = %q, want %q", h.module, h.spec.agentName, h.module) + } + if h.spec.strategy != h.strategy { + t.Errorf("%s: strategy = %q, want %q", h.module, h.spec.strategy, h.strategy) + } + if len(h.spec.keywords) == 0 { + t.Errorf("%s: no domain keywords", h.module) + } + } + if len(allHunters) != 7 { + t.Errorf("expected 7 hunters, got %d", len(allHunters)) + } + // Every strategy label is a member of the HunterStrategy catalog in + // schemas/hunt.py. + for _, h := range allHunters { + if !schemas.HunterStrategy(h.strategy).Valid() { + t.Errorf("%s: %q is not a schemas.HunterStrategy", h.module, h.strategy) + } + } +} + +// The keyword lists are what make each hunter's graph slice different; a +// copy-paste slip between two files would otherwise go unnoticed. +func TestHunterKeywordsAreDistinct(t *testing.T) { + seen := map[string]string{} + for _, h := range allHunters { + key := strings.Join(h.spec.keywords, "|") + if other, dup := seen[key]; dup { + t.Errorf("%s and %s share a keyword list", other, h.module) + } + seen[key] = h.module + } + if got := strings.Join(complianceHunter.keywords, "|"); got != "" { + t.Errorf("compliance keywords = %q, want the single empty string", got) + } +} + +// --------------------------------------------------------------------------- + +type errBoom struct{} + +func (errBoom) Error() string { return "boom" } + +func rawFinding(id string) string { + return `{"id": "` + id + `", "hunter_strategy": "iam", "title": "t", "description": "d", + "category": "overprivilege", "resources": [], "estimated_severity": "high", + "confidence": "medium", "iac_file": "iam.tf", "iac_line": 1, + "config_snippet": "", "benchmark_id": null, "fingerprint": "fp-` + id + `"}` +} + +func readGolden(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "golden", name)) + if err != nil { + t.Fatalf("reading golden %s: %v (regenerate with go/scripts/gen_golden.py)", name, err) + } + return string(b) +} + +func decodeGolden(t *testing.T, name string, dest any) { + t.Helper() + if err := json.Unmarshal([]byte(readGolden(t, name)), dest); err != nil { + t.Fatalf("decoding golden %s: %v", name, err) + } +} + +func jsonEqual(a, b any) bool { + left, err1 := json.Marshal(a) + right, err2 := json.Marshal(b) + return err1 == nil && err2 == nil && string(left) == string(right) +} diff --git a/go/internal/agents/hunt/iam_hunter.go b/go/internal/agents/hunt/iam_hunter.go new file mode 100644 index 0000000..2270e74 --- /dev/null +++ b/go/internal/agents/hunt/iam_hunter.go @@ -0,0 +1,44 @@ +package hunt + +import ( + "context" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// iamHunter ports the four module-level values of +// src/cloudsecurity_af/agents/hunt/iam_hunter.py. +// +// Domain keywords, verbatim from the Python call: +// +// 'iam', 'role', 'policy', 'user', 'group', 'assume_role', +// 'trust', 'permission', 'mfa' +var iamHunter = hunter{ + promptPath: "hunt/iam.txt", + keywords: []string{ + "iam", + "role", + "policy", + "user", + "group", + "assume_role", + "trust", + "permission", + "mfa", + }, + agentName: "iam_hunter", + strategy: "iam", +} + +// RunIAMHunter ports run_iam_hunter in +// src/cloudsecurity_af/agents/hunt/iam_hunter.py. +// +// It is registered as the `run_iam_hunter` router reasoner (see +// internal/reasoners) and reached from hunt_phase through app.Call, with the +// same four kwargs: repo_path, resource_graph_path, inventory_path, depth. +// +// See hunter.run in hunt.go for the shared body and its parity notes. +func RunIAMHunter(ctx context.Context, app appx.Harnesser, repoPath, resourceGraphPath, inventoryPath, depth string) (schemas.HuntResult, error) { + return iamHunter.run(ctx, app, repoPath, resourceGraphPath, inventoryPath, depth) +} diff --git a/go/internal/agents/hunt/logging_hunter.go b/go/internal/agents/hunt/logging_hunter.go new file mode 100644 index 0000000..fe4d121 --- /dev/null +++ b/go/internal/agents/hunt/logging_hunter.go @@ -0,0 +1,42 @@ +package hunt + +import ( + "context" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// loggingHunter ports the four module-level values of +// src/cloudsecurity_af/agents/hunt/logging_hunter.py. +// +// Domain keywords, verbatim from the Python call: +// +// 'cloudtrail', 'flow_log', 'guardduty', 'cloudwatch', +// 'log_group', 'access_log', 'waf_log' +var loggingHunter = hunter{ + promptPath: "hunt/logging.txt", + keywords: []string{ + "cloudtrail", + "flow_log", + "guardduty", + "cloudwatch", + "log_group", + "access_log", + "waf_log", + }, + agentName: "logging_hunter", + strategy: "logging", +} + +// RunLoggingHunter ports run_logging_hunter in +// src/cloudsecurity_af/agents/hunt/logging_hunter.py. +// +// It is registered as the `run_logging_hunter` router reasoner (see +// internal/reasoners) and reached from hunt_phase through app.Call, with the +// same four kwargs: repo_path, resource_graph_path, inventory_path, depth. +// +// See hunter.run in hunt.go for the shared body and its parity notes. +func RunLoggingHunter(ctx context.Context, app appx.Harnesser, repoPath, resourceGraphPath, inventoryPath, depth string) (schemas.HuntResult, error) { + return loggingHunter.run(ctx, app, repoPath, resourceGraphPath, inventoryPath, depth) +} diff --git a/go/internal/agents/hunt/network_hunter.go b/go/internal/agents/hunt/network_hunter.go new file mode 100644 index 0000000..a652f01 --- /dev/null +++ b/go/internal/agents/hunt/network_hunter.go @@ -0,0 +1,56 @@ +package hunt + +import ( + "context" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// networkHunter ports the four module-level values of +// src/cloudsecurity_af/agents/hunt/network_hunter.py. +// +// Domain keywords, verbatim from the Python call: +// +// 'vpc', 'subnet', 'security_group', 'nacl', 'route', 'peering', +// 'endpoint', 'load_balancer', 'elb', 'alb', 'nlb', 'firewall', +// 'gateway', 'igw', 'nat', 'network_interface', 'eni', +// 'flow_log', 'lb' +var networkHunter = hunter{ + promptPath: "hunt/network.txt", + keywords: []string{ + "vpc", + "subnet", + "security_group", + "nacl", + "route", + "peering", + "endpoint", + "load_balancer", + "elb", + "alb", + "nlb", + "firewall", + "gateway", + "igw", + "nat", + "network_interface", + "eni", + "flow_log", + "lb", + }, + agentName: "network_hunter", + strategy: "network", +} + +// RunNetworkHunter ports run_network_hunter in +// src/cloudsecurity_af/agents/hunt/network_hunter.py. +// +// It is registered as the `run_network_hunter` router reasoner (see +// internal/reasoners) and reached from hunt_phase through app.Call, with the +// same four kwargs: repo_path, resource_graph_path, inventory_path, depth. +// +// See hunter.run in hunt.go for the shared body and its parity notes. +func RunNetworkHunter(ctx context.Context, app appx.Harnesser, repoPath, resourceGraphPath, inventoryPath, depth string) (schemas.HuntResult, error) { + return networkHunter.run(ctx, app, repoPath, resourceGraphPath, inventoryPath, depth) +} diff --git a/go/internal/agents/hunt/secrets_hunter.go b/go/internal/agents/hunt/secrets_hunter.go new file mode 100644 index 0000000..35da52d --- /dev/null +++ b/go/internal/agents/hunt/secrets_hunter.go @@ -0,0 +1,49 @@ +package hunt + +import ( + "context" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// secretsHunter ports the four module-level values of +// src/cloudsecurity_af/agents/hunt/secrets_hunter.py. +// +// Domain keywords, verbatim from the Python call: +// +// 'secret', 'kms', 'ssm', 'parameter_store', 'credential', 'key', +// 'certificate', 'access_key', 'password', 'lambda', 'instance', +// 'db_instance', 'provider' +var secretsHunter = hunter{ + promptPath: "hunt/secrets.txt", + keywords: []string{ + "secret", + "kms", + "ssm", + "parameter_store", + "credential", + "key", + "certificate", + "access_key", + "password", + "lambda", + "instance", + "db_instance", + "provider", + }, + agentName: "secrets_hunter", + strategy: "secrets", +} + +// RunSecretsHunter ports run_secrets_hunter in +// src/cloudsecurity_af/agents/hunt/secrets_hunter.py. +// +// It is registered as the `run_secrets_hunter` router reasoner (see +// internal/reasoners) and reached from hunt_phase through app.Call, with the +// same four kwargs: repo_path, resource_graph_path, inventory_path, depth. +// +// See hunter.run in hunt.go for the shared body and its parity notes. +func RunSecretsHunter(ctx context.Context, app appx.Harnesser, repoPath, resourceGraphPath, inventoryPath, depth string) (schemas.HuntResult, error) { + return secretsHunter.run(ctx, app, repoPath, resourceGraphPath, inventoryPath, depth) +} diff --git a/go/internal/agents/hunt/testdata/fixture/graph.json b/go/internal/agents/hunt/testdata/fixture/graph.json new file mode 100644 index 0000000..edc3a0c --- /dev/null +++ b/go/internal/agents/hunt/testdata/fixture/graph.json @@ -0,0 +1,148 @@ +{ + "nodes": [ + { + "resource_id": "aws_iam_role.admin", + "resource_type": "aws_iam_role", + "provider": "aws", + "file_path": "iam.tf", + "config_summary": { + "name": "admin", + "assume_role_policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "*" + } + } + ] + }, + "managed_policy_arns": [ + "arn:aws:iam::aws:policy/AdministratorAccess" + ] + } + }, + { + "resource_id": "aws_iam_policy.broad", + "resource_type": "aws_iam_policy", + "provider": "aws", + "file_path": "iam.tf", + "config_summary": { + "policy": "{\"Statement\":[{\"Action\":\"*\",\"Resource\":\"*\"}]}", + "description": null + } + }, + { + "resource_id": "aws_s3_bucket.data", + "resource_type": "aws_s3_bucket", + "provider": "aws", + "file_path": "storage.tf", + "config_summary": { + "acl": "public-read", + "versioning": false, + "tags": { + "owner": "data-team", + "env": "prod" + } + } + }, + { + "resource_id": "aws_vpc.main", + "resource_type": "aws_vpc", + "provider": "aws", + "file_path": "network.tf", + "config_summary": { + "cidr_block": "10.0.0.0/16", + "enable_dns_hostnames": true + } + }, + { + "resource_id": "aws_subnet.private", + "resource_type": "aws_subnet", + "provider": "aws", + "file_path": "network.tf", + "config_summary": { + "cidr_block": "10.0.1.0/24", + "map_public_ip_on_launch": false + } + }, + { + "resource_id": "aws_instance.web", + "resource_type": "aws_instance", + "provider": "aws", + "file_path": "compute.tf", + "config_summary": { + "instance_type": "t3.micro", + "associate_public_ip_address": true, + "metadata_options": { + "http_tokens": "optional" + } + } + }, + { + "resource_id": "aws_kms_key.master", + "resource_type": "aws_kms_key", + "provider": "aws", + "file_path": "security.tf", + "config_summary": { + "enable_key_rotation": false, + "deletion_window_in_days": 7, + "rotation_period_days": 90.0, + "description": "clé principale" + } + }, + { + "resource_id": "aws_cloudtrail.audit", + "resource_type": "aws_cloudtrail", + "provider": "aws", + "file_path": "logging.tf", + "config_summary": { + "is_multi_region_trail": false, + "enable_log_file_validation": false + } + }, + { + "resource_id": "aws_secretsmanager_secret.db", + "resource_type": "aws_secretsmanager_secret", + "provider": "aws", + "file_path": "secrets.tf", + "config_summary": { + "recovery_window_in_days": 0, + "rotation_rules": null + } + } + ], + "edges": [ + { + "source": "aws_iam_role.admin", + "target": "aws_iam_policy.broad", + "type": "policy_attachment", + "description": "Admin role has the broad policy attached" + }, + { + "source": "aws_vpc.main", + "target": "aws_subnet.private", + "type": "contains" + }, + { + "source": "aws_s3_bucket.data", + "target": "aws_kms_key.master", + "type": "encrypted_by", + "description": "" + }, + { + "source": "aws_instance.web", + "target": "aws_kms_key.master" + } + ], + "clusters": [ + { + "name": "identity/root", + "members": [ + "aws_iam_role.admin", + "aws_iam_policy.broad" + ] + } + ] +} diff --git a/go/internal/agents/hunt/testdata/fixture/inventory.json b/go/internal/agents/hunt/testdata/fixture/inventory.json new file mode 100644 index 0000000..ae6f551 --- /dev/null +++ b/go/internal/agents/hunt/testdata/fixture/inventory.json @@ -0,0 +1,142 @@ +{ + "resources": [ + { + "id": "aws_iam_role.admin", + "type": "aws_iam_role", + "name": "admin", + "provider": "aws", + "file_path": "iam.tf", + "line_number": 0 + }, + { + "id": "aws_iam_policy.broad", + "type": "aws_iam_policy", + "name": "broad", + "provider": "aws", + "file_path": "iam.tf", + "line_number": 0 + }, + { + "id": "aws_s3_bucket.data", + "type": "aws_s3_bucket", + "name": "data", + "provider": "aws", + "file_path": "storage.tf", + "line_number": 0 + }, + { + "id": "aws_vpc.main", + "type": "aws_vpc", + "name": "main", + "provider": "aws", + "file_path": "network.tf", + "line_number": 0 + }, + { + "id": "aws_subnet.private", + "type": "aws_subnet", + "name": "private", + "provider": "aws", + "file_path": "network.tf", + "line_number": 0 + }, + { + "id": "aws_instance.web", + "type": "aws_instance", + "name": "web", + "provider": "aws", + "file_path": "compute.tf", + "line_number": 0 + }, + { + "id": "aws_kms_key.master", + "type": "aws_kms_key", + "name": "master", + "provider": "aws", + "file_path": "security.tf", + "line_number": 0 + }, + { + "id": "aws_cloudtrail.audit", + "type": "aws_cloudtrail", + "name": "audit", + "provider": "aws", + "file_path": "logging.tf", + "line_number": 0 + }, + { + "id": "aws_secretsmanager_secret.db", + "type": "aws_secretsmanager_secret", + "name": "db", + "provider": "aws", + "file_path": "secrets.tf", + "line_number": 0 + }, + { + "id": "google_storage_bucket.exports", + "type": "google_storage_bucket", + "name": "exports", + "provider": "google", + "file_path": "gcp.tf", + "line_number": 0 + }, + { + "id": "local_file.notes", + "type": "local_file", + "name": "notes", + "provider": "", + "file_path": "misc.tf", + "line_number": 0 + }, + { + "id": "null_resource.bootstrap", + "type": "null_resource", + "name": "bootstrap", + "provider": null, + "file_path": "misc.tf", + "line_number": 0 + } + ], + "variables": [ + { + "name": "region", + "type": "string", + "default": "us-east-1" + }, + { + "name": "environment", + "type": "string", + "default": "prod" + } + ], + "outputs": [ + { + "name": "bucket_arn", + "value": "aws_s3_bucket.data.arn" + } + ], + "providers": [ + { + "name": "aws", + "region": "us-east-1", + "alias": null, + "version": null + }, + { + "name": "google", + "region": "us-central1", + "alias": null, + "version": null + } + ], + "modules": [ + { + "name": "vpc", + "source": "./modules/vpc" + }, + { + "name": "logging", + "source": "./modules/logging" + } + ] +} diff --git a/go/internal/agents/hunt/testdata/fixture/not_an_object.json b/go/internal/agents/hunt/testdata/fixture/not_an_object.json new file mode 100644 index 0000000..da812d2 --- /dev/null +++ b/go/internal/agents/hunt/testdata/fixture/not_an_object.json @@ -0,0 +1 @@ +["not", "an", "object"] diff --git a/go/internal/agents/hunt/testdata/golden/compliance_hunter_prompt.txt b/go/internal/agents/hunt/testdata/golden/compliance_hunter_prompt.txt new file mode 100644 index 0000000..68f8baa --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/compliance_hunter_prompt.txt @@ -0,0 +1,102 @@ +ROLE: +You are a principal cloud compliance security engineer specializing in control interpretation, technical evidence mapping, and graph-aware control gap analysis for CloudSecurity AF HUNT phase. + +CONTEXT: +- Repository path: /fixture/repo +- Depth profile: standard + +RESOURCE GRAPH CONTEXT: +The following resources and relationships are relevant to your domain: + +RELEVANT RESOURCES: + - aws_iam_role.admin (aws_iam_role) @ iam.tf + Config: {'name': 'admin', 'assume_role_policy': {'Version': '2012-10-17', 'Statement': [{'Effect': 'Allow', 'Principal': {'AWS': '*'}}]}, 'managed_policy_arns': ['arn:aws:iam::aws:policy/AdministratorAccess']} + - aws_iam_policy.broad (aws_iam_policy) @ iam.tf + Config: {'policy': '{"Statement":[{"Action":"*","Resource":"*"}]}', 'description': None} + - aws_s3_bucket.data (aws_s3_bucket) @ storage.tf + Config: {'acl': 'public-read', 'versioning': False, 'tags': {'owner': 'data-team', 'env': 'prod'}} + - aws_vpc.main (aws_vpc) @ network.tf + Config: {'cidr_block': '10.0.0.0/16', 'enable_dns_hostnames': True} + - aws_subnet.private (aws_subnet) @ network.tf + Config: {'cidr_block': '10.0.1.0/24', 'map_public_ip_on_launch': False} + - aws_instance.web (aws_instance) @ compute.tf + Config: {'instance_type': 't3.micro', 'associate_public_ip_address': True, 'metadata_options': {'http_tokens': 'optional'}} + - aws_kms_key.master (aws_kms_key) @ security.tf + Config: {'enable_key_rotation': False, 'deletion_window_in_days': 7, 'rotation_period_days': 90.0, 'description': 'clé principale'} + - aws_cloudtrail.audit (aws_cloudtrail) @ logging.tf + Config: {'is_multi_region_trail': False, 'enable_log_file_validation': False} + - aws_secretsmanager_secret.db (aws_secretsmanager_secret) @ secrets.tf + Config: {'recovery_window_in_days': 0, 'rotation_rules': None} + +INFRASTRUCTURE STATISTICS: +Total resources: 12 +Providers: aws, google +Modules: 2 +Variables: 2 +Outputs: 1 +Graph nodes: 9 +Graph edges: 4 +Filtered nodes: 9 +Filtered edges: 4 + +CONNECTED RELATIONSHIPS: +RELEVANT RELATIONSHIPS: + - aws_iam_role.admin --[policy_attachment]--> aws_iam_policy.broad + Admin role has the broad policy attached + - aws_vpc.main --[contains]--> aws_subnet.private + - aws_s3_bucket.data --[encrypted_by]--> aws_kms_key.master + - aws_instance.web --[references]--> aws_kms_key.master + +TASK: +Read repository IaC and evaluate compliance control gaps using full graph coverage across IAM, network, data, compute, and logging domains. + +GRAPH-AWARE ANALYSIS INSTRUCTIONS: +1. Use the full graph scope and do not limit analysis to a single domain slice. +2. Map each control requirement to concrete nodes and edges that satisfy or violate control intent. +3. Trace transitive dependencies to detect inherited or bypassed controls across connected resources. +4. Validate that compensating controls are technically connected to the at-risk nodes. +5. Prioritize gaps where graph topology amplifies business impact across multiple control families. + +SECURITY REASONING METHODOLOGY: +You are not checking boxes against a compliance framework. You are reasoning about regulatory and security posture the way a principal compliance architect would — asking "if an auditor examined this infrastructure today, what control gaps would they find, and do those gaps represent genuine security risk or just documentation gaps?" + +For the entire infrastructure, apply this reasoning process: + +Step 1 — UNDERSTAND CONTROL INTENT: For each compliance control (CIS, SOC2, PCI-DSS), understand what the control is trying to PROTECT against, not just what it requires. A control that says "enable encryption at rest" is really asking "can an attacker who gains physical or logical access to storage media read sensitive data?" Map each control to the actual threat it mitigates. + +Step 2 — MAP CONTROLS TO RESOURCES: For each control requirement, identify which specific resources in the infrastructure graph are relevant. Then verify whether those resources have the technical configuration that satisfies the control's intent. A control is only satisfied when the technical implementation actually prevents the threat the control addresses. + +Step 3 — IDENTIFY ABSENT CONTROLS: The most important compliance findings are controls that are entirely missing — not misconfigured, but simply not present. Walk through the major control families (identity, network, data, logging, resilience) and identify where the infrastructure has NO technical implementation of a required control. + +Step 4 — ASSESS CASCADING FAILURES: Some missing controls affect multiple compliance frameworks simultaneously. A single gap (like missing audit logging) can cascade across CIS, SOC2, and PCI-DSS at the same time. Identify these high-impact gaps that create the largest compliance surface area. + +Step 5 — DISTINGUISH RISK FROM CHECKBOX: Every compliance finding should reflect a genuine security risk, not just a gap in documentation or policy. If a control is missing but the threat it addresses cannot materialize in this specific infrastructure, note it as informational rather than critical. + +Your domain spans all compliance frameworks and all resource types. You use the full graph to assess whether the infrastructure's security posture meets the intent of regulatory controls across identity, network, data protection, logging, and operational resilience. + +IMPORTANT: Compliance analysis requires examining the ENTIRE infrastructure, not just resources that look like they belong to a specific control family. A compliance gap might be a missing resource type (no CloudTrail), a missing attribute on an existing resource (no encryption), or a systemic pattern (no resource in the entire infrastructure has logging enabled). + +WORKFLOW: +1. Use Bash to identify ALL IaC files related to identity, network, data stores, compute runtime, and telemetry. +2. Use Read to extract concrete evidence for control evaluation, including policy docs and module defaults. +3. Build control-to-resource mapping tables mentally from graph nodes and edges before asserting non-compliance. +4. Validate each compliance finding with exact evidence lines and explicit control IDs. +5. Use Write to emit strict HuntResult JSON only. + +FINDING QUALITY REQUIREMENTS: +- Every finding must cite specific graph nodes and edges showing why control intent fails. +- benchmark_id must contain the most precise control identifier available. +- Explain compliance impact and security impact separately when both apply. +- Avoid generic checklist language; findings must be technically actionable. + +OUTPUT: +- Return strict JSON matching HuntResult with no markdown or wrapper text. +- Set hunter_strategy to "compliance" on every RawFinding. +- Use valid FindingCategory values only. +- Populate title, description, iac_file, iac_line, config_snippet, benchmark_id, estimated_severity, and confidence. +- Keep total_raw, deduplicated_count, strategies_run, and hunt_duration_seconds coherent. + +CONSTRAINTS: +- Base findings only on validated IaC and graph evidence. +- Do not use regex-only pattern matching as final logic. +- Do not invent placeholders beyond those defined in this template. diff --git a/go/internal/agents/hunt/testdata/golden/compute_hunter_prompt.txt b/go/internal/agents/hunt/testdata/golden/compute_hunter_prompt.txt new file mode 100644 index 0000000..218ab1a --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/compute_hunter_prompt.txt @@ -0,0 +1,88 @@ +ROLE: +You are a senior cloud compute security engineer specializing in workload isolation, runtime hardening, and identity-to-execution attack path analysis for CloudSecurity AF HUNT phase. + +CONTEXT: +- Repository path: /fixture/repo +- Depth profile: standard + +RESOURCE GRAPH CONTEXT: +The following resources and relationships are relevant to your domain: + +RELEVANT RESOURCES: + - aws_instance.web (aws_instance) @ compute.tf + Config: {'instance_type': 't3.micro', 'associate_public_ip_address': True, 'metadata_options': {'http_tokens': 'optional'}} + - aws_secretsmanager_secret.db (aws_secretsmanager_secret) @ secrets.tf + Config: {'recovery_window_in_days': 0, 'rotation_rules': None} + +CONNECTED RESOURCES (1-hop neighbors): + - aws_kms_key.master (aws_kms_key) @ security.tf + Config: {'enable_key_rotation': False, 'deletion_window_in_days': 7, 'rotation_period_days': 90.0, 'description': 'clé principale'} + +INFRASTRUCTURE STATISTICS: +Total resources: 12 +Providers: aws, google +Modules: 2 +Variables: 2 +Outputs: 1 +Graph nodes: 9 +Graph edges: 4 +Filtered nodes: 2 +Filtered edges: 1 + +CONNECTED RELATIONSHIPS: +RELEVANT RELATIONSHIPS: + - aws_instance.web --[references]--> aws_kms_key.master + +TASK: +Read repository IaC and identify compute findings by combining runtime posture checks with graph-based privilege and reachability analysis. + +GRAPH-AWARE ANALYSIS INSTRUCTIONS: +1. Follow execution edges to map which principals can run code on which compute resources. +2. Trace trust and data_access edges from compute nodes to identify post-compromise blast radius. +3. Correlate network_path edges with compute placement to separate isolated from internet-reachable workloads. +4. Prioritize findings where weak runtime controls combine with high-privilege execution identities. +5. Use multi-hop graph paths to detect transitive privilege escalation beyond single-resource misconfigurations. + +SECURITY REASONING METHODOLOGY: +You are not checking compute resources against a hardening checklist. You are reasoning about workload security the way a principal platform security architect would — asking "if an attacker gained code execution on this workload, what could they do next, and what stops them?" + +For every compute-related resource you encounter, apply this reasoning process: + +Step 1 — ENUMERATE: Read every attribute of the resource configuration. For compute resources, configuration spans runtime settings, identity bindings, network placement, storage attachments, bootstrap scripts, container settings, and orchestration controls. Each attribute affects a different aspect of the workload's security posture. + +Step 2 — INFER DEFAULTS: For attributes that are NOT present, determine the cloud provider's default behavior. Compute resources have many security features that must be explicitly enabled — metadata service hardening, volume encryption, image scanning, audit logging, secrets encryption, and more. Every missing security attribute is a potential finding because the default is typically the less secure option. + +Step 3 — ASSESS POST-COMPROMISE IMPACT: For each compute resource, reason about what an attacker could do after gaining code execution. Trace the workload's identity (role, instance profile) through the graph to understand what data, services, and other identities it can reach. Trace its network position to understand what it can communicate with. The severity of a compute misconfiguration is proportional to what becomes reachable after compromise. + +Step 4 — EVALUATE SUPPLY CHAIN: For container-based workloads, reason about the image supply chain. Can images be tampered with? Are images scanned for vulnerabilities? Is there provenance verification? Can an attacker push a malicious image that gets deployed automatically? The integrity of the software running on compute resources is as important as the configuration of the resources themselves. + +Step 5 — ASSESS COMPLETENESS: After analyzing individual resources, step back and ask: are there systemic compute security gaps? Sometimes the finding isn't a single misconfigured instance — it's the absence of a security baseline across all compute resources (no metadata hardening anywhere, no volume encryption standard, no container scanning strategy, no audit logging on any orchestration platform). + +Your domain covers everything related to code execution, workload identity, runtime configuration, container orchestration, image management, and compute-attached storage. This includes but is not limited to: EC2 instances, Lambda functions, ECS tasks and services, EKS clusters and node groups, ECR repositories, auto-scaling groups, launch templates, batch jobs, and any resource that runs code or hosts containers. + +IMPORTANT: Do not limit your analysis to resource types you expect to find. Examine every compute resource provided in the graph context across every attribute. A single compute resource can have findings across runtime hardening, identity, network, storage, and supply chain dimensions simultaneously. + +WORKFLOW: +1. Use Bash to identify ALL compute IaC artifacts across the repository. +2. Use Read to inspect every attribute of each compute resource. Pay special attention to attributes that are ABSENT — missing security configurations are findings. +3. Build graph-based attack paths from exposed compute to privileged identities and sensitive targets. +4. Validate each candidate finding against exact IaC evidence and remove assumptions unsupported by config. +5. Use Write to emit strict HuntResult JSON only. + +FINDING QUALITY REQUIREMENTS: +- Every finding must reference specific compute nodes and connected trust/execution/network/data_access edges. +- Explain exploitability as an attack sequence, not a single static misconfiguration statement. +- Include whether impact is lateral movement, privilege escalation, data compromise, or persistence risk. +- Include benchmark_id when a CIS mapping is applicable. + +OUTPUT: +- Return strict JSON matching HuntResult with no markdown or wrapper text. +- Set hunter_strategy to "compute" on every RawFinding. +- Use valid FindingCategory values only. +- Populate title, description, iac_file, iac_line, config_snippet, benchmark_id, estimated_severity, and confidence. +- Keep total_raw, deduplicated_count, strategies_run, and hunt_duration_seconds coherent. + +CONSTRAINTS: +- Base findings only on validated IaC and graph evidence. +- Do not use regex-only pattern matching as final logic. +- Do not invent placeholders beyond those defined in this template. diff --git a/go/internal/agents/hunt/testdata/golden/data_hunter_prompt.txt b/go/internal/agents/hunt/testdata/golden/data_hunter_prompt.txt new file mode 100644 index 0000000..a77f6b3 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/data_hunter_prompt.txt @@ -0,0 +1,89 @@ +ROLE: +You are a senior cloud data security engineer specializing in storage protection, key management integration, and graph-based data access risk analysis for CloudSecurity AF HUNT phase. + +CONTEXT: +- Repository path: /fixture/repo +- Depth profile: standard + +RESOURCE GRAPH CONTEXT: +The following resources and relationships are relevant to your domain: + +RELEVANT RESOURCES: + - aws_s3_bucket.data (aws_s3_bucket) @ storage.tf + Config: {'acl': 'public-read', 'versioning': False, 'tags': {'owner': 'data-team', 'env': 'prod'}} + - aws_kms_key.master (aws_kms_key) @ security.tf + Config: {'enable_key_rotation': False, 'deletion_window_in_days': 7, 'rotation_period_days': 90.0, 'description': 'clé principale'} + +CONNECTED RESOURCES (1-hop neighbors): + - aws_instance.web (aws_instance) @ compute.tf + Config: {'instance_type': 't3.micro', 'associate_public_ip_address': True, 'metadata_options': {'http_tokens': 'optional'}} + +INFRASTRUCTURE STATISTICS: +Total resources: 12 +Providers: aws, google +Modules: 2 +Variables: 2 +Outputs: 1 +Graph nodes: 9 +Graph edges: 4 +Filtered nodes: 2 +Filtered edges: 2 + +CONNECTED RELATIONSHIPS: +RELEVANT RELATIONSHIPS: + - aws_s3_bucket.data --[encrypted_by]--> aws_kms_key.master + - aws_instance.web --[references]--> aws_kms_key.master + +TASK: +Read repository IaC and identify data protection findings by combining storage configuration review with graph-based access path analysis. + +GRAPH-AWARE ANALYSIS INSTRUCTIONS: +1. Trace data_access edges to identify which identities or workloads can read/write sensitive data stores. +2. Correlate execution and trust edges with data_access edges to reveal transitive data exposure paths. +3. Evaluate encryption controls in context of key policy relationships and key usage dependencies. +4. Distinguish public exposure from authenticated over-broad access and cross-account data sharing risk. +5. Prioritize findings where compromise of one node enables broad downstream data impact. + +SECURITY REASONING METHODOLOGY: +You are not checking a list. You are reasoning about data security the way a principal security engineer would during a design review. Your goal is to find every way data can be exposed, lost, corrupted, or accessed by unauthorized parties — including ways that no checklist would anticipate. + +For every data-related resource you encounter, apply this reasoning process: + +Step 1 — ENUMERATE: Read every attribute of the resource configuration. For each attribute, understand what it controls and what security property it affects. + +Step 2 — INFER DEFAULTS: For attributes that are NOT present in the configuration, determine what the cloud provider's default behavior is. In cloud infrastructure, the most dangerous misconfigurations are often things that are ABSENT — security features that exist but were never enabled. If a security-relevant attribute is missing, the resource is likely using an insecure default. + +Step 3 — ASSESS SECURITY POSTURE: For each resource, reason about what an ideally-secured version of this resource would look like given its role in the infrastructure. Compare the actual configuration against that ideal. Every gap between actual and ideal is a potential finding. + +Step 4 — TRACE IMPACT: Use the resource graph to understand what happens if this resource is compromised. Follow edges to connected resources. A misconfiguration on an isolated resource is less severe than the same misconfiguration on a resource connected to sensitive data stores or privileged identities. + +Step 5 — ASSESS COMPLETENESS: After analyzing individual resources, step back and ask: are there entire categories of data protection that are missing from this infrastructure? Sometimes the finding isn't a misconfigured resource — it's the complete absence of a protection mechanism (no encryption strategy, no backup strategy, no access logging anywhere, no key rotation policy). + +Your domain covers everything related to data at rest, data in transit, data lifecycle, data access control, encryption key management, backup and recovery, and data observability. This includes but is not limited to: object stores, databases, volumes, snapshots, caches, queues, streams, search indices, data warehouses, and any resource that stores, processes, or transmits data. + +IMPORTANT: Do not limit your analysis to resource types you expect to find. Examine every resource provided in the graph context and determine whether it has data security implications. A resource that stores data — even temporarily — is in your domain. + +WORKFLOW: +1. Use Bash to locate ALL data-related IaC definitions across the repository. +2. Use Read to inspect every attribute of each data resource. Pay special attention to attributes that are ABSENT — missing security configurations are findings. +3. Build graph-based access paths showing identity/workload to data-store relationships. +4. Re-validate each finding with exact file and line evidence before severity assignment. +5. Use Write to emit strict HuntResult JSON only. + +FINDING QUALITY REQUIREMENTS: +- Each finding must reference specific data-store nodes and connected access/trust/execution edges. +- Include concrete confidentiality and integrity impact, not only control absence statements. +- Explain whether the risk is immediate exposure, conditional exposure, or resilience/retention gap. +- Include benchmark_id mapping when control alignment is clear. + +OUTPUT: +- Return strict JSON matching HuntResult with no markdown or wrapper text. +- Set hunter_strategy to "data" on every RawFinding. +- Use valid FindingCategory values only. +- Populate title, description, iac_file, iac_line, config_snippet, benchmark_id, estimated_severity, and confidence. +- Keep total_raw, deduplicated_count, strategies_run, and hunt_duration_seconds coherent. + +CONSTRAINTS: +- Base findings only on validated IaC and graph evidence. +- Avoid regex-only pattern matching as final logic. +- Do not invent placeholders beyond those defined in this template. diff --git a/go/internal/agents/hunt/testdata/golden/empty_result.json b/go/internal/agents/hunt/testdata/golden/empty_result.json new file mode 100644 index 0000000..c2cbea8 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/empty_result.json @@ -0,0 +1,65 @@ +{ + "iam_hunter": { + "findings": [], + "total_raw": 0, + "deduplicated_count": 0, + "strategies_run": [ + "iam" + ], + "hunt_duration_seconds": 0.0 + }, + "network_hunter": { + "findings": [], + "total_raw": 0, + "deduplicated_count": 0, + "strategies_run": [ + "network" + ], + "hunt_duration_seconds": 0.0 + }, + "data_hunter": { + "findings": [], + "total_raw": 0, + "deduplicated_count": 0, + "strategies_run": [ + "data" + ], + "hunt_duration_seconds": 0.0 + }, + "secrets_hunter": { + "findings": [], + "total_raw": 0, + "deduplicated_count": 0, + "strategies_run": [ + "secrets" + ], + "hunt_duration_seconds": 0.0 + }, + "compute_hunter": { + "findings": [], + "total_raw": 0, + "deduplicated_count": 0, + "strategies_run": [ + "compute" + ], + "hunt_duration_seconds": 0.0 + }, + "logging_hunter": { + "findings": [], + "total_raw": 0, + "deduplicated_count": 0, + "strategies_run": [ + "logging" + ], + "hunt_duration_seconds": 0.0 + }, + "compliance_hunter": { + "findings": [], + "total_raw": 0, + "deduplicated_count": 0, + "strategies_run": [ + "compliance" + ], + "hunt_duration_seconds": 0.0 + } +} diff --git a/go/internal/agents/hunt/testdata/golden/harness_options.json b/go/internal/agents/hunt/testdata/golden/harness_options.json new file mode 100644 index 0000000..8ee51a7 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/harness_options.json @@ -0,0 +1,37 @@ +{ + "iam_hunter": { + "schema": "HuntResult", + "cwd": "/fixture/repo", + "project_dir": "/fixture/repo" + }, + "network_hunter": { + "schema": "HuntResult", + "cwd": "/fixture/repo", + "project_dir": "/fixture/repo" + }, + "data_hunter": { + "schema": "HuntResult", + "cwd": "/fixture/repo", + "project_dir": "/fixture/repo" + }, + "secrets_hunter": { + "schema": "HuntResult", + "cwd": "/fixture/repo", + "project_dir": "/fixture/repo" + }, + "compute_hunter": { + "schema": "HuntResult", + "cwd": "/fixture/repo", + "project_dir": "/fixture/repo" + }, + "logging_hunter": { + "schema": "HuntResult", + "cwd": "/fixture/repo", + "project_dir": "/fixture/repo" + }, + "compliance_hunter": { + "schema": "HuntResult", + "cwd": "/fixture/repo", + "project_dir": "/fixture/repo" + } +} diff --git a/go/internal/agents/hunt/testdata/golden/iam_hunter_prompt.txt b/go/internal/agents/hunt/testdata/golden/iam_hunter_prompt.txt new file mode 100644 index 0000000..ffa4c5d --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/iam_hunter_prompt.txt @@ -0,0 +1,85 @@ +ROLE: +You are a senior cloud security engineer specializing in AWS IAM architecture, delegated administration, and identity-based attack path analysis for CloudSecurity AF HUNT phase. + +CONTEXT: +- Repository path: /fixture/repo +- Depth profile: standard + +RESOURCE GRAPH CONTEXT: +The following resources and relationships are relevant to your domain: + +RELEVANT RESOURCES: + - aws_iam_role.admin (aws_iam_role) @ iam.tf + Config: {'name': 'admin', 'assume_role_policy': {'Version': '2012-10-17', 'Statement': [{'Effect': 'Allow', 'Principal': {'AWS': '*'}}]}, 'managed_policy_arns': ['arn:aws:iam::aws:policy/AdministratorAccess']} + - aws_iam_policy.broad (aws_iam_policy) @ iam.tf + Config: {'policy': '{"Statement":[{"Action":"*","Resource":"*"}]}', 'description': None} + +INFRASTRUCTURE STATISTICS: +Total resources: 12 +Providers: aws, google +Modules: 2 +Variables: 2 +Outputs: 1 +Graph nodes: 9 +Graph edges: 4 +Filtered nodes: 2 +Filtered edges: 1 + +CONNECTED RELATIONSHIPS: +RELEVANT RELATIONSHIPS: + - aws_iam_role.admin --[policy_attachment]--> aws_iam_policy.broad + Admin role has the broad policy attached + +TASK: +Read repository IaC and hunt IAM findings using graph-aware privilege path reasoning, not isolated resource checks. + +GRAPH-AWARE ANALYSIS INSTRUCTIONS: +1. Trace trust chains through the resource graph edges to identify transitive privilege escalation. +2. Follow execution edges from compute principals to roles and downstream data/network access. +3. Use trust edge directionality explicitly; A trusts B does not imply B trusts A. +4. Correlate wildcard permissions with node config to verify whether resource-level scope or condition keys materially constrain access. +5. Prioritize findings that create shortest paths from low-trust principals to high-impact resources. + +SECURITY REASONING METHODOLOGY: +You are not auditing against a checklist of IAM anti-patterns. You are reasoning about identity and access the way a principal security engineer would during a threat model — asking "if this identity is compromised, what is the worst possible outcome, and what prevents it?" + +For every identity-related resource you encounter, apply this reasoning process: + +Step 1 — ENUMERATE PERMISSIONS: Read every policy document, trust policy, and permission attachment. For each statement, understand the effective scope: what actions are allowed, on what resources, under what conditions. Wildcards and missing conditions are the most common sources of overprivilege. + +Step 2 — INFER DEFAULTS: For attributes that are NOT present, determine the default behavior. Many IAM resources have dangerous defaults when security features are omitted (no MFA requirement, no permission boundary, no condition keys constraining access). The absence of a security constraint is itself a finding. + +Step 3 — TRACE PRIVILEGE PATHS: For each identity, trace the full chain of what it can do. Start from the identity, follow trust relationships, assume-role chains, pass-role capabilities, and compute execution paths. An identity's effective power is not just its direct permissions — it's the transitive closure of everything reachable through delegation, assumption, and execution chains. + +Step 4 — ASSESS BLAST RADIUS: For each identity, reason about the worst-case scenario if it's compromised. What data can it reach? What other identities can it pivot to? Can it modify its own permissions? Can it disable security controls or logging? The severity of an IAM finding is proportional to the blast radius of compromise. + +Step 5 — ASSESS COMPLETENESS: After analyzing individual identities, step back and ask: are there systemic IAM patterns that indicate structural weakness? Sometimes the finding isn't a single overprivileged role — it's the absence of guardrails across the entire identity architecture (no permission boundaries anywhere, no MFA enforcement, no separation of duties, all identities sharing similar broad policies). + +Your domain covers everything related to identity, authentication, authorization, delegation, and privilege management. This includes but is not limited to: IAM users, roles, policies, groups, instance profiles, access keys, trust relationships, permission boundaries, service-linked roles, and cross-account access configurations. + +IMPORTANT: Do not limit your analysis to identities that look obviously overprivileged. A role with narrowly-scoped permissions can still be dangerous if its trust policy is overly broad, or if it can pass itself to a compute service. Reason about the full attack surface of each identity. + +WORKFLOW: +1. Use Bash to inventory ALL IAM-related IaC definitions across the repository. +2. Use Read to inspect every policy document, trust policy, and identity configuration. Pay special attention to what is ABSENT — missing conditions, boundaries, and constraints are findings. +3. Build explicit candidate attack paths from graph nodes and edges before writing any finding. +4. Re-open supporting files with Read to verify exact evidence lines, principals, actions, and resources. +5. Use Write to produce strict HuntResult JSON only after all findings are evidence-backed. + +FINDING QUALITY REQUIREMENTS: +- Every finding must cite specific graph nodes and edge types involved in the risk path. +- Include concrete principal, action, and target resource semantics, not generic overprivilege language. +- Distinguish direct exploitability from conditional exploitability and explain the missing preconditions. +- Tie benchmark_id to the most relevant CIS control when available. + +OUTPUT: +- Return strict JSON matching HuntResult with no markdown or wrapper text. +- Set hunter_strategy to "iam" on every RawFinding. +- Use valid FindingCategory values only. +- Populate title, description, iac_file, iac_line, config_snippet, benchmark_id, estimated_severity, and confidence. +- Keep total_raw, deduplicated_count, strategies_run, and hunt_duration_seconds internally consistent. + +CONSTRAINTS: +- Base findings only on IaC content and graph context you actually validated. +- Do not rely on regex-only pattern detection as final logic. +- Do not invent placeholders beyond those provided in this template. diff --git a/go/internal/agents/hunt/testdata/golden/logging_hunter_prompt.txt b/go/internal/agents/hunt/testdata/golden/logging_hunter_prompt.txt new file mode 100644 index 0000000..45f71f6 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/logging_hunter_prompt.txt @@ -0,0 +1,82 @@ +ROLE: +You are a senior cloud detection engineering specialist focused on audit coverage, telemetry integrity, and graph-based visibility gap analysis for CloudSecurity AF HUNT phase. + +CONTEXT: +- Repository path: /fixture/repo +- Depth profile: standard + +RESOURCE GRAPH CONTEXT: +The following resources and relationships are relevant to your domain: + +RELEVANT RESOURCES: + - aws_cloudtrail.audit (aws_cloudtrail) @ logging.tf + Config: {'is_multi_region_trail': False, 'enable_log_file_validation': False} + +INFRASTRUCTURE STATISTICS: +Total resources: 12 +Providers: aws, google +Modules: 2 +Variables: 2 +Outputs: 1 +Graph nodes: 9 +Graph edges: 4 +Filtered nodes: 1 +Filtered edges: 0 + +CONNECTED RELATIONSHIPS: +RELEVANT RELATIONSHIPS: + - no edges matched this hunter domain + +TASK: +Read repository IaC and identify logging, monitoring, and detection gaps by reasoning over graph coverage of critical resources and paths. + +GRAPH-AWARE ANALYSIS INSTRUCTIONS: +1. Map critical graph nodes to required telemetry sources and detect visibility blind spots. +2. Follow network_path and execution edges to ensure high-risk paths are observable end-to-end. +3. Validate log integrity and retention controls for evidence continuity across connected services. +4. Correlate detection controls with the resources they protect to avoid control-presence false confidence. +5. Prioritize findings where missing telemetry obscures high-impact attack paths. + +SECURITY REASONING METHODOLOGY: +You are not checking whether specific logging services are enabled. You are reasoning about observability the way an incident response lead would — asking "if an attacker compromised any resource in this infrastructure right now, would I have the telemetry to detect it, investigate it, and prove what happened?" + +For the entire infrastructure, apply this reasoning process: + +Step 1 — MAP CRITICAL RESOURCES: Identify every resource in the graph that an attacker would target or traverse. For each, ask: is there telemetry that would capture unauthorized access, modification, or exfiltration? If a resource has no associated logging, monitoring, or alerting, that's a visibility blind spot. + +Step 2 — TRACE ATTACK PATHS: For each attack path visible in the graph, trace whether every step would generate detectable telemetry. An attack that can proceed from entry point to data exfiltration without triggering any log, alarm, or detection mechanism represents a critical observability gap. + +Step 3 — INFER MISSING TELEMETRY: For resources where logging or monitoring is NOT explicitly configured, determine the cloud provider's default behavior. Many critical telemetry sources must be explicitly enabled — they are not on by default. The complete absence of logging configuration for a resource type is often the most important finding. + +Step 4 — ASSESS LOG INTEGRITY: For telemetry that IS configured, evaluate whether it can be trusted. Can logs be tampered with? Can an attacker delete evidence? Are logs stored in a location the attacker could reach? Is there validation that logs haven't been modified? + +Step 5 — ASSESS COMPLETENESS: After analyzing individual resources, step back and ask: is there a coherent observability strategy, or are there systemic gaps? Sometimes the finding isn't a single missing log source — it's the complete absence of an observability architecture (no centralized logging, no alerting pipeline, no detection rules, no log retention policy). + +Your domain covers everything related to audit trails, telemetry, monitoring, alerting, detection, and forensic readiness. You are looking for blind spots — places where an attacker could act without being observed. + +IMPORTANT: Observability findings often come from ABSENCE, not misconfiguration. A resource that has no logging configuration at all is a finding. An infrastructure with no alerting pipeline is a finding. Reason about what SHOULD exist for security operations, not just what IS configured. + +WORKFLOW: +1. Use Bash to locate ALL IaC files — observability gaps are found by identifying what SHOULD have logging but doesn't, not just by examining logging resources. +2. Use Read to inspect both logging configurations and the resources they should cover. Pay special attention to resources with NO associated telemetry. +3. Build graph-based visibility maps from critical resources to corresponding telemetry and alerting controls. +4. Validate each finding with precise IaC evidence and control-to-resource mapping. +5. Use Write to emit strict HuntResult JSON only. + +FINDING QUALITY REQUIREMENTS: +- Each finding must reference specific graph nodes whose activity is insufficiently logged or monitored. +- Include the missing or weak telemetry control and the impacted attack path visibility. +- Distinguish between coverage gap, integrity gap, and detection-response gap. +- Include benchmark_id mappings where control alignment is explicit. + +OUTPUT: +- Return strict JSON matching HuntResult with no markdown or wrapper text. +- Set hunter_strategy to "logging" on every RawFinding. +- Use valid FindingCategory values only. +- Populate title, description, iac_file, iac_line, config_snippet, benchmark_id, estimated_severity, and confidence. +- Keep total_raw, deduplicated_count, strategies_run, and hunt_duration_seconds coherent. + +CONSTRAINTS: +- Base findings only on validated IaC and graph evidence. +- Do not use regex-only pattern matching as final logic. +- Do not invent placeholders beyond those defined in this template. diff --git a/go/internal/agents/hunt/testdata/golden/network_hunter_prompt.txt b/go/internal/agents/hunt/testdata/golden/network_hunter_prompt.txt new file mode 100644 index 0000000..da632f2 --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/network_hunter_prompt.txt @@ -0,0 +1,84 @@ +ROLE: +You are a senior cloud network security engineer specializing in cloud segmentation, transit routing, and graph-based exposure analysis for CloudSecurity AF HUNT phase. + +CONTEXT: +- Repository path: /fixture/repo +- Depth profile: standard + +RESOURCE GRAPH CONTEXT: +The following resources and relationships are relevant to your domain: + +RELEVANT RESOURCES: + - aws_vpc.main (aws_vpc) @ network.tf + Config: {'cidr_block': '10.0.0.0/16', 'enable_dns_hostnames': True} + - aws_subnet.private (aws_subnet) @ network.tf + Config: {'cidr_block': '10.0.1.0/24', 'map_public_ip_on_launch': False} + +INFRASTRUCTURE STATISTICS: +Total resources: 12 +Providers: aws, google +Modules: 2 +Variables: 2 +Outputs: 1 +Graph nodes: 9 +Graph edges: 4 +Filtered nodes: 2 +Filtered edges: 1 + +CONNECTED RELATIONSHIPS: +RELEVANT RELATIONSHIPS: + - aws_vpc.main --[contains]--> aws_subnet.private + +TASK: +Read repository IaC and identify network findings by tracing real reachability paths across network graph edges. + +GRAPH-AWARE ANALYSIS INSTRUCTIONS: +1. Follow network_path edges to determine which resources are reachable from public subnets. +2. Build internet-to-workload reachability chains through IGW, route tables, load balancers, and target groups. +3. Correlate security group ingress with downstream connected nodes to measure true blast radius. +4. Evaluate edge direction, route precedence, and overlap to avoid false assumptions about isolation. +5. Prioritize findings where exposure traverses multiple trust zones or VPC boundaries. + +SECURITY REASONING METHODOLOGY: +You are not checking a list of known-bad configurations. You are reasoning about network security the way a principal security architect would during a threat model review. Your goal is to find every way an attacker could enter, traverse, or exfiltrate from this network — including paths that no checklist would anticipate. + +For every network-related resource you encounter, apply this reasoning process: + +Step 1 — ENUMERATE: Read every attribute of the resource configuration — every rule, every CIDR block, every port range, every protocol setting, every boolean flag. Network security is defined by the totality of these attributes, not a subset. + +Step 2 — INFER DEFAULTS: For attributes that are NOT present, determine the cloud provider's default behavior. Many network resources have insecure defaults (e.g., subnets may auto-assign public IPs, security groups may allow all outbound traffic, load balancers may accept unencrypted traffic). The absence of an explicit security setting is often the finding. + +Step 3 — MAP REACHABILITY: For each resource, trace the full network path from the internet to the resource and from the resource to internal targets. A security group rule is only dangerous in the context of what sits behind it. An open port on a security group attached to a database is very different from the same port on a bastion host. + +Step 4 — EVALUATE DEFENSE IN DEPTH: Security should not depend on a single control. For each network path, verify that multiple independent controls (security groups, NACLs, subnet placement, route tables) collectively enforce the intended isolation. If removing any single control would expose a sensitive resource, that's a finding. + +Step 5 — ASSESS COMPLETENESS: After analyzing individual resources, step back and ask: are there entire categories of network security that are missing? Sometimes the finding isn't a misconfigured resource — it's the complete absence of a control layer (no egress filtering anywhere, no flow logs, no network segmentation between tiers, all subnets configured identically). + +Your domain covers everything related to network topology, traffic flow, perimeter security, internal segmentation, and network-layer observability. This includes but is not limited to: VPCs, subnets, security groups, NACLs, route tables, internet gateways, NAT gateways, load balancers, VPC endpoints, peering connections, transit gateways, network interfaces, and flow logs. + +IMPORTANT: Do not limit your analysis to resources that look obviously misconfigured. Examine every network resource and every rule within it. A finding can be a single overly-broad rule in an otherwise well-configured security group, or a structural issue like all subnets being public when a tiered architecture is needed. + +WORKFLOW: +1. Use Bash to locate ALL networking IaC files across the repository. +2. Use Read to inspect every attribute of every network resource — every rule, every flag, every CIDR. Pay special attention to attributes that are ABSENT. +3. Build graph-backed reachability paths and validate each path with file-level evidence. +4. Re-check ambiguous routes or inherited module defaults before finalizing severity. +5. Use Write to emit strict HuntResult JSON only. + +FINDING QUALITY REQUIREMENTS: +- Each finding must reference concrete graph nodes and network_path edges used in the exposure path. +- Include the external source, intermediate routing/forwarding nodes, and final sensitive destination. +- Explain whether exposure is direct, transitive, or conditional based on additional controls. +- Use benchmark_id where relevant and align evidence to the cited control intent. + +OUTPUT: +- Return strict JSON matching HuntResult with no markdown or wrapper text. +- Set hunter_strategy to "network" on every RawFinding. +- Use valid FindingCategory values only. +- Populate title, description, iac_file, iac_line, config_snippet, benchmark_id, estimated_severity, and confidence. +- Keep total_raw, deduplicated_count, strategies_run, and hunt_duration_seconds coherent. + +CONSTRAINTS: +- Base findings only on IaC and graph evidence you actually verified. +- Avoid regex-only matching as final logic; reason about exploitability and topology. +- Do not invent placeholders beyond those defined in this template. diff --git a/go/internal/agents/hunt/testdata/golden/secrets_hunter_prompt.txt b/go/internal/agents/hunt/testdata/golden/secrets_hunter_prompt.txt new file mode 100644 index 0000000..4199ded --- /dev/null +++ b/go/internal/agents/hunt/testdata/golden/secrets_hunter_prompt.txt @@ -0,0 +1,91 @@ +ROLE: +You are a senior cloud secrets security engineer specializing in credential lifecycle hardening, secret distribution paths, and graph-based compromise analysis for CloudSecurity AF HUNT phase. + +CONTEXT: +- Repository path: /fixture/repo +- Depth profile: standard + +RESOURCE GRAPH CONTEXT: +The following resources and relationships are relevant to your domain: + +RELEVANT RESOURCES: + - aws_instance.web (aws_instance) @ compute.tf + Config: {'instance_type': 't3.micro', 'associate_public_ip_address': True, 'metadata_options': {'http_tokens': 'optional'}} + - aws_kms_key.master (aws_kms_key) @ security.tf + Config: {'enable_key_rotation': False, 'deletion_window_in_days': 7, 'rotation_period_days': 90.0, 'description': 'clé principale'} + - aws_secretsmanager_secret.db (aws_secretsmanager_secret) @ secrets.tf + Config: {'recovery_window_in_days': 0, 'rotation_rules': None} + +CONNECTED RESOURCES (1-hop neighbors): + - aws_s3_bucket.data (aws_s3_bucket) @ storage.tf + Config: {'acl': 'public-read', 'versioning': False, 'tags': {'owner': 'data-team', 'env': 'prod'}} + +INFRASTRUCTURE STATISTICS: +Total resources: 12 +Providers: aws, google +Modules: 2 +Variables: 2 +Outputs: 1 +Graph nodes: 9 +Graph edges: 4 +Filtered nodes: 3 +Filtered edges: 2 + +CONNECTED RELATIONSHIPS: +RELEVANT RELATIONSHIPS: + - aws_s3_bucket.data --[encrypted_by]--> aws_kms_key.master + - aws_instance.web --[references]--> aws_kms_key.master + +TASK: +Read repository IaC and identify secrets findings by combining content-level exposure checks with graph-based credential propagation analysis. + +GRAPH-AWARE ANALYSIS INSTRUCTIONS: +1. Trace execution edges to identify Lambda, ECS, EKS, EC2, and CI/CD nodes that may receive credentials at runtime. +2. Correlate references and data_access edges to determine where secret material is stored and consumed. +3. Prioritize findings where a single leaked credential provides transitive access to high-impact resources. +4. Evaluate whether secret protections are inherited, explicit, or bypassable through connected graph paths. +5. Use edge directionality to avoid incorrect assumptions about secret retrieval permissions. + +SECURITY REASONING METHODOLOGY: +You are not scanning for known credential patterns. You are reasoning about secrets hygiene the way a principal security engineer would during a security architecture review — asking "if an attacker gained read access to this repository, what credentials could they extract, and what could they do with them?" + +For every resource and configuration file you encounter, apply this reasoning process: + +Step 1 — SCAN FOR SENSITIVE MATERIAL: Read every attribute, variable, output, provider block, and resource configuration. For each value, ask: could this be a credential, key, password, token, or other sensitive material? Secrets appear in unexpected places — not just dedicated secrets resources, but variable defaults, output values, user_data scripts, environment blocks, provider configurations, and module inputs. + +Step 2 — ASSESS EXPOSURE SURFACE: For every piece of sensitive material found, determine all the ways it could be accessed by an unauthorized party. This includes: direct repo access, Terraform state files, CloudWatch logs, deployment artifacts, instance metadata, and any connected system that receives the secret. + +Step 3 — TRACE CREDENTIAL POWER: For each exposed credential, trace the full scope of what it grants access to. Follow the credential to the identity it authenticates, then trace that identity's permissions through the graph. A hardcoded database password is severe if the database contains sensitive data and is publicly accessible; it's even more severe if the same credentials are reused elsewhere. + +Step 4 — EVALUATE SECRETS ARCHITECTURE: Step back from individual secrets and assess the overall secrets management posture. Are secrets managed through a proper secrets management service, or scattered across IaC files? Are there rotation mechanisms? Is there a pattern of secure secret injection (runtime retrieval from a vault) or insecure injection (build-time embedding in configs)? The absence of a secrets management strategy is itself a critical finding. + +Step 5 — ASSESS COMPLETENESS: After analyzing individual resources, ask: are there entire categories of secret exposure that this infrastructure is vulnerable to? Sometimes the most important finding isn't a specific hardcoded key — it's a systemic pattern like "all credentials are embedded in IaC with no rotation mechanism." + +Your domain covers everything related to credentials, keys, tokens, passwords, certificates, and any sensitive configuration value whose exposure would grant unauthorized access. This includes scanning all IaC files, not just resources explicitly related to secrets management. + +IMPORTANT: Secrets hide in unexpected places. Do not limit your search to resources that look like they should contain secrets. Examine EVERY file and EVERY attribute. Variable defaults, output blocks, provider blocks, and user_data scripts are common hiding places for sensitive material. + +WORKFLOW: +1. Use Bash to locate ALL IaC files across the repository — secrets can appear anywhere. +2. Use Read to inspect every file and every attribute. Pay special attention to variable defaults, outputs, provider blocks, environment blocks, and user_data scripts. +3. Build graph-backed credential exposure paths linking secret sources, execution principals, and target assets. +4. Confirm each finding with exact file/line evidence and remove false positives caused by placeholders or test fixtures. +5. Use Write to emit strict HuntResult JSON only. + +FINDING QUALITY REQUIREMENTS: +- Every finding must reference specific graph nodes and edges that explain secret exposure or misuse. +- Describe compromise impact in terms of reachable systems and privilege scope. +- Distinguish actual plaintext material from risky configuration patterns that enable future exposure. +- Include benchmark_id where control mapping is supported. + +OUTPUT: +- Return strict JSON matching HuntResult with no markdown or wrapper text. +- Set hunter_strategy to "secrets" on every RawFinding. +- Use valid FindingCategory values only. +- Populate title, description, iac_file, iac_line, config_snippet, benchmark_id, estimated_severity, and confidence. +- Keep total_raw, deduplicated_count, strategies_run, and hunt_duration_seconds coherent. + +CONSTRAINTS: +- Base findings only on validated IaC and graph evidence. +- Do not rely on regex-only pattern matching as final logic. +- Do not invent placeholders beyond those defined in this template. diff --git a/go/internal/agents/prove/doc.go b/go/internal/agents/prove/doc.go new file mode 100644 index 0000000..956bfa0 --- /dev/null +++ b/go/internal/agents/prove/doc.go @@ -0,0 +1,75 @@ +// Package prove ports src/cloudsecurity_af/agents/prove/** — the PROVE phase's +// two verifiers. +// +// Python Go +// ------------------------------------------ ------------------------------ +// prove/static_prover.run_static_prover RunStaticProver +// prove/static_prover._build_prompt BuildStaticProverPrompt +// prove/live_prover.run_live_prover RunLiveProver +// prove/live_prover._build_prompt BuildLiveProverPrompt +// +// The two Run* functions are what internal/reasoners wraps as the +// `run_static_prover` and `run_live_prover` router reasoners; internal/phases +// picks ONE of them per finding — static for tier < 2, live for tier >= 2 — and +// drives it through app.Call, never in-process, exactly as prove_phase does in +// Python. +// +// # The two agents are the same code twice +// +// static_prover.py and live_prover.py are byte-for-byte identical apart from +// three constants: the prompt template, the tempdir prefix and the +// extract_harness_result agent name. Python duplicates the whole module +// (including a private `_build_prompt` with the same 13 replacements in the same +// order); the Go port keeps ONE implementation, buildProverPrompt, and two thin +// exported builders that differ only in which template they load. The observable +// behavior is unchanged — that is verified by golden fixtures generated from +// BOTH Python modules independently. +// +// # What a prover does and does not do +// +// It renders a prompt, runs ONE harness call with cwd= and +// project_dir=, and returns whatever VerifiedFinding the model produced. +// There is no deterministic pre-processing and NO post-processing: the verdict, +// severity, risk_score, sarif_rule_id and sarif_security_severity all come +// straight out of the model (the prompt's MANDATORY FIELDS block is the only +// thing that asks for them). In particular the +// `cloudsecurity//` SARIF rule id that the design +// notes mention is NOT synthesized here — it is applied in two other places, +// both owned by other packages: +// +// - reasoners/phases.py `_fallback_verified` mints it when a prover call +// FAILED and the phase has to fabricate an inconclusive finding, and +// - output/sarif.py falls back to it whenever `finding.sarif_rule_id` is +// empty at report time. +// +// A prover returning an empty sarif_rule_id is therefore normal and must not be +// "fixed up" here. +// +// # Divergences from Python, in one place +// +// 1. Parameter ORDER. Python's agent function is +// `run_static_prover(app, repo_path, finding, attack_path, tier)`, but its +// `@router.reasoner()` wrapper — the signature the control plane and every +// caller actually see — is `(repo_path, finding, tier, attack_path=None)`. +// The Go port follows the REASONER order, so attackPath is last and +// optional-looking, matching the call sites in internal/reasoners and the +// port's cross-package contract. Nothing observable depends on it. +// 2. The two json.dumps sites go through pyfmt.Dumps, whose documented +// deviations are: `Any`-typed leaves that arrived as JSON numbers are +// float64 in Go, so an integer renders as "1.0" where Python renders "1" +// (only reachable through a DriftedResource nested in an AttackPath); and a +// Go map has no insertion order, so a map[string]any inside a finding is +// dumped with SORTED keys. Struct field order — which is what model_dump() +// order actually is — is preserved exactly. +// 3. pyfmt.Dumps renders a NIL Go slice as `null` where pydantic's +// default_factory=list guarantees `[]`. It cannot fire in the live DAG — +// every finding reaching a prover crossed a control-plane JSON boundary and +// was re-seeded by RawFinding.UnmarshalJSON — but a Go caller handing in a +// hand-built struct literal would see it. Pinned by +// TestBuildProverPrompt_NilSliceRendersNull. +// +// Everything else — the prompt bytes, the substitution ORDER, the tempdir +// prefixes, the cwd/project_dir pair, the extract agent names ("StaticProver" / +// "LiveProver") and the cleanup semantics — is byte-for-byte the Python +// behavior. +package prove diff --git a/go/internal/agents/prove/prover.go b/go/internal/agents/prove/prover.go new file mode 100644 index 0000000..825ed56 --- /dev/null +++ b/go/internal/agents/prove/prover.go @@ -0,0 +1,215 @@ +package prove + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/harnessx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/prompts" + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// The three constants that are all static_prover.py and live_prover.py differ +// by. Each pair is that module's PROMPT_PATH, its tempfile.mkdtemp prefix and +// the `agent_name` it passes to extract_harness_result. +const ( + staticProverPromptPath = "prove/static_prover.txt" + staticProverTempPrefix = "cloudsecurity-static-prover-" + staticProverAgentName = "StaticProver" + liveProverPromptPath = "prove/live_prover.txt" + liveProverTempPrefix = "cloudsecurity-live-prover-" + liveProverAgentName = "LiveProver" + // emptyAttackPathJSON is the literal `json.dumps(...) if attack_path else "{}"` + // fallback for {{ATTACK_PATH_JSON}}. + emptyAttackPathJSON = "{}" +) + +// RunStaticProver ports run_static_prover in +// src/cloudsecurity_af/agents/prove/static_prover.py: +// +// template = PROMPT_PATH.read_text(encoding="utf-8") +// prompt = _build_prompt(template, finding, attack_path, tier, repo_path) +// harness_cwd = tempfile.mkdtemp(prefix="cloudsecurity-static-prover-") +// try: +// result = await app.harness(prompt=prompt, schema=VerifiedFinding, +// cwd=harness_cwd, project_dir=repo_path) +// return extract_harness_result(result, VerifiedFinding, "StaticProver") +// finally: +// shutil.rmtree(harness_cwd, ignore_errors=True) +// +// attackPath is Python's `attack_path: AttackPath | None`: nil renders the +// literal "{}" into {{ATTACK_PATH_JSON}} and nothing else changes. See doc.go +// for why it is the LAST parameter here and the second-to-last in Python. +// +// The returned VerifiedFinding is exactly what the model produced — this +// function performs no scoring, no severity flooring and no SARIF rule-id +// synthesis. See doc.go. +func RunStaticProver( + ctx context.Context, + app appx.Harnesser, + repoPath string, + finding schemas.RawFinding, + tier int, + attackPath *schemas.AttackPath, +) (schemas.VerifiedFinding, error) { + return runProver(ctx, app, repoPath, finding, tier, attackPath, + staticProverPromptPath, staticProverTempPrefix, staticProverAgentName) +} + +// RunLiveProver ports run_live_prover in +// src/cloudsecurity_af/agents/prove/live_prover.py. It is run_static_prover with +// a different template, tempdir prefix and agent name — see doc.go. +func RunLiveProver( + ctx context.Context, + app appx.Harnesser, + repoPath string, + finding schemas.RawFinding, + tier int, + attackPath *schemas.AttackPath, +) (schemas.VerifiedFinding, error) { + return runProver(ctx, app, repoPath, finding, tier, attackPath, + liveProverPromptPath, liveProverTempPrefix, liveProverAgentName) +} + +// runProver is the body both Python modules duplicate verbatim. +func runProver( + ctx context.Context, + app appx.Harnesser, + repoPath string, + finding schemas.RawFinding, + tier int, + attackPath *schemas.AttackPath, + promptPath string, + tempPrefix string, + agentName string, +) (schemas.VerifiedFinding, error) { + template, err := prompts.Load(promptPath) + if err != nil { + // Python parity: PROMPT_PATH.read_text() raising surfaces as a failed + // reasoner, and it happens BEFORE mkdtemp. + return schemas.VerifiedFinding{}, err + } + prompt := buildProverPrompt(template, finding, attackPath, tier, repoPath) + + harnessCwd, err := os.MkdirTemp("", tempPrefix) + if err != nil { + return schemas.VerifiedFinding{}, fmt.Errorf("cloudsecurity prove: creating %s work dir: %w", agentName, err) + } + // Python: `finally: shutil.rmtree(harness_cwd, ignore_errors=True)`. + defer func() { _ = os.RemoveAll(harnessCwd) }() // ignore_errors=True + + return harnessx.RunExtract[schemas.VerifiedFinding]( + ctx, app, prompt, + // Python parity: cwd is the throwaway tempdir, project_dir is the + // repository under audit — the prover reads IaC, it does not write it. + harness.Options{Cwd: harnessCwd, ProjectDir: repoPath}, + agentName, + ) +} + +// BuildStaticProverPrompt renders the static-prover harness prompt. Exported for +// the golden test, which compares it byte-for-byte against the string +// static_prover._build_prompt emits. +func BuildStaticProverPrompt( + finding schemas.RawFinding, + attackPath *schemas.AttackPath, + tier int, + repoPath string, +) (string, error) { + template, err := prompts.Load(staticProverPromptPath) + if err != nil { + return "", err + } + return buildProverPrompt(template, finding, attackPath, tier, repoPath), nil +} + +// BuildLiveProverPrompt renders the live-prover harness prompt. Exported for the +// golden test, which compares it byte-for-byte against the string +// live_prover._build_prompt emits. +func BuildLiveProverPrompt( + finding schemas.RawFinding, + attackPath *schemas.AttackPath, + tier int, + repoPath string, +) (string, error) { + template, err := prompts.Load(liveProverPromptPath) + if err != nil { + return "", err + } + return buildProverPrompt(template, finding, attackPath, tier, repoPath), nil +} + +// buildProverPrompt ports the `_build_prompt` that static_prover.py and +// live_prover.py each declare identically: +// +// replacements = { +// "{{TITLE}}": finding.title, +// "{{DESCRIPTION}}": finding.description, +// "{{CATEGORY}}": finding.category, +// "{{HUNTER_STRATEGY}}": finding.hunter_strategy, +// "{{IAC_FILE}}": finding.iac_file, +// "{{IAC_LINE}}": str(finding.iac_line), +// "{{CONFIG_SNIPPET}}": finding.config_snippet, +// "{{ESTIMATED_SEVERITY}}": finding.estimated_severity.value, +// "{{CONFIDENCE}}": finding.confidence.value, +// "{{FINDING_JSON}}": json.dumps(finding.model_dump(), indent=2), +// "{{ATTACK_PATH_JSON}}": json.dumps(attack_path.model_dump(), indent=2) if attack_path else "{}", +// "{{TIER}}": str(tier), +// "{{REPO_PATH}}": repo_path, +// } +// for needle, value in replacements.items(): +// prompt = prompt.replace(needle, value) +// +// PYTHON PARITY — SUBSTITUTION ORDER IS LOAD-BEARING. Python 3.7+ dicts iterate +// in insertion order, so the 13 replacements run in exactly the order written +// above, over the same accumulating string. A value that itself contains a later +// placeholder (a finding titled "{{REPO_PATH}}", a config snippet containing +// "{{TIER}}") really does get substituted a second time. The Go port keeps the +// order rather than doing one pass. +// +// PYTHON PARITY — `if attack_path` is an IDENTITY test in practice: a pydantic +// BaseModel defines neither __bool__ nor __len__, so every AttackPath instance +// is truthy and only None takes the "{}" branch. A nil Go pointer is the same +// condition. +func buildProverPrompt( + template string, + finding schemas.RawFinding, + attackPath *schemas.AttackPath, + tier int, + repoPath string, +) string { + attackPathJSON := emptyAttackPathJSON + if attackPath != nil { + attackPathJSON = pyfmt.Dumps(*attackPath, 2) + } + + // Ordered exactly like the Python dict literal. + replacements := []struct{ needle, value string }{ + {"{{TITLE}}", finding.Title}, + {"{{DESCRIPTION}}", finding.Description}, + {"{{CATEGORY}}", finding.Category}, + {"{{HUNTER_STRATEGY}}", finding.HunterStrategy}, + {"{{IAC_FILE}}", finding.IaCFile}, + {"{{IAC_LINE}}", strconv.Itoa(finding.IaCLine)}, + {"{{CONFIG_SNIPPET}}", finding.ConfigSnippet}, + {"{{ESTIMATED_SEVERITY}}", finding.EstimatedSeverity.String()}, + {"{{CONFIDENCE}}", finding.Confidence.String()}, + {"{{FINDING_JSON}}", pyfmt.Dumps(finding, 2)}, + {"{{ATTACK_PATH_JSON}}", attackPathJSON}, + {"{{TIER}}", strconv.Itoa(tier)}, + {"{{REPO_PATH}}", repoPath}, + } + + prompt := template + for _, r := range replacements { + prompt = strings.ReplaceAll(prompt, r.needle, r.value) + } + return prompt +} diff --git a/go/internal/agents/prove/prover_test.go b/go/internal/agents/prove/prover_test.go new file mode 100644 index 0000000..9445d04 --- /dev/null +++ b/go/internal/agents/prove/prover_test.go @@ -0,0 +1,361 @@ +package prove + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +// proveInputs mirrors go/scripts/gen_golden.py's prove inputs.json — the exact +// pydantic models the Python builders were driven with, so the golden +// comparison is against the same values rather than a hand transcription. +type proveInputs struct { + FindingFull schemas.RawFinding `json:"finding_full"` + FindingBare schemas.RawFinding `json:"finding_bare"` + AttackPath schemas.AttackPath `json:"attack_path"` + RepoPath string `json:"repo_path"` +} + +func loadInputs(t *testing.T) proveInputs { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", "golden", "inputs.json")) + if err != nil { + t.Fatalf("read inputs.json: %v", err) + } + var in proveInputs + if err := json.Unmarshal(raw, &in); err != nil { + t.Fatalf("decode inputs.json: %v", err) + } + return in +} + +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", name, err) + } + return string(raw) +} + +// verifiedJSON is a schema-valid VerifiedFinding the fake harness can return. +// It deliberately leaves sarif_rule_id EMPTY: the prover must not fill it in. +const verifiedJSON = `{ + "id": "v-1", + "title": "verified", + "verdict": "confirmed", + "severity": "high", + "category": "overprivilege", + "risk_score": 7.5, + "hunter_strategy": "iam" +}` + +// --------------------------------------------------------------------------- +// Prompt goldens — the bytes that reach the model +// --------------------------------------------------------------------------- + +// TestBuildProverPrompts_Golden pins both `_build_prompt` implementations +// byte-for-byte, with and without an attack path. +func TestBuildProverPrompts_Golden(t *testing.T) { + in := loadInputs(t) + path := in.AttackPath + + cases := []struct { + name string + build func(schemas.RawFinding, *schemas.AttackPath, int, string) (string, error) + finding schemas.RawFinding + path *schemas.AttackPath + tier int + want string + }{ + {"static_with_attack_path", BuildStaticProverPrompt, in.FindingFull, &path, 1, "static_prompt_a.txt"}, + {"static_without_attack_path", BuildStaticProverPrompt, in.FindingBare, nil, 2, "static_prompt_b.txt"}, + {"live_with_attack_path", BuildLiveProverPrompt, in.FindingFull, &path, 2, "live_prompt_a.txt"}, + {"live_without_attack_path", BuildLiveProverPrompt, in.FindingBare, nil, 3, "live_prompt_b.txt"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := tc.build(tc.finding, tc.path, tc.tier, in.RepoPath) + if err != nil { + t.Fatalf("build prompt: %v", err) + } + if want := golden(t, tc.want); got != want { + t.Errorf("prompt differs from Python\n%s", firstDiff(got, want)) + } + }) + } +} + +// TestBuildProverPrompt_NilAttackPathRendersEmptyObject pins the `if attack_path +// else "{}"` branch: no attack path becomes the two-character literal, not +// `null` and not an empty AttackPath dump. +func TestBuildProverPrompt_NilAttackPathRendersEmptyObject(t *testing.T) { + in := loadInputs(t) + got, err := BuildStaticProverPrompt(in.FindingBare, nil, 1, in.RepoPath) + if err != nil { + t.Fatalf("BuildStaticProverPrompt: %v", err) + } + if !strings.Contains(got, "- Attack path context:\n{}\n") { + t.Errorf("attack-path block is not the literal {}:\n%s", got) + } +} + +// TestBuildProverPrompt_SubstitutionOrder pins the parity quirk that the 13 +// replacements run in the Python dict's insertion order over one accumulating +// string, so a placeholder embedded in an EARLIER value is substituted by a +// LATER replacement and vice-versa. +func TestBuildProverPrompt_SubstitutionOrder(t *testing.T) { + f := schemas.NewRawFinding() + // {{TITLE}} is replaced FIRST, so a {{TIER}} embedded in the title is still + // ahead of the loop and gets substituted, while a {{TITLE}} embedded in it + // is already behind the loop and survives verbatim. + f.Title = "{{TITLE}} tier={{TIER}}" + f.Category = "cat" + f.HunterStrategy = "iam" + + got, err := BuildStaticProverPrompt(f, nil, 7, "/repo") + if err != nil { + t.Fatalf("BuildStaticProverPrompt: %v", err) + } + if !strings.Contains(got, "- Finding title: {{TITLE}} tier=7") { + t.Errorf("expected the later {{TIER}} to be substituted and the already-consumed {{TITLE}} to survive:\n%s", got) + } +} + +// TestBuildProverPrompt_ZeroFloatsRenderPythonStyle pins the single most likely +// byte drift: Go's encoding/json writes a zero float as "0", Python as "0.0". +func TestBuildProverPrompt_ZeroFloatsRenderPythonStyle(t *testing.T) { + f := schemas.NewRawFinding() + path := schemas.NewAttackPath() + path.ID = "p" + + got, err := BuildStaticProverPrompt(f, &path, 1, "/repo") + if err != nil { + t.Fatalf("BuildStaticProverPrompt: %v", err) + } + // AttackPath.blast_radius.estimated_data_volume is Optional -> null, and the + // finding's benchmark_id is Optional -> null; both must be present, not + // omitted. + for _, want := range []string{`"benchmark_id": null`, `"estimated_data_volume": null`, `"resources": []`} { + if !strings.Contains(got, want) { + t.Errorf("prompt is missing %s (pydantic model_dump emits every field)", want) + } + } +} + +// --------------------------------------------------------------------------- +// RunStaticProver / RunLiveProver +// --------------------------------------------------------------------------- + +func okApp() *appx.Fake { + return &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return json.RawMessage(verifiedJSON), nil + })} +} + +// TestRunProver_HarnessOptions pins, for both provers, the tempdir prefix, that +// project_dir is the REPOSITORY (not the tempdir), that exactly one harness call +// happens, and that the tempdir is removed afterwards. +func TestRunProver_HarnessOptions(t *testing.T) { + in := loadInputs(t) + path := in.AttackPath + + cases := []struct { + name string + run func(context.Context, appx.Harnesser, string, schemas.RawFinding, int, *schemas.AttackPath) (schemas.VerifiedFinding, error) + wantPrefix string + wantPrompt string + }{ + {"static", RunStaticProver, staticProverTempPrefix, "You are the CloudSecurity static prover."}, + {"live", RunLiveProver, liveProverTempPrefix, "You are the CloudSecurity live prover."}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + app := okApp() + got, err := tc.run(context.Background(), app, "/repo/under/audit", in.FindingFull, 2, &path) + if err != nil { + t.Fatalf("run: %v", err) + } + if len(app.Harnesses) != 1 { + t.Fatalf("made %d harness calls, want 1", len(app.Harnesses)) + } + call := app.Harnesses[0] + if !strings.Contains(call.Prompt, tc.wantPrompt) { + t.Errorf("prompt does not come from the %s template:\n%s", tc.name, call.Prompt) + } + if base := filepath.Base(call.Opts.Cwd); !strings.HasPrefix(base, tc.wantPrefix) { + t.Errorf("cwd = %q, want a tempdir named %q*", call.Opts.Cwd, tc.wantPrefix) + } + if call.Opts.ProjectDir != "/repo/under/audit" { + t.Errorf("project_dir = %q, want the repo path", call.Opts.ProjectDir) + } + if _, err := os.Stat(call.Opts.Cwd); !os.IsNotExist(err) { + t.Errorf("temp dir %q survived the call (stat err = %v)", call.Opts.Cwd, err) + } + if got.ID != "v-1" || got.Verdict != schemas.VerdictConfirmed || got.RiskScore != 7.5 { + t.Errorf("returned %+v, want the model's VerifiedFinding verbatim", got) + } + }) + } +} + +// TestRunProver_DoesNotSynthesizeSARIFRuleID pins the boundary between this +// package and phases.py / output/sarif.py: a prover returns the model's finding +// UNCHANGED, so an empty sarif_rule_id stays empty here. The +// `cloudsecurity//` formula lives in the two fallbacks, not +// in the prover. +func TestRunProver_DoesNotSynthesizeSARIFRuleID(t *testing.T) { + in := loadInputs(t) + app := okApp() + + got, err := RunStaticProver(context.Background(), app, "/repo", in.FindingFull, 1, nil) + if err != nil { + t.Fatalf("RunStaticProver: %v", err) + } + if got.SARIFRuleID != "" { + t.Errorf("sarif_rule_id = %q, want it left exactly as the model returned it", got.SARIFRuleID) + } + if got.SARIFSecuritySeverity != 0 { + t.Errorf("sarif_security_severity = %v, want the model's value", got.SARIFSecuritySeverity) + } +} + +// TestRunProver_HarnessErrorUsesTheAgentName pins the two agent names that reach +// extract_harness_result and therefore the error strings the phase logs. +func TestRunProver_HarnessErrorUsesTheAgentName(t *testing.T) { + in := loadInputs(t) + cases := []struct { + name string + run func(context.Context, appx.Harnesser, string, schemas.RawFinding, int, *schemas.AttackPath) (schemas.VerifiedFinding, error) + want string + }{ + {"static", RunStaticProver, "StaticProver harness error: boom"}, + {"live", RunLiveProver, "LiveProver harness error: boom"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + app := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return nil, errors.New("boom") + })} + _, err := tc.run(context.Background(), app, "/repo", in.FindingBare, 1, nil) + if err == nil || err.Error() != tc.want { + t.Errorf("error = %v, want %q", err, tc.want) + } + }) + } +} + +// TestRunProver_TempDirIsUniquePerCall guards against a shared work directory: +// prove_phase runs up to three provers concurrently, and Python's mkdtemp gives +// each its own. +func TestRunProver_TempDirIsUniquePerCall(t *testing.T) { + in := loadInputs(t) + app := okApp() + + for i := 0; i < 2; i++ { + if _, err := RunStaticProver(context.Background(), app, "/repo", in.FindingBare, 1, nil); err != nil { + t.Fatalf("RunStaticProver: %v", err) + } + } + if app.Harnesses[0].Opts.Cwd == app.Harnesses[1].Opts.Cwd { + t.Errorf("both calls shared cwd %q", app.Harnesses[0].Opts.Cwd) + } +} + +// --------------------------------------------------------------------------- +// pyjson divergences, pinned explicitly rather than hidden +// --------------------------------------------------------------------------- + +// TestPyDumps_DocumentedDivergences pins the two places where the Go dump cannot +// match Python, so a reader sees the exact shape of the gap instead of +// discovering it in production. Both are documented in doc.go and pyjson.go. +func TestPyDumps_DocumentedDivergences(t *testing.T) { + t.Run("int_leaf_becomes_a_float", func(t *testing.T) { + // afx.Bind decodes a JSON `1` into an `any` field as float64(1), which + // renders as Python's float repr. Python's json decoder keeps the int. + diff := schemas.ConfigDiff{Attribute: "a", IaCValue: float64(1)} + if got := pyfmt.Dumps(diff, 0); !strings.Contains(got, `"iac_value": 1.0`) { + t.Errorf("got %s, want the documented 1.0 rendering", got) + } + // A json.Number leaf — what a UseNumber decoder produces — IS exact. + diff.IaCValue = json.Number("1") + if got := pyfmt.Dumps(diff, 0); !strings.Contains(got, `"iac_value": 1`) { + t.Errorf("got %s, want a json.Number to keep its int literal", got) + } + }) + + t.Run("map_keys_are_sorted", func(t *testing.T) { + d := schemas.DriftedResource{IaCConfig: map[string]any{"z": 1.0, "a": 2.0}} + got := pyfmt.Dumps(d, 0) + if !strings.Contains(got, `"iac_config": {"a": 2.0, "z": 1.0}`) { + t.Errorf("got %s, want Go map keys sorted (Python would keep insertion order)", got) + } + }) +} + +// TestBuildProverPrompt_NilSliceRendersNull pins the one latent gap between +// pyfmt.Dumps and pydantic: a NIL Go slice renders as `null` where a +// default_factory=list field always dumps as `[]`. It is unreachable in the live +// DAG (a finding always arrives via afx.Bind, whose UnmarshalJSON seeds `[]`), +// and the test exists so that a future in-process caller sees the behavior +// documented rather than discovering it in a prompt. +func TestBuildProverPrompt_NilSliceRendersNull(t *testing.T) { + seeded := schemas.NewRawFinding() // what afx.Bind produces + got, err := BuildStaticProverPrompt(seeded, nil, 1, "/repo") + if err != nil { + t.Fatalf("BuildStaticProverPrompt: %v", err) + } + if !strings.Contains(got, `"resources": []`) { + t.Errorf("a seeded RawFinding must dump resources as [], got:\n%s", got) + } + + bare := schemas.RawFinding{} // a hand-built struct literal: nil slice + got, err = BuildStaticProverPrompt(bare, nil, 1, "/repo") + if err != nil { + t.Fatalf("BuildStaticProverPrompt: %v", err) + } + if !strings.Contains(got, `"resources": null`) { + t.Errorf("expected the documented nil-slice rendering, got:\n%s", got) + } +} + +// TestPyDumps_EscapesLikePython pins ensure_ascii=True and the absence of Go's +// HTML escaping — the difference is visible in almost every real finding title. +func TestPyDumps_EscapesLikePython(t *testing.T) { + f := schemas.NewRawFinding() + f.Title = ` & "b" — ünï 😀` + got := pyfmt.Dumps(f, 0) + want := `"title": " & \"b\" \u2014 \u00fcn\u00ef \ud83d\ude00"` + if !strings.Contains(got, want) { + t.Errorf("got %s\nwant it to contain %s", got, want) + } +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +func firstDiff(got, want string) string { + g, w := strings.Split(got, "\n"), strings.Split(want, "\n") + for i := 0; i < len(g) && i < len(w); i++ { + if g[i] != w[i] { + return fmt.Sprintf("first difference at line %d:\n go: %q\n python: %q", i+1, g[i], w[i]) + } + } + return fmt.Sprintf("line counts differ: go %d lines, python %d lines", len(g), len(w)) +} diff --git a/go/internal/agents/prove/testdata/golden/inputs.json b/go/internal/agents/prove/testdata/golden/inputs.json new file mode 100644 index 0000000..9da6736 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/inputs.json @@ -0,0 +1,82 @@ +{ + "finding_full": { + "id": "finding-1", + "hunter_strategy": "iam", + "title": "Role trusts * & assumes admin", + "description": "aws_iam_role.admin has a wildcard trust policy.", + "category": "overprivilege", + "resources": [ + { + "resource_id": "aws_iam_role.admin", + "resource_type": "aws_iam_role", + "attribute": "assume_role_policy", + "current_value": "{\"Principal\": \"*\"}", + "recommended_value": "scoped principal" + } + ], + "estimated_severity": "critical", + "confidence": "high", + "iac_file": "main.tf", + "iac_line": 12, + "config_snippet": "resource \"aws_iam_role\" \"admin\" {\n assume_role_policy = \"*\"\n}", + "benchmark_id": "CIS-1.16", + "fingerprint": "fp-iam-1" + }, + "finding_bare": { + "id": "finding-2", + "hunter_strategy": "network", + "title": "Security group open to 0.0.0.0/0 \u2014 p\u00f6rt\u00e9 22", + "description": "Ingress from anywhere.", + "category": "public_exposure", + "resources": [], + "estimated_severity": "high", + "confidence": "medium", + "iac_file": "network.tf", + "iac_line": 0, + "config_snippet": "", + "benchmark_id": null, + "fingerprint": "fp-net-2" + }, + "attack_path": { + "id": "path-1", + "title": "Wildcard role -> public bucket", + "description": "An anonymous principal assumes the admin role and reads the data lake.", + "steps": [ + { + "step_number": 1, + "resource_id": "aws_iam_role.admin", + "resource_type": "aws_iam_role", + "action": "sts:AssumeRole as any principal", + "permission_used": "assume_role_policy: *", + "description": "" + }, + { + "step_number": 2, + "resource_id": "aws_s3_bucket.data", + "resource_type": "aws_s3_bucket", + "action": "s3:GetObject", + "permission_used": "role policy allows s3:*", + "description": "Exfiltrate the data lake." + } + ], + "entry_point": "aws_iam_role.admin", + "target": "aws_s3_bucket.data", + "findings_involved": [ + "finding-1", + "finding-2" + ], + "combined_severity": "critical", + "blast_radius": { + "data_stores_reachable": [ + "aws_s3_bucket.data" + ], + "compute_reachable": [], + "estimated_data_volume": "~2 TB", + "services_affected": [ + "s3", + "iam" + ] + } + }, + "repo_path": "/fixture/repo" +} \ No newline at end of file diff --git a/go/internal/agents/prove/testdata/golden/live_prompt_a.txt b/go/internal/agents/prove/testdata/golden/live_prompt_a.txt new file mode 100644 index 0000000..e6a06ea --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/live_prompt_a.txt @@ -0,0 +1,138 @@ +ROLE: +You are the CloudSecurity live prover. + +OBJECTIVE: +Verify a finding using both IaC inspection and live cloud checks via executable scripts. + +INPUTS: +- Repo path: /fixture/repo +- Tier: 2 +- Finding title: Role trusts * & assumes admin +- Description: aws_iam_role.admin has a wildcard trust policy. +- Category: overprivilege +- Strategy: iam +- Estimated severity: critical +- Confidence: high +- IaC location: main.tf:12 +- Config snippet: +resource "aws_iam_role" "admin" { + assume_role_policy = "*" +} +- Attack path context: +{ + "id": "path-1", + "title": "Wildcard role -> public bucket", + "description": "An anonymous principal assumes the admin role and reads the data lake.", + "steps": [ + { + "step_number": 1, + "resource_id": "aws_iam_role.admin", + "resource_type": "aws_iam_role", + "action": "sts:AssumeRole as any principal", + "permission_used": "assume_role_policy: *", + "description": "" + }, + { + "step_number": 2, + "resource_id": "aws_s3_bucket.data", + "resource_type": "aws_s3_bucket", + "action": "s3:GetObject", + "permission_used": "role policy allows s3:*", + "description": "Exfiltrate the data lake." + } + ], + "entry_point": "aws_iam_role.admin", + "target": "aws_s3_bucket.data", + "findings_involved": [ + "finding-1", + "finding-2" + ], + "combined_severity": "critical", + "blast_radius": { + "data_stores_reachable": [ + "aws_s3_bucket.data" + ], + "compute_reachable": [], + "estimated_data_volume": "~2 TB", + "services_affected": [ + "s3", + "iam" + ] + } +} +- Full finding JSON: +{ + "id": "finding-1", + "hunter_strategy": "iam", + "title": "Role trusts * & assumes admin", + "description": "aws_iam_role.admin has a wildcard trust policy.", + "category": "overprivilege", + "resources": [ + { + "resource_id": "aws_iam_role.admin", + "resource_type": "aws_iam_role", + "attribute": "assume_role_policy", + "current_value": "{\"Principal\": \"*\"}", + "recommended_value": "scoped principal" + } + ], + "estimated_severity": "critical", + "confidence": "high", + "iac_file": "main.tf", + "iac_line": 12, + "config_snippet": "resource \"aws_iam_role\" \"admin\" {\n assume_role_policy = \"*\"\n}", + "benchmark_id": "CIS-1.16", + "fingerprint": "fp-iam-1" +} + +TASK: +1) Re-check the finding statically in IaC files. +2) Write Python verification scripts using boto3 and/or google-cloud SDK as applicable. +3) Prefer dry-run or read-only enumeration commands and API calls wherever supported. +4) Execute scripts in the harness environment and capture deterministic outputs. +5) Interpret script output to confirm, weaken, or refute exploitability. +6) Identify compensating controls from live state where relevant. +7) Return VerifiedFinding with proof.method=live_api_verification. +8) Record executed script names/commands in proof.scripts_executed and key observations in proof.evidence. +9) Populate verdict, severity, risk_score, sarif_rule_id, and sarif_security_severity. + +SCRIPT PATTERN GUIDANCE: +1) Use boto3 clients for read-only describe/list/get operations, for example: + - IAM: get_role, list_attached_role_policies, simulate_principal_policy + - EC2/VPC: describe_instances, describe_security_groups, describe_route_tables + - S3/RDS/KMS: get_bucket_policy_status, get_public_access_block, describe_db_instances, describe_key + - CloudTrail/GuardDuty/CloudWatch: describe_trails, get_trail_status, list_detectors, describe_alarms +2) Use gcloud/google SDK equivalents with list/describe/get for GCP findings. +3) Add pagination handling for list APIs to avoid false negatives. +4) Include minimal error handling for AccessDenied/NotFound and continue partial verification. + +SAFETY CONSTRAINTS: +1) Never modify resources; verification is enumerate/describe only. +2) Do not run create/update/delete/put operations. +3) Use --dry-run where command patterns support it. +4) If write-capable scripts are present, explicitly disable mutation code paths. +5) Respect account/project scope from available credentials; do not attempt lateral credential discovery. + +ANALYSIS QUALITY RULES: +1) Correlate live evidence back to the IaC finding location and intended configuration. +2) Distinguish configuration drift from false-positive IaC interpretation. +3) Evaluate compensating controls before final verdict and severity. +4) Use inconclusive when access gaps prevent confidence, not confirmed by assumption. + +OUTPUT: +Return one JSON object matching VerifiedFinding. + +MANDATORY FIELDS: +- verdict +- severity +- proof.method +- proof.evidence +- proof.scripts_executed +- sarif_rule_id +- risk_score + +CONSTRAINTS: +- Run scripts only for verification, not mutation. +- Be explicit when credentials/access are missing and use inconclusive if needed. +- Keep all output fields schema-compatible and internally coherent. +- No markdown, no prose wrapper, JSON only. diff --git a/go/internal/agents/prove/testdata/golden/live_prompt_b.txt b/go/internal/agents/prove/testdata/golden/live_prompt_b.txt new file mode 100644 index 0000000..ec29c16 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/live_prompt_b.txt @@ -0,0 +1,88 @@ +ROLE: +You are the CloudSecurity live prover. + +OBJECTIVE: +Verify a finding using both IaC inspection and live cloud checks via executable scripts. + +INPUTS: +- Repo path: /fixture/repo +- Tier: 3 +- Finding title: Security group open to 0.0.0.0/0 — pörté 22 +- Description: Ingress from anywhere. +- Category: public_exposure +- Strategy: network +- Estimated severity: high +- Confidence: medium +- IaC location: network.tf:0 +- Config snippet: + +- Attack path context: +{} +- Full finding JSON: +{ + "id": "finding-2", + "hunter_strategy": "network", + "title": "Security group open to 0.0.0.0/0 \u2014 p\u00f6rt\u00e9 22", + "description": "Ingress from anywhere.", + "category": "public_exposure", + "resources": [], + "estimated_severity": "high", + "confidence": "medium", + "iac_file": "network.tf", + "iac_line": 0, + "config_snippet": "", + "benchmark_id": null, + "fingerprint": "fp-net-2" +} + +TASK: +1) Re-check the finding statically in IaC files. +2) Write Python verification scripts using boto3 and/or google-cloud SDK as applicable. +3) Prefer dry-run or read-only enumeration commands and API calls wherever supported. +4) Execute scripts in the harness environment and capture deterministic outputs. +5) Interpret script output to confirm, weaken, or refute exploitability. +6) Identify compensating controls from live state where relevant. +7) Return VerifiedFinding with proof.method=live_api_verification. +8) Record executed script names/commands in proof.scripts_executed and key observations in proof.evidence. +9) Populate verdict, severity, risk_score, sarif_rule_id, and sarif_security_severity. + +SCRIPT PATTERN GUIDANCE: +1) Use boto3 clients for read-only describe/list/get operations, for example: + - IAM: get_role, list_attached_role_policies, simulate_principal_policy + - EC2/VPC: describe_instances, describe_security_groups, describe_route_tables + - S3/RDS/KMS: get_bucket_policy_status, get_public_access_block, describe_db_instances, describe_key + - CloudTrail/GuardDuty/CloudWatch: describe_trails, get_trail_status, list_detectors, describe_alarms +2) Use gcloud/google SDK equivalents with list/describe/get for GCP findings. +3) Add pagination handling for list APIs to avoid false negatives. +4) Include minimal error handling for AccessDenied/NotFound and continue partial verification. + +SAFETY CONSTRAINTS: +1) Never modify resources; verification is enumerate/describe only. +2) Do not run create/update/delete/put operations. +3) Use --dry-run where command patterns support it. +4) If write-capable scripts are present, explicitly disable mutation code paths. +5) Respect account/project scope from available credentials; do not attempt lateral credential discovery. + +ANALYSIS QUALITY RULES: +1) Correlate live evidence back to the IaC finding location and intended configuration. +2) Distinguish configuration drift from false-positive IaC interpretation. +3) Evaluate compensating controls before final verdict and severity. +4) Use inconclusive when access gaps prevent confidence, not confirmed by assumption. + +OUTPUT: +Return one JSON object matching VerifiedFinding. + +MANDATORY FIELDS: +- verdict +- severity +- proof.method +- proof.evidence +- proof.scripts_executed +- sarif_rule_id +- risk_score + +CONSTRAINTS: +- Run scripts only for verification, not mutation. +- Be explicit when credentials/access are missing and use inconclusive if needed. +- Keep all output fields schema-compatible and internally coherent. +- No markdown, no prose wrapper, JSON only. diff --git a/go/internal/agents/prove/testdata/golden/static_prompt_a.txt b/go/internal/agents/prove/testdata/golden/static_prompt_a.txt new file mode 100644 index 0000000..2b25ee8 --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/static_prompt_a.txt @@ -0,0 +1,133 @@ +ROLE: +You are the CloudSecurity static prover. + +OBJECTIVE: +Re-verify a HUNT finding directly from IaC source and determine exploitability verdict with high-quality, control-aware evidence. + +INPUTS: +- Repo path: /fixture/repo +- Tier: 1 +- Finding title: Role trusts * & assumes admin +- Description: aws_iam_role.admin has a wildcard trust policy. +- Category: overprivilege +- Strategy: iam +- Estimated severity: critical +- Confidence: high +- IaC location: main.tf:12 +- Config snippet: +resource "aws_iam_role" "admin" { + assume_role_policy = "*" +} +- Attack path context: +{ + "id": "path-1", + "title": "Wildcard role -> public bucket", + "description": "An anonymous principal assumes the admin role and reads the data lake.", + "steps": [ + { + "step_number": 1, + "resource_id": "aws_iam_role.admin", + "resource_type": "aws_iam_role", + "action": "sts:AssumeRole as any principal", + "permission_used": "assume_role_policy: *", + "description": "" + }, + { + "step_number": 2, + "resource_id": "aws_s3_bucket.data", + "resource_type": "aws_s3_bucket", + "action": "s3:GetObject", + "permission_used": "role policy allows s3:*", + "description": "Exfiltrate the data lake." + } + ], + "entry_point": "aws_iam_role.admin", + "target": "aws_s3_bucket.data", + "findings_involved": [ + "finding-1", + "finding-2" + ], + "combined_severity": "critical", + "blast_radius": { + "data_stores_reachable": [ + "aws_s3_bucket.data" + ], + "compute_reachable": [], + "estimated_data_volume": "~2 TB", + "services_affected": [ + "s3", + "iam" + ] + } +} +- Full finding JSON: +{ + "id": "finding-1", + "hunter_strategy": "iam", + "title": "Role trusts * & assumes admin", + "description": "aws_iam_role.admin has a wildcard trust policy.", + "category": "overprivilege", + "resources": [ + { + "resource_id": "aws_iam_role.admin", + "resource_type": "aws_iam_role", + "attribute": "assume_role_policy", + "current_value": "{\"Principal\": \"*\"}", + "recommended_value": "scoped principal" + } + ], + "estimated_severity": "critical", + "confidence": "high", + "iac_file": "main.tf", + "iac_line": 12, + "config_snippet": "resource \"aws_iam_role\" \"admin\" {\n assume_role_policy = \"*\"\n}", + "benchmark_id": "CIS-1.16", + "fingerprint": "fp-iam-1" +} + +TASK: +1) Read IaC files at and around the finding location. +2) Verify whether the reported misconfiguration is real in current IaC. +3) Check compensating controls in surrounding IaC context (module boundaries, network restrictions, scoped trust, encryption, logging, policy constraints). +4) Verify whether higher-level policy already mitigates the issue (for example SCPs, organization-level policy constraints, centralized guardrails). +5) Determine verdict (confirmed | likely | inconclusive | not_exploitable). +6) Set final severity and risk_score based on exploitability, preconditions, and blast radius. +7) Populate proof with method=static_analysis and concrete evidence lines. +8) Populate sarif_rule_id and sarif_security_severity. + +ANALYSIS REQUIREMENTS: +1) Reconstruct the alleged attack path from finding context and IaC relationships. +2) Validate whether the vulnerable setting is active, inherited, overridden, or unused. +3) Distinguish direct exploitability from conditional exploitability requiring additional compromise steps. +4) Downgrade confidence when critical context is unresolved. +5) Avoid false confirmations caused by dead code, non-deployed examples, or commented artifacts. + +COMPENSATING CONTROL CHECKLIST: +1) Identity controls: permission boundaries, deny policies, SCP limits, principal restrictions. +2) Network controls: private subnet placement, restrictive security groups/NACLs, endpoint-only access. +3) Data controls: encryption requirements, key policy restrictions, immutable retention constraints. +4) Observability controls: robust logging and alerting that reduces stealth or persistence risk. +5) Execution controls: runtime restrictions that prevent practical abuse of a nominal misconfiguration. + +EVIDENCE QUALITY RULES: +1) Each proof.evidence entry must be traceable to concrete file and setting details. +2) Quote or summarize exact risky attributes and any mitigating attributes in adjacent context. +3) If mitigated, explicitly explain why the finding is not exploitable despite initial signal. +4) If inconclusive, state what missing context prevented a definitive verdict. + +OUTPUT: +Return one JSON object matching VerifiedFinding. + +MANDATORY FIELDS: +- verdict +- severity +- proof.method +- proof.evidence +- sarif_rule_id +- risk_score + +CONSTRAINTS: +- Do not use live cloud APIs in this mode. +- Evidence must be traceable to IaC files and settings. +- Keep all output fields schema-compatible and internally consistent. +- No markdown, no prose wrapper, JSON only. diff --git a/go/internal/agents/prove/testdata/golden/static_prompt_b.txt b/go/internal/agents/prove/testdata/golden/static_prompt_b.txt new file mode 100644 index 0000000..e8a77cb --- /dev/null +++ b/go/internal/agents/prove/testdata/golden/static_prompt_b.txt @@ -0,0 +1,83 @@ +ROLE: +You are the CloudSecurity static prover. + +OBJECTIVE: +Re-verify a HUNT finding directly from IaC source and determine exploitability verdict with high-quality, control-aware evidence. + +INPUTS: +- Repo path: /fixture/repo +- Tier: 2 +- Finding title: Security group open to 0.0.0.0/0 — pörté 22 +- Description: Ingress from anywhere. +- Category: public_exposure +- Strategy: network +- Estimated severity: high +- Confidence: medium +- IaC location: network.tf:0 +- Config snippet: + +- Attack path context: +{} +- Full finding JSON: +{ + "id": "finding-2", + "hunter_strategy": "network", + "title": "Security group open to 0.0.0.0/0 \u2014 p\u00f6rt\u00e9 22", + "description": "Ingress from anywhere.", + "category": "public_exposure", + "resources": [], + "estimated_severity": "high", + "confidence": "medium", + "iac_file": "network.tf", + "iac_line": 0, + "config_snippet": "", + "benchmark_id": null, + "fingerprint": "fp-net-2" +} + +TASK: +1) Read IaC files at and around the finding location. +2) Verify whether the reported misconfiguration is real in current IaC. +3) Check compensating controls in surrounding IaC context (module boundaries, network restrictions, scoped trust, encryption, logging, policy constraints). +4) Verify whether higher-level policy already mitigates the issue (for example SCPs, organization-level policy constraints, centralized guardrails). +5) Determine verdict (confirmed | likely | inconclusive | not_exploitable). +6) Set final severity and risk_score based on exploitability, preconditions, and blast radius. +7) Populate proof with method=static_analysis and concrete evidence lines. +8) Populate sarif_rule_id and sarif_security_severity. + +ANALYSIS REQUIREMENTS: +1) Reconstruct the alleged attack path from finding context and IaC relationships. +2) Validate whether the vulnerable setting is active, inherited, overridden, or unused. +3) Distinguish direct exploitability from conditional exploitability requiring additional compromise steps. +4) Downgrade confidence when critical context is unresolved. +5) Avoid false confirmations caused by dead code, non-deployed examples, or commented artifacts. + +COMPENSATING CONTROL CHECKLIST: +1) Identity controls: permission boundaries, deny policies, SCP limits, principal restrictions. +2) Network controls: private subnet placement, restrictive security groups/NACLs, endpoint-only access. +3) Data controls: encryption requirements, key policy restrictions, immutable retention constraints. +4) Observability controls: robust logging and alerting that reduces stealth or persistence risk. +5) Execution controls: runtime restrictions that prevent practical abuse of a nominal misconfiguration. + +EVIDENCE QUALITY RULES: +1) Each proof.evidence entry must be traceable to concrete file and setting details. +2) Quote or summarize exact risky attributes and any mitigating attributes in adjacent context. +3) If mitigated, explicitly explain why the finding is not exploitable despite initial signal. +4) If inconclusive, state what missing context prevented a definitive verdict. + +OUTPUT: +Return one JSON object matching VerifiedFinding. + +MANDATORY FIELDS: +- verdict +- severity +- proof.method +- proof.evidence +- sarif_rule_id +- risk_score + +CONSTRAINTS: +- Do not use live cloud APIs in this mode. +- Evidence must be traceable to IaC files and settings. +- Keep all output fields schema-compatible and internally consistent. +- No markdown, no prose wrapper, JSON only. diff --git a/go/internal/agents/recon/agents_test.go b/go/internal/agents/recon/agents_test.go new file mode 100644 index 0000000..3dbf65c --- /dev/null +++ b/go/internal/agents/recon/agents_test.go @@ -0,0 +1,369 @@ +package recon + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// VALIDATION CONTRACT for the four RECON agents (from iac_reader.py, +// resource_graph_builder.py, cloud_connector.py and drift_detector.py): +// +// 1. run_iac_reader / run_resource_graph_builder try the deterministic path +// first and only call the harness when it raises; the harness is NOT called +// on the happy path. +// 2. Their work directory SURVIVES the call — the returned path points into it. +// cloud_connector's and drift_detector's work directory is REMOVED in a +// finally block. +// 3. Every harness call gets Cwd = the work dir and ProjectDir = the repo +// (= the work dir itself for the two cloud agents). +// 4. Temp-dir prefixes are exactly "cloudsecurity-recon-iac-reader-", +// "cloudsecurity-recon-graph-builder-", "cloudsecurity-recon-cloud-connector-" +// and "cloudsecurity-recon-drift-detector-". +// 5. A harness error surfaces as `" harness error: "` with +// the agent names "IaC reader", "Resource graph builder", "Cloud connector" +// and "Drift detector". +// 6. The prompts are the templates with their placeholders substituted — see +// golden_test.go for the byte-verbatim assertions. + +const fixtureRepoPath = "/fixture/repo" + +// harnessSpy records the options of every harness call and answers with canned +// JSON. +type harnessSpy struct { + *appx.Fake + opts []harness.Options +} + +func newSpy(reply string) *harnessSpy { + spy := &harnessSpy{Fake: &appx.Fake{}} + inner := appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return json.RawMessage(reply), nil + }) + spy.HarnessFn = func(ctx context.Context, prompt string, schema map[string]any, dest any, opts harness.Options) (*harness.Result, error) { + spy.opts = append(spy.opts, opts) + return inner(ctx, prompt, schema, dest, opts) + } + return spy +} + +func newFailingSpy(message string) *harnessSpy { + spy := &harnessSpy{Fake: &appx.Fake{}} + spy.HarnessFn = func(_ context.Context, _ string, _ map[string]any, _ any, opts harness.Options) (*harness.Result, error) { + spy.opts = append(spy.opts, opts) + return &harness.Result{IsError: true, ErrorMessage: message}, nil + } + return spy +} + +// silenceDiagnostics redirects the stdout/stderr diagnostics the port emits so a +// passing test run stays quiet, and returns what was captured on stderr. +func silenceWarnings(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + prev := warnOut + warnOut = &buf + t.Cleanup(func() { warnOut = prev }) + return &buf +} + +// --------------------------------------------------------------------------- +// run_iac_reader +// --------------------------------------------------------------------------- + +// Contract items 1 and 2. +func TestRunIaCReader_FastPathSkipsTheHarnessAndKeepsItsWorkDir(t *testing.T) { + app := &appx.Fake{} // no HarnessFn: any harness call fails the test loudly + + got, err := RunIaCReader(context.Background(), app, vulnerableInfraFixture) + if err != nil { + t.Fatalf("RunIaCReader: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(filepath.Dir(got.InventorySavedPath)) }) + + if len(app.Harnesses) != 0 { + t.Errorf("the deterministic path must not call the harness, got %d calls", len(app.Harnesses)) + } + if got.TotalResources != 7 { + t.Errorf("TotalResources = %d, want 7", got.TotalResources) + } + if got.IaCType != "terraform" { + t.Errorf("IaCType = %q, want %q", got.IaCType, "terraform") + } + if got.IaCVersion != nil { + t.Errorf("IaCVersion = %v, want nil (Python leaves the default)", got.IaCVersion) + } + if filepath.Base(got.InventorySavedPath) != "inventory.json" { + t.Errorf("InventorySavedPath = %q, want .../inventory.json", got.InventorySavedPath) + } + if !strings.HasPrefix(filepath.Base(filepath.Dir(got.InventorySavedPath)), iacReaderTempPrefix) { + t.Errorf("work dir %q does not use the %q prefix", got.InventorySavedPath, iacReaderTempPrefix) + } + // Contract item 2: the file must still be there — every downstream phase + // reads it. + if _, err := os.Stat(got.InventorySavedPath); err != nil { + t.Errorf("inventory.json must survive the call: %v", err) + } +} + +// Contract items 1, 3, 5 and 6. +func TestRunIaCReader_FallsBackToTheHarnessWhenTheFastPathFails(t *testing.T) { + warnings := silenceWarnings(t) + + prev := iacFastParseFn + iacFastParseFn = func(string, string) (schemas.ResourceInventory, error) { + return schemas.ResourceInventory{}, errors.New("pyhcl2 exploded") + } + t.Cleanup(func() { iacFastParseFn = prev }) + + spy := newSpy(`{"inventory_saved_path":"/tmp/x/inventory.json","total_resources":3,"iac_type":"terraform"}`) + got, err := RunIaCReader(context.Background(), spy.Fake, fixtureRepoPath) + if err != nil { + t.Fatalf("RunIaCReader: %v", err) + } + + if len(spy.Harnesses) != 1 { + t.Fatalf("harness calls = %d, want 1", len(spy.Harnesses)) + } + if got.TotalResources != 3 || got.InventorySavedPath != "/tmp/x/inventory.json" { + t.Errorf("harness result not returned: %+v", got) + } + + opts := spy.opts[0] + if opts.ProjectDir != fixtureRepoPath { + t.Errorf("ProjectDir = %q, want %q", opts.ProjectDir, fixtureRepoPath) + } + if !strings.HasPrefix(filepath.Base(opts.Cwd), iacReaderTempPrefix) { + t.Errorf("Cwd = %q, want a %q temp dir", opts.Cwd, iacReaderTempPrefix) + } + t.Cleanup(func() { _ = os.RemoveAll(opts.Cwd) }) + + wantPrompt := goldenPrompt(t, "iac_reader_prompt.txt") + if spy.Harnesses[0].Prompt != wantPrompt { + t.Error("fallback prompt does not match the Python golden") + } + + if w := warnings.String(); !strings.Contains(w, "Deterministic parser failed (pyhcl2 exploded), falling back to harness") { + t.Errorf("missing the Python log.warning text, got %q", w) + } +} + +// Contract item 5. +func TestRunIaCReader_HarnessErrorUsesThePythonMessage(t *testing.T) { + silenceWarnings(t) + prev := iacFastParseFn + iacFastParseFn = func(string, string) (schemas.ResourceInventory, error) { + return schemas.ResourceInventory{}, errors.New("nope") + } + t.Cleanup(func() { iacFastParseFn = prev }) + + spy := newFailingSpy("model timed out") + _, err := RunIaCReader(context.Background(), spy.Fake, fixtureRepoPath) + if err == nil { + t.Fatal("want an error") + } + if want := "IaC reader harness error: model timed out"; err.Error() != want { + t.Errorf("error = %q, want %q", err, want) + } + t.Cleanup(func() { _ = os.RemoveAll(spy.opts[0].Cwd) }) +} + +// --------------------------------------------------------------------------- +// run_resource_graph_builder +// --------------------------------------------------------------------------- + +// Contract items 1 and 2. +func TestRunResourceGraphBuilder_FastPathSkipsTheHarness(t *testing.T) { + app := &appx.Fake{} + + got, err := RunResourceGraphBuilder(context.Background(), app, fixtureRepoPath, pythonInventoryFixture) + if err != nil { + t.Fatalf("RunResourceGraphBuilder: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(filepath.Dir(got.GraphSavedPath)) }) + + if len(app.Harnesses) != 0 { + t.Errorf("the deterministic path must not call the harness, got %d calls", len(app.Harnesses)) + } + if got.TotalNodes != 7 || got.TotalEdges != 1 { + t.Errorf("(nodes, edges) = (%d, %d), want (7, 1) — the Python inventory's graph", got.TotalNodes, got.TotalEdges) + } + if filepath.Base(got.GraphSavedPath) != "graph.json" { + t.Errorf("GraphSavedPath = %q, want .../graph.json", got.GraphSavedPath) + } + if !strings.HasPrefix(filepath.Base(filepath.Dir(got.GraphSavedPath)), graphBuilderTempPrefix) { + t.Errorf("work dir %q does not use the %q prefix", got.GraphSavedPath, graphBuilderTempPrefix) + } + if _, err := os.Stat(got.GraphSavedPath); err != nil { + t.Errorf("graph.json must survive the call: %v", err) + } +} + +// Contract items 1, 3, 5 and 6 — reached naturally, by handing it an inventory +// path that does not exist. +func TestRunResourceGraphBuilder_FallsBackToTheHarnessOnAMissingInventory(t *testing.T) { + warnings := silenceWarnings(t) + + spy := newSpy(`{"graph_saved_path":"/tmp/x/graph.json","total_nodes":4,"total_edges":2}`) + missing := filepath.Join(t.TempDir(), "inventory.json") + + got, err := RunResourceGraphBuilder(context.Background(), spy.Fake, fixtureRepoPath, missing) + if err != nil { + t.Fatalf("RunResourceGraphBuilder: %v", err) + } + if got.TotalNodes != 4 || got.TotalEdges != 2 { + t.Errorf("harness result not returned: %+v", got) + } + if len(spy.opts) != 1 { + t.Fatalf("harness calls = %d, want 1", len(spy.opts)) + } + opts := spy.opts[0] + t.Cleanup(func() { _ = os.RemoveAll(opts.Cwd) }) + if opts.ProjectDir != fixtureRepoPath { + t.Errorf("ProjectDir = %q, want the REPO path %q", opts.ProjectDir, fixtureRepoPath) + } + if !strings.HasPrefix(filepath.Base(opts.Cwd), graphBuilderTempPrefix) { + t.Errorf("Cwd = %q, want a %q temp dir", opts.Cwd, graphBuilderTempPrefix) + } + + // The prompt interpolates the INVENTORY path, not the repo path. + if !strings.Contains(spy.Harnesses[0].Prompt, missing) { + t.Error("the fallback prompt must carry the inventory path") + } + if w := warnings.String(); !strings.Contains(w, "Deterministic graph builder failed") { + t.Errorf("missing the Python log.warning text, got %q", w) + } +} + +// Contract item 5. +func TestRunResourceGraphBuilder_HarnessErrorUsesThePythonMessage(t *testing.T) { + silenceWarnings(t) + spy := newFailingSpy("out of turns") + _, err := RunResourceGraphBuilder(context.Background(), spy.Fake, fixtureRepoPath, filepath.Join(t.TempDir(), "nope.json")) + if err == nil { + t.Fatal("want an error") + } + if want := "Resource graph builder harness error: out of turns"; err.Error() != want { + t.Errorf("error = %q, want %q", err, want) + } + t.Cleanup(func() { _ = os.RemoveAll(spy.opts[0].Cwd) }) +} + +// --------------------------------------------------------------------------- +// run_cloud_connector +// --------------------------------------------------------------------------- + +// Contract items 2, 3 and 4. +func TestRunCloudConnector_HarnessOptionsAndTempDirLifecycle(t *testing.T) { + spy := newSpy(`{"inventory_saved_path":"/live/inventory.json","total_resources":12,"iac_type":"terraform"}`) + + got, err := RunCloudConnector(context.Background(), spy.Fake, cloudConfigCaseA()) + if err != nil { + t.Fatalf("RunCloudConnector: %v", err) + } + if got.TotalResources != 12 { + t.Errorf("TotalResources = %d, want 12", got.TotalResources) + } + if len(spy.opts) != 1 { + t.Fatalf("harness calls = %d, want 1", len(spy.opts)) + } + + opts := spy.opts[0] + if !strings.HasPrefix(filepath.Base(opts.Cwd), "cloudsecurity-"+cloudConnectorAgentName+"-") { + t.Errorf("Cwd = %q, want a cloudsecurity-recon-cloud-connector- temp dir", opts.Cwd) + } + if opts.ProjectDir != opts.Cwd { + t.Errorf("ProjectDir = %q, want it equal to Cwd %q (Python: repo_path = harness_cwd)", opts.ProjectDir, opts.Cwd) + } + // Contract item 2: this agent DOES clean up (Python's finally: rmtree). + if _, err := os.Stat(opts.Cwd); !os.IsNotExist(err) { + t.Errorf("work dir %q must be removed after the call (stat err = %v)", opts.Cwd, err) + } +} + +// Contract item 5. +func TestRunCloudConnector_HarnessErrorUsesThePythonMessage(t *testing.T) { + spy := newFailingSpy("no credentials") + _, err := RunCloudConnector(context.Background(), spy.Fake, cloudConfigCaseA()) + if err == nil { + t.Fatal("want an error") + } + if want := "Cloud connector harness error: no credentials"; err.Error() != want { + t.Errorf("error = %q, want %q", err, want) + } + // Even on the error path the finally block runs. + if _, statErr := os.Stat(spy.opts[0].Cwd); !os.IsNotExist(statErr) { + t.Errorf("work dir must be removed on the error path too (stat err = %v)", statErr) + } +} + +// --------------------------------------------------------------------------- +// run_drift_detector +// --------------------------------------------------------------------------- + +// Contract items 2, 3 and 4. +func TestRunDriftDetector_HarnessOptionsAndTempDirLifecycle(t *testing.T) { + spy := newSpy(`{"drifted_resources":[],"iac_only_resources":["a"],"cloud_only_resources":["b"]}`) + + got, err := RunDriftDetector(context.Background(), spy.Fake, "/fixture/work/graph.json", cloudConfigCaseA()) + if err != nil { + t.Fatalf("RunDriftDetector: %v", err) + } + if len(got.IaCOnlyResources) != 1 || got.IaCOnlyResources[0] != "a" { + t.Errorf("IaCOnlyResources = %v, want [a]", got.IaCOnlyResources) + } + if len(spy.opts) != 1 { + t.Fatalf("harness calls = %d, want 1", len(spy.opts)) + } + + opts := spy.opts[0] + if !strings.HasPrefix(filepath.Base(opts.Cwd), "cloudsecurity-"+driftDetectorAgentName+"-") { + t.Errorf("Cwd = %q, want a cloudsecurity-recon-drift-detector- temp dir", opts.Cwd) + } + if opts.ProjectDir != opts.Cwd { + t.Errorf("ProjectDir = %q, want it equal to Cwd %q", opts.ProjectDir, opts.Cwd) + } + if _, err := os.Stat(opts.Cwd); !os.IsNotExist(err) { + t.Errorf("work dir %q must be removed after the call (stat err = %v)", opts.Cwd, err) + } +} + +// Contract item 5. +func TestRunDriftDetector_HarnessErrorUsesThePythonMessage(t *testing.T) { + spy := newFailingSpy("timeout") + _, err := RunDriftDetector(context.Background(), spy.Fake, "/fixture/work/graph.json", cloudConfigCaseA()) + if err == nil { + t.Fatal("want an error") + } + if want := "Drift detector harness error: timeout"; err.Error() != want { + t.Errorf("error = %q, want %q", err, want) + } + if _, statErr := os.Stat(spy.opts[0].Cwd); !os.IsNotExist(statErr) { + t.Errorf("work dir must be removed on the error path too (stat err = %v)", statErr) + } +} + +// The ported Python bug: `{{IAC_GRAPH_PATH}}` is substituted but the template +// says `{{IAC_GRAPH_JSON}}`, so the graph path never reaches the model. +func TestBuildDriftDetectorPrompt_GraphPathSubstitutionIsANoOp(t *testing.T) { + prompt, err := BuildDriftDetectorPrompt("/fixture/work/graph.json", cloudConfigCaseA()) + if err != nil { + t.Fatal(err) + } + if strings.Contains(prompt, "/fixture/work/graph.json") { + t.Error("the graph path must NOT appear — Python substitutes a placeholder the template does not contain") + } + if !strings.Contains(prompt, "{{IAC_GRAPH_JSON}}") { + t.Error("the literal {{IAC_GRAPH_JSON}} token must survive into the prompt, as it does in Python") + } +} diff --git a/go/internal/agents/recon/cloud_connector.go b/go/internal/agents/recon/cloud_connector.go new file mode 100644 index 0000000..ed52e78 --- /dev/null +++ b/go/internal/agents/recon/cloud_connector.go @@ -0,0 +1,68 @@ +package recon + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/harnessx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/prompts" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// cloudConnectorPromptPath is PROMPT_PATH in cloud_connector.py. +const cloudConnectorPromptPath = "recon/cloud_connector.txt" + +// cloudConnectorAgentName is the `agent_name` local in run_cloud_connector; it +// feeds the tempdir prefix `f"cloudsecurity-{agent_name}-"`. +const cloudConnectorAgentName = "recon-cloud-connector" + +// RunCloudConnector ports run_cloud_connector in +// src/cloudsecurity_af/agents/recon/cloud_connector.py. +// +// prompt = template.replace("{{CLOUD_CONFIG_JSON}}", json.dumps(cloud_config, indent=2)) +// harness_cwd = tempfile.mkdtemp(prefix="cloudsecurity-recon-cloud-connector-") +// repo_path = harness_cwd +// try: result = await app.harness(prompt=prompt, schema=ResourceInventory, +// cwd=harness_cwd, project_dir=repo_path) +// return extract_harness_result(result, ResourceInventory, "Cloud connector") +// finally: shutil.rmtree(harness_cwd, ignore_errors=True) +// +// Python parity: this agent has NO deterministic fast path — it always calls the +// harness — and unlike the two IaC agents it DOES clean its temp dir up, in a +// `finally`, because nothing downstream reads a file out of it. `project_dir` +// is the temp dir itself, not a repository. +func RunCloudConnector(ctx context.Context, app appx.Harnesser, cloudConfig map[string]any) (schemas.ResourceInventory, error) { + prompt, err := BuildCloudConnectorPrompt(cloudConfig) + if err != nil { + return schemas.ResourceInventory{}, err + } + + harnessCwd, err := os.MkdirTemp("", "cloudsecurity-"+cloudConnectorAgentName+"-") + if err != nil { + return schemas.ResourceInventory{}, fmt.Errorf("cloudsecurity recon: creating cloud-connector work dir: %w", err) + } + // Python: `finally: shutil.rmtree(harness_cwd, ignore_errors=True)`. + defer func() { _ = os.RemoveAll(harnessCwd) }() // ignore_errors=True + + repoPath := harnessCwd + return harnessx.RunExtract[schemas.ResourceInventory]( + ctx, app, prompt, + harness.Options{Cwd: harnessCwd, ProjectDir: repoPath}, + "Cloud connector", + ) +} + +// BuildCloudConnectorPrompt renders the cloud-connector harness prompt. +// Exported for the golden test. +func BuildCloudConnectorPrompt(cloudConfig map[string]any) (string, error) { + template, err := prompts.Load(cloudConnectorPromptPath) + if err != nil { + return "", err + } + return strings.ReplaceAll(template, "{{CLOUD_CONFIG_JSON}}", cloudConfigJSON(cloudConfig)), nil +} diff --git a/go/internal/agents/recon/cloudconfig.go b/go/internal/agents/recon/cloudconfig.go new file mode 100644 index 0000000..61cfa21 --- /dev/null +++ b/go/internal/agents/recon/cloudconfig.go @@ -0,0 +1,78 @@ +package recon + +import ( + "sort" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" +) + +// cloudConfigFieldOrder is the declaration order of the pydantic CloudConfig +// model in src/cloudsecurity_af/schemas/input.py. +// +// WHY IT IS HARD-CODED. The cloud_connector and drift_detector prompts embed +// `json.dumps(cloud_config, indent=2)`, and the key order of that JSON reaches +// the LLM verbatim. On the Python side cloud_config is produced by +// `self.input.cloud.model_dump()` (orchestrator.py), travels to the reasoner as +// a JSON object, and comes back out of `json.loads` with its DECLARATION order +// intact — pydantic dumps fields in declaration order and both json libraries +// preserve document order. In Go the reasoner receives a map[string]any, which +// has no order at all, so the order has to be restored from the model. +func cloudConfigFieldOrder() []string { + return []string{"provider", "regions", "account_id", "assume_role_arn"} +} + +// orderCloudConfig turns the unordered map a Go reasoner receives back into the +// key order Python's json.dumps would have emitted: the CloudConfig fields +// first, in declaration order, then any extra keys sorted alphabetically. +// +// Extra keys cannot occur for a CloudConfig-shaped payload (pydantic's default +// config ignores unknown input fields, so model_dump only ever emits the four), +// but sorting them keeps the output deterministic if a caller passes something +// else — Go map iteration order is randomized on purpose. +func orderCloudConfig(cloudConfig map[string]any) pyfmt.Ordered { + // Python parity: `json.dumps(None, indent=2)` is "null", which is what a + // nil map must render as — NOT "{}". + if cloudConfig == nil { + return nil + } + + out := make(pyfmt.Ordered, 0, len(cloudConfig)) + seen := map[string]bool{} + for _, field := range cloudConfigFieldOrder() { + if v, ok := cloudConfig[field]; ok { + out = append(out, pyfmt.KV{K: field, V: v}) + seen[field] = true + } + } + extra := make([]string, 0) + for k := range cloudConfig { + if !seen[k] { + extra = append(extra, k) + } + } + sort.Strings(extra) + for _, k := range extra { + out = append(out, pyfmt.KV{K: k, V: cloudConfig[k]}) + } + return out +} + +// cloudConfigJSON renders cloud_config the way the two harness prompts do: +// `json.dumps(cloud_config, indent=2)`. +// +// NUMBER CAVEAT: if the caller obtained the map from encoding/json without +// Decoder.UseNumber, every JSON number in it is a float64 and renders with a +// trailing ".0" where Python would print an int. CloudConfig has no numeric +// field (provider and assume_role_arn are strings, regions a list of strings, +// account_id a string), so no real payload reaches this; a caller that decodes +// with UseNumber is exact either way, because pyfmt.Dumps handles json.Number. +func cloudConfigJSON(cloudConfig map[string]any) string { + if cloudConfig == nil { + // Python parity: the reasoner signature types cloud_config as + // dict[str, Any], but nothing enforces it at runtime and phases.py only + // calls these two reasoners when cloud_config is not None. A nil map + // still has to produce valid JSON rather than panic. + return "null" + } + return pyfmt.Dumps(orderCloudConfig(cloudConfig), 2) +} diff --git a/go/internal/agents/recon/doc.go b/go/internal/agents/recon/doc.go new file mode 100644 index 0000000..927e5c4 --- /dev/null +++ b/go/internal/agents/recon/doc.go @@ -0,0 +1,60 @@ +// Package recon ports src/cloudsecurity_af/agents/recon/** — the RECON phase's +// four agents plus the two deterministic engines they prefer over the LLM. +// +// Python Go +// ------------------------------------------- --------------------------------- +// recon/_terraform_parser.parse_terraform_... ParseTerraformDirectory (tfparse.go) +// recon/_graph_builder_fast.build_graph_from... BuildGraphFromInventory (graphfast.go) +// recon/iac_reader.run_iac_reader RunIaCReader (iac_reader.go) +// recon/resource_graph_builder.run_resource_... RunResourceGraphBuilder (resource_graph_builder.go) +// recon/cloud_connector.run_cloud_connector RunCloudConnector (cloud_connector.go) +// recon/drift_detector.run_drift_detector RunDriftDetector (drift_detector.go) +// +// The four Run* functions are what internal/reasoners wraps as the +// `run_iac_reader`, `run_resource_graph_builder`, `run_cloud_connector` and +// `run_drift_detector` router reasoners; internal/phases drives them through +// app.Call, never in-process, exactly as recon_phase does in Python. +// +// SHAPE OF THE PHASE. run_iac_reader and run_resource_graph_builder each try a +// deterministic, offline path first (a real Terraform parse; a pure function +// over inventory.json) and only fall back to the harness when that raises. The +// two Tier-2 agents, run_cloud_connector and run_drift_detector, are +// harness-only — they enumerate a live cloud account, which no local parser can +// do. +// +// # Divergences from Python, in one place +// +// 1. NON-CONSTANT TERRAFORM EXPRESSIONS. Python renders them as pyhcl2 AST +// reprs (with source byte offsets); Go renders them as their source text. +// This is the design contract's instruction and the only change with +// observable downstream effects — `references`, `referenced_by` and the +// graph's edges differ. Read the long note on exprToValue in tfparse.go for +// the full accounting, and TestParseTerraformDirectory_ReferenceDivergence +// for the exact per-resource delta on the checked-in fixture. +// 2. `null` and negated numeric literals evaluate in Go and stringify to a +// repr in Python. Same root cause as (1). +// 3. Heredoc bodies keep their `<<-EOT ... EOT` wrapper in Go where pyhcl2 +// hands back the dedented body. +// 4. Object key order is preserved everywhere it is observable (pyfmt.Load +// decodes into pyfmt.Ordered and pyfmt.Dumps writes that order back out); +// the only sorted-instead-of-insertion-ordered cases are values Go cannot +// recover an order for, each commented at its site. +// 5. Log lines for the fallback warnings are formatted by the Go port rather +// than by Python's logging module; the message text is verbatim. +// 6. `json.dump(inventory, f, indent=2, default=str)`'s `default=str` arm has +// no Go counterpart. This package used to carry its own json.dumps copy +// that rendered an unknown Go type as a JSON string; it was folded into +// pyfmt.Dumps at integration time, and pyfmt.Dumps walks an unknown struct +// into a JSON object instead. Unreachable in practice: ctyToValue maps +// every Terraform value into +// `nil | bool | string | int | float64 | []any | pyfmt.Ordered` before it is +// written, and orderCloudConfig only ever holds JSON-decoded values, so no +// output byte changes. The Python-ground-truth table that pinned the old +// copy now lives in internal/pyfmt/pyjson_valuemodel_test.go. +// +// Everything else — resource ids, types, names, provider mapping, file paths, +// the inventory/graph key sets and their order, cluster keys, edge-type +// inference, the four prompt strings, the harness Cwd/ProjectDir, the temp-dir +// prefixes, the extract error strings and which work dirs are cleaned up — is +// byte-for-byte the Python behavior. +package recon diff --git a/go/internal/agents/recon/drift_detector.go b/go/internal/agents/recon/drift_detector.go new file mode 100644 index 0000000..001467e --- /dev/null +++ b/go/internal/agents/recon/drift_detector.go @@ -0,0 +1,73 @@ +package recon + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/harnessx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/prompts" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// driftDetectorPromptPath is PROMPT_PATH in drift_detector.py. +const driftDetectorPromptPath = "recon/drift_detector.txt" + +// driftDetectorAgentName is the `agent_name` local in run_drift_detector. +const driftDetectorAgentName = "recon-drift-detector" + +// RunDriftDetector ports run_drift_detector in +// src/cloudsecurity_af/agents/recon/drift_detector.py. +// +// prompt = template.replace("{{IAC_GRAPH_PATH}}", iac_graph_path).replace( +// "{{CLOUD_CONFIG_JSON}}", json.dumps(cloud_config, indent=2)) +// harness_cwd = tempfile.mkdtemp(prefix="cloudsecurity-recon-drift-detector-") +// repo_path = harness_cwd +// try: result = await app.harness(prompt=prompt, schema=DriftReport, +// cwd=harness_cwd, project_dir=repo_path) +// return extract_harness_result(result, DriftReport, "Drift detector") +// finally: shutil.rmtree(harness_cwd, ignore_errors=True) +func RunDriftDetector(ctx context.Context, app appx.Harnesser, iacGraphPath string, cloudConfig map[string]any) (schemas.DriftReport, error) { + prompt, err := BuildDriftDetectorPrompt(iacGraphPath, cloudConfig) + if err != nil { + return schemas.DriftReport{}, err + } + + harnessCwd, err := os.MkdirTemp("", "cloudsecurity-"+driftDetectorAgentName+"-") + if err != nil { + return schemas.DriftReport{}, fmt.Errorf("cloudsecurity recon: creating drift-detector work dir: %w", err) + } + defer func() { _ = os.RemoveAll(harnessCwd) }() // ignore_errors=True + + repoPath := harnessCwd + return harnessx.RunExtract[schemas.DriftReport]( + ctx, app, prompt, + harness.Options{Cwd: harnessCwd, ProjectDir: repoPath}, + "Drift detector", + ) +} + +// BuildDriftDetectorPrompt renders the drift-detector harness prompt. Exported +// for the golden test. +// +// PYTHON PARITY BUG, REPRODUCED VERBATIM: the Python substitutes +// `{{IAC_GRAPH_PATH}}`, but prompts/recon/drift_detector.txt contains +// `{{IAC_GRAPH_JSON}}`. The replacement is therefore a NO-OP: the graph path is +// never interpolated and the literal token `{{IAC_GRAPH_JSON}}` is what reaches +// the model, which is why the prompt tells it to "Parse IaC graph nodes/edges" +// with no graph in sight. The Go port performs the same no-op replacement so the +// prompt bytes match; fixing it would change what the LLM sees and is a Python- +// side change, not a port change. +func BuildDriftDetectorPrompt(iacGraphPath string, cloudConfig map[string]any) (string, error) { + template, err := prompts.Load(driftDetectorPromptPath) + if err != nil { + return "", err + } + prompt := strings.ReplaceAll(template, "{{IAC_GRAPH_PATH}}", iacGraphPath) + prompt = strings.ReplaceAll(prompt, "{{CLOUD_CONFIG_JSON}}", cloudConfigJSON(cloudConfig)) + return prompt, nil +} diff --git a/go/internal/agents/recon/golden_test.go b/go/internal/agents/recon/golden_test.go new file mode 100644 index 0000000..83d9acc --- /dev/null +++ b/go/internal/agents/recon/golden_test.go @@ -0,0 +1,173 @@ +package recon + +import ( + "os" + "path/filepath" + "testing" +) + +// The four RECON prompts must be BYTE-IDENTICAL to what the Python agents hand +// to app.harness. The goldens under testdata/golden/ are captured from the real +// Python coroutines by go/scripts/gen_golden.py — they are not transcriptions — +// so a drift in either the template or the substitution logic fails here. +// +// The inputs below MUST stay in sync with the constants at the top of +// gen_golden.py; regenerate the goldens after changing them. + +const ( + goldenRepoPath = "/fixture/repo" + goldenInventoryPath = "/fixture/work/inventory.json" + goldenIaCGraphPath = "/fixture/work/graph.json" +) + +// cloudConfigCaseA is CloudConfig().model_dump() — the defaults, with the two +// optional fields explicitly None. +func cloudConfigCaseA() map[string]any { + return map[string]any{ + "provider": "aws", + "regions": []any{"us-east-1"}, + "account_id": nil, + "assume_role_arn": nil, + } +} + +// cloudConfigCaseB has every field populated. +func cloudConfigCaseB() map[string]any { + return map[string]any{ + "provider": "gcp", + "regions": []any{"us-central1", "europe-west1"}, + "account_id": "123456789012", + "assume_role_arn": "arn:aws:iam::123456789012:role/cloudsecurity-scanner", + } +} + +// cloudConfigCaseC is the empty-dict edge case. +func cloudConfigCaseC() map[string]any { return map[string]any{} } + +func goldenPrompt(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "golden", name)) + if err != nil { + t.Fatalf("reading golden %s (regenerate with go/scripts/gen_golden.py): %v", name, err) + } + return string(b) +} + +func assertGolden(t *testing.T, name, got string) { + t.Helper() + want := goldenPrompt(t, name) + if got == want { + return + } + // Report the first differing line so the failure is readable for a + // multi-kilobyte prompt. + gl, wl := splitLines(got), splitLines(want) + for i := 0; i < len(gl) || i < len(wl); i++ { + var g, w string + if i < len(gl) { + g = gl[i] + } + if i < len(wl) { + w = wl[i] + } + if g != w { + t.Fatalf("%s: first difference at line %d\n got: %q\nwant: %q", name, i+1, g, w) + } + } + t.Fatalf("%s: prompts differ but every line matches (trailing-newline drift?): got %d bytes, want %d", name, len(got), len(want)) +} + +func splitLines(s string) []string { + var 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 TestBuildIaCReaderPrompt_MatchesPython(t *testing.T) { + got, err := BuildIaCReaderPrompt(goldenRepoPath) + if err != nil { + t.Fatal(err) + } + assertGolden(t, "iac_reader_prompt.txt", got) +} + +func TestBuildResourceGraphBuilderPrompt_MatchesPython(t *testing.T) { + got, err := BuildResourceGraphBuilderPrompt(goldenInventoryPath) + if err != nil { + t.Fatal(err) + } + assertGolden(t, "resource_graph_builder_prompt.txt", got) +} + +func TestBuildCloudConnectorPrompt_MatchesPython(t *testing.T) { + cases := map[string]map[string]any{ + "cloud_connector_prompt_a.txt": cloudConfigCaseA(), + "cloud_connector_prompt_b.txt": cloudConfigCaseB(), + "cloud_connector_prompt_c.txt": cloudConfigCaseC(), + } + for name, cfg := range cases { + t.Run(name, func(t *testing.T) { + got, err := BuildCloudConnectorPrompt(cfg) + if err != nil { + t.Fatal(err) + } + assertGolden(t, name, got) + }) + } +} + +func TestBuildDriftDetectorPrompt_MatchesPython(t *testing.T) { + cases := map[string]map[string]any{ + "drift_detector_prompt_a.txt": cloudConfigCaseA(), + "drift_detector_prompt_b.txt": cloudConfigCaseB(), + "drift_detector_prompt_c.txt": cloudConfigCaseC(), + } + for name, cfg := range cases { + t.Run(name, func(t *testing.T) { + got, err := BuildDriftDetectorPrompt(goldenIaCGraphPath, cfg) + if err != nil { + t.Fatal(err) + } + assertGolden(t, name, got) + }) + } +} + +// The cloud_config JSON embedded in both prompts must reproduce +// `json.dumps(cloud_config, indent=2)`, whose key order is the pydantic +// CloudConfig declaration order — NOT Go's randomized map order and not +// alphabetical. +func TestCloudConfigJSON_UsesPydanticFieldOrder(t *testing.T) { + want := "{\n" + + " \"provider\": \"aws\",\n" + + " \"regions\": [\n \"us-east-1\"\n ],\n" + + " \"account_id\": null,\n" + + " \"assume_role_arn\": null\n" + + "}" + for i := 0; i < 20; i++ { + if got := cloudConfigJSON(cloudConfigCaseA()); got != want { + t.Fatalf("iteration %d:\n got: %q\nwant: %q", i, got, want) + } + } + + if got := cloudConfigJSON(map[string]any{}); got != "{}" { + t.Errorf("empty config = %q, want %q", got, "{}") + } + if got := cloudConfigJSON(nil); got != "null" { + t.Errorf("nil config = %q, want %q", got, "null") + } + + // Unknown keys cannot occur for a CloudConfig payload, but they must still + // be deterministic: known fields first, extras sorted after them. + got := cloudConfigJSON(map[string]any{"zeta": 1, "provider": "aws", "alpha": 2}) + want = "{\n \"provider\": \"aws\",\n \"alpha\": 2,\n \"zeta\": 1\n}" + if got != want { + t.Errorf("extra keys:\n got: %q\nwant: %q", got, want) + } +} diff --git a/go/internal/agents/recon/graphfast.go b/go/internal/agents/recon/graphfast.go new file mode 100644 index 0000000..eef2c54 --- /dev/null +++ b/go/internal/agents/recon/graphfast.go @@ -0,0 +1,435 @@ +package recon + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" +) + +// This file ports src/cloudsecurity_af/agents/recon/_graph_builder_fast.py — +// the deterministic ResourceGraph builder that reads inventory.json and writes +// graph.json without ever calling the harness. + +// edgeTypeRule is one entry of Python's _EDGE_TYPE_MAP. +type edgeTypeRule struct { + Keyword string + Type string +} + +// edgeTypeMap is _EDGE_TYPE_MAP. +// +// ORDER IS LOAD-BEARING: _infer_edge_type returns the FIRST entry whose keyword +// appears in either type, and Python iterates a dict in insertion order. A Go +// map would be iterated in randomized order and pick a different edge type run +// to run, so this is a slice in the exact source order of the Python literal. +var edgeTypeMap = []edgeTypeRule{ + {"iam", "trust"}, + {"role", "trust"}, + {"policy", "trust"}, + {"assume", "trust"}, + {"subnet", "network_path"}, + {"security_group", "network_path"}, + {"route", "network_path"}, + {"vpc", "network_path"}, + {"lb", "network_path"}, + {"elb", "network_path"}, + {"alb", "network_path"}, + {"nlb", "network_path"}, + {"gateway", "network_path"}, + {"igw", "network_path"}, + {"nat", "network_path"}, + {"nacl", "network_path"}, + {"network_interface", "network_path"}, + {"peering", "network_path"}, + {"endpoint", "network_path"}, + {"flow_log", "network_path"}, + {"bucket", "data_access"}, + {"dynamodb", "data_access"}, + {"rds", "data_access"}, + {"db_instance", "data_access"}, + {"db_subnet", "data_access"}, + {"db_option", "data_access"}, + {"db_parameter", "data_access"}, + {"s3", "data_access"}, + {"kms", "data_access"}, + {"sqs", "data_access"}, + {"sns", "data_access"}, + {"neptune", "data_access"}, + {"elasticsearch", "data_access"}, + {"es_domain", "data_access"}, + {"redshift", "data_access"}, + {"ebs", "data_access"}, + {"efs", "data_access"}, + {"backup", "data_access"}, + {"snapshot", "data_access"}, + {"lambda", "execution"}, + {"function", "execution"}, + {"instance", "execution"}, + {"ecs", "execution"}, + {"eks", "execution"}, + {"ecr", "execution"}, + {"task", "execution"}, + {"fargate", "execution"}, + {"node_group", "execution"}, + {"launch_template", "execution"}, + {"auto_scaling", "execution"}, +} + +// inferEdgeType ports _infer_edge_type. +// +// Python parity: callers pass a resource ID where the parameter is named +// "type" (`_infer_edge_type(source_type, ref)`), which is intentional — the +// substring test works on both. +func inferEdgeType(sourceType, targetType string) string { + for _, rule := range edgeTypeMap { + if strings.Contains(sourceType, rule.Keyword) || strings.Contains(targetType, rule.Keyword) { + return rule.Type + } + } + return "references" +} + +// networkClusterKeywords / identityClusterKeywords / dataClusterKeywords / +// computeClusterKeywords are the four keyword tuples of _cluster_key, in source +// order (irrelevant to the result — the test is `any(...)` — but kept so the +// tables diff cleanly against the Python). +var ( + networkClusterKeywords = []string{ + "vpc", "subnet", "security_group", "route", "gateway", "igw", "nat", "nacl", + "elb", "alb", "nlb", "lb", "network_interface", "peering", "endpoint", "flow_log", + } + identityClusterKeywords = []string{"iam", "role", "policy", "user", "group", "access_key"} + dataClusterKeywords = []string{ + "s3", "bucket", "rds", "db_instance", "dynamodb", "neptune", "elasticsearch", + "redshift", "ebs", "efs", "kms", "snapshot", "backup", + } + computeClusterKeywords = []string{"lambda", "function", "instance", "ecs", "eks", "ecr", "fargate"} +) + +// configSummaryKeywords is the keyword tuple of the `security_attrs` dict +// comprehension in build_graph_from_inventory. +var configSummaryKeywords = []string{ + "encrypt", "public", "acl", "policy", "logging", "ssl", "tls", "secret", + "password", "key", "auth", "cidr", "ingress", "egress", "port", "protocol", "versioning", +} + +func containsAny(haystack string, keywords []string) bool { + for _, kw := range keywords { + if strings.Contains(haystack, kw) { + return true + } + } + return false +} + +// clusterKey ports _cluster_key. +// +// Python parity — THIS FUNCTION IS WHERE A MALFORMED INVENTORY BLOWS UP, and +// blowing up is load-bearing: run_resource_graph_builder wraps the whole fast +// path in `except Exception` and falls back to the LLM harness, so a raise here +// is what makes a bad inventory.json produce a REAL graph instead of a silently +// edge-less one. +// +// parts = file_path.split("/") -> AttributeError when file_path is not a str +// any(kw in rtype for kw in ...) -> TypeError when rtype is not a str +// +// The order matters and is reproduced: `file_path.split` runs before the rtype +// membership tests, so a resource with both fields malformed raises the +// AttributeError. Verified against the repo venv. +func clusterKey(resource pyfmt.Ordered) (string, error) { + // Python: `file_path.split("/")`. + filePath, badType := invStr(resource, "file_path") + if badType != "" { + return "", fmt.Errorf("'%s' object has no attribute 'split'", badType) + } + // Python: `kw in rtype`, whose TypeError message names the ARGUMENT type. + rtype, badType := invStr(resource, "type") + if badType != "" { + return "", fmt.Errorf("argument of type '%s' is not iterable", badType) + } + + parts := strings.Split(filePath, "/") + moduleDir := "root" + if len(parts) > 1 { + moduleDir = strings.Join(parts[:len(parts)-1], "/") + } + + switch { + case containsAny(rtype, networkClusterKeywords): + return "network/" + moduleDir, nil + case containsAny(rtype, identityClusterKeywords): + return "identity/" + moduleDir, nil + case containsAny(rtype, dataClusterKeywords): + return "data/" + moduleDir, nil + case containsAny(rtype, computeClusterKeywords): + return "compute/" + moduleDir, nil + } + return "general/" + moduleDir, nil +} + +// BuildGraphFromInventory ports build_graph_from_inventory: read inventory.json +// and write graph.json deterministically. +// +// Returns (graphPath, totalNodes, totalEdges) — the Python tuple — plus an +// error for the failures Python raises on (open/json.load/makedirs/write). +func BuildGraphFromInventory(inventoryPath, outputDir string) (string, int, int, error) { + raw, err := os.ReadFile(inventoryPath) + if err != nil { + return "", 0, 0, fmt.Errorf("cloudsecurity recon: reading %s: %w", inventoryPath, err) + } + decoded, err := pyfmt.Load(raw) + if err != nil { + return "", 0, 0, fmt.Errorf("cloudsecurity recon: parsing %s: %w", inventoryPath, err) + } + + // Python: `if not isinstance(inv, dict): inv = {"resources": []}`, then the + // same defensive shape checks on `resources` and on each element. + inv, _ := decoded.(pyfmt.Ordered) + var rawResources []any + if v, ok := inv.Get("resources"); ok { + rawResources, _ = v.([]any) + } + resources := make([]pyfmt.Ordered, 0, len(rawResources)) + for _, r := range rawResources { + if m, ok := r.(pyfmt.Ordered); ok { + resources = append(resources, m) + } + } + + // --- nodes --- + nodes := []any{} + resourceIDs := map[string]struct{}{} + for _, r := range resources { + rid := invString(r, "id") + resourceIDs[rid] = struct{}{} + + securityAttrs := pyfmt.Ordered{} + if cfg, ok := r.Get("config"); ok { + if cfgMap, isMap := cfg.(pyfmt.Ordered); isMap { + for _, kv := range cfgMap { + if containsAny(strings.ToLower(kv.K), configSummaryKeywords) { + securityAttrs = append(securityAttrs, kv) + } + } + } + } + + // Python parity: the node dict COPIES these three through without a + // string operation, so a non-string one does not raise here — it + // raises later, in _infer_edge_type or _cluster_key. + nodes = append(nodes, pyfmt.Ordered{ + {K: "resource_id", V: rid}, + {K: "resource_type", V: invString(r, "type")}, + {K: "provider", V: invString(r, "provider")}, + {K: "file_path", V: invString(r, "file_path")}, + {K: "config_summary", V: securityAttrs}, + }) + } + + // --- edges --- + edges := []any{} + seenEdges := map[string]struct{}{} + for _, r := range resources { + sourceID := invString(r, "id") + sourceType := invString(r, "type") + + refs, err := invStrings(r, "references") + if err != nil { + return "", 0, 0, err + } + for _, ref := range refs { + if _, known := resourceIDs[ref]; known && ref != sourceID { + edgeKey := sourceID + "->" + ref + if _, seen := seenEdges[edgeKey]; !seen { + seenEdges[edgeKey] = struct{}{} + edges = append(edges, pyfmt.Ordered{ + {K: "source", V: sourceID}, + {K: "target", V: ref}, + {K: "type", V: inferEdgeType(sourceType, ref)}, + }) + } + } + } + refBys, err := invStrings(r, "referenced_by") + if err != nil { + return "", 0, 0, err + } + for _, refBy := range refBys { + if _, known := resourceIDs[refBy]; known && refBy != sourceID { + edgeKey := refBy + "->" + sourceID + if _, seen := seenEdges[edgeKey]; !seen { + seenEdges[edgeKey] = struct{}{} + edges = append(edges, pyfmt.Ordered{ + {K: "source", V: refBy}, + {K: "target", V: sourceID}, + {K: "type", V: inferEdgeType(refBy, sourceType)}, + }) + } + } + } + } + + // --- clusters --- + clusterMap := map[string][]any{} + for _, r := range resources { + ck, err := clusterKey(r) + if err != nil { + return "", 0, 0, err + } + clusterMap[ck] = append(clusterMap[ck], invString(r, "id")) + } + clusterNames := make([]string, 0, len(clusterMap)) + for name := range clusterMap { + clusterNames = append(clusterNames, name) + } + sort.Strings(clusterNames) // Python: sorted(cluster_map.items()) + clusters := []any{} + for _, name := range clusterNames { + clusters = append(clusters, pyfmt.Ordered{ + {K: "name", V: name}, + {K: "members", V: clusterMap[name]}, + }) + } + + graph := pyfmt.Ordered{ + {K: "nodes", V: nodes}, + {K: "edges", V: edges}, + {K: "clusters", V: clusters}, + } + + if err := os.MkdirAll(outputDir, 0o777); err != nil { + return "", 0, 0, fmt.Errorf("cloudsecurity recon: creating %s: %w", outputDir, err) + } + graphPath := filepath.Join(outputDir, "graph.json") + if err := os.WriteFile(graphPath, []byte(pyfmt.Dumps(graph, 2)), 0o666); err != nil { + return "", 0, 0, fmt.Errorf("cloudsecurity recon: writing %s: %w", graphPath, err) + } + + return graphPath, len(nodes), len(edges), nil +} + +// invString is Python's `r.get(key, "")` for a value that is only ever COPIED +// into the graph document, never used in a string operation: a missing key, a +// null or a non-string all render as "". +// +// DIVERGENCE (verified against the repo venv, deliberately kept): Python copies +// such a value through unchanged, so an inventory whose resource has `"id": 7` +// produces `"resource_id": 7` in graph.json and does NOT raise. Go writes "". +// Reproducing it would mean carrying `any` ids through resource_ids, the +// seen_edges keys and the cluster members — where an unhashable id would panic +// instead of raising Python's TypeError. Only `id` and `provider` reach this +// helper; every value Python performs a string operation on goes through invStr +// and RAISES, because that raise is what selects the harness fallback. +func invString(o pyfmt.Ordered, key string) string { + v, ok := o.Get(key) + if !ok { + return "" + } + s, _ := v.(string) + return s +} + +// invStr is Python's `r.get(key, "")` for a value the code then performs a +// STRING operation on (`file_path.split`, `kw in rtype`). +// +// The second result is the Python type name of a present-but-not-a-str value +// ("" when the value is a str or the key is absent, both of which Python +// tolerates). The caller turns it into the exact exception message Python +// raises at its own call site, so the diagnostic the harness fallback logs +// matches. +func invStr(o pyfmt.Ordered, key string) (string, string) { + v, ok := o.Get(key) + if !ok { + // Python: the "" default, on which .split and `in` both work. + return "", "" + } + if s, isStr := v.(string); isStr { + return s, "" + } + return "", pyTypeName(v) +} + +// invStrings ports the ITERATION `for ref in r.get(key, [])`, not a typed read. +// +// Python duck-types the loop, so the port has to as well (all verified against +// the repo venv on src/cloudsecurity_af/agents/recon/_graph_builder_fast.py): +// +// absent / [] -> no iterations +// list -> its elements +// str "b" -> its CHARACTERS ("b" therefore yields the ref "b") +// dict {"b": 1} -> its KEYS +// None, int, float, +// bool -> TypeError: '' object is not iterable +// +// The TypeError is the point: run_resource_graph_builder catches it and falls +// back to the LLM harness, where coercing to "no references" would instead +// return a successful, silently edge-less graph (total_edges: 0, every hunter +// prompt saying "no edges matched this hunter domain") with no warning. +// +// Non-string ELEMENTS are dropped rather than compared: `ref in resource_ids` +// is a set-of-str membership test, so a non-string element can only match a +// non-string id, which invString has already coerced away. +func invStrings(o pyfmt.Ordered, key string) ([]string, error) { + v, ok := o.Get(key) + if !ok { + return nil, nil + } + switch x := v.(type) { + case []any: + out := make([]string, 0, len(x)) + for _, it := range x { + if s, isStr := it.(string); isStr { + out = append(out, s) + } + } + return out, nil + case string: + out := make([]string, 0, len(x)) + for _, r := range x { + out = append(out, string(r)) + } + return out, nil + case pyfmt.Ordered: + out := make([]string, 0, len(x)) + for _, kv := range x { + out = append(out, kv.K) + } + return out, nil + default: + return nil, fmt.Errorf("'%s' object is not iterable", pyTypeName(v)) + } +} + +// pyTypeName is type(v).__name__ for the value model pyfmt.Load produces +// (nil | bool | string | int | json.Number | float64 | []any | pyfmt.Ordered). +func pyTypeName(v any) string { + switch n := v.(type) { + case nil: + return "NoneType" + case bool: + return "bool" + case string: + return "str" + case int: + return "int" + case json.Number: + // An arbitrary-precision Python int, or a number whose literal was kept + // verbatim (see pyfmt.loadNumber). + if !strings.ContainsAny(string(n), ".eE") { + return "int" + } + return "float" + case float64: + return "float" + case []any: + return "list" + case pyfmt.Ordered: + return "dict" + } + return fmt.Sprintf("%T", v) +} diff --git a/go/internal/agents/recon/graphfast_malformed_test.go b/go/internal/agents/recon/graphfast_malformed_test.go new file mode 100644 index 0000000..0e738e8 --- /dev/null +++ b/go/internal/agents/recon/graphfast_malformed_test.go @@ -0,0 +1,166 @@ +package recon + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// VALIDATION CONTRACT — malformed inventory.json (derived by RUNNING +// src/cloudsecurity_af/agents/recon/_graph_builder_fast.py:build_graph_from_inventory +// under the repo venv, not by reading the Go code): +// +// inventory field observable Python behaviour +// ----------------------------- ----------------------------------------------- +// references: null TypeError: 'NoneType' object is not iterable +// references: 5 TypeError: 'int' object is not iterable +// referenced_by: null TypeError: 'NoneType' object is not iterable +// file_path: 12 AttributeError: 'int' object has no attribute 'split' +// file_path: null AttributeError: 'NoneType' object has no attribute 'split' +// type: 7 TypeError: argument of type 'int' is not iterable +// references: "b" NO raise — Python iterates the CHARACTERS, so a +// single-character id "b" DOES produce an edge +// references: {"b": 1} NO raise — Python iterates the dict's KEYS +// file_path absent NO raise — the "" default splits fine +// id: 7 NO raise — the value is copied into the node +// +// Every raise is caught by run_resource_graph_builder's `except Exception` and +// selects the harness fallback (resource_graph_builder.py:27-31). Coercing +// those fields to empty instead would return a SUCCESSFUL, silently edge-less +// graph — total_edges 0, "no edges matched this hunter domain" in every hunter +// prompt — with no error and no warning anywhere. + +// malformedResource is one inventory resource with every field spelled out, so +// each case below changes exactly one thing. +func malformedInventory(t *testing.T, resources ...map[string]any) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "inventory.json") + body, err := json.Marshal(map[string]any{"resources": resources}) + if err != nil { + t.Fatalf("marshal inventory: %v", err) + } + if err := os.WriteFile(path, body, 0o644); err != nil { + t.Fatalf("write inventory: %v", err) + } + return path +} + +func resource(overrides map[string]any) map[string]any { + r := map[string]any{ + "id": "a", + "type": "aws_s3_bucket", + "provider": "aws", + "file_path": "main.tf", + "config": map[string]any{}, + "references": []any{}, + "referenced_by": []any{}, + } + for k, v := range overrides { + r[k] = v + } + return r +} + +func TestBuildGraphFromInventory_RaisesWherePythonRaises(t *testing.T) { + cases := []struct { + name string + res map[string]any + wantErr string + }{ + {"references null", resource(map[string]any{"references": nil}), "'NoneType' object is not iterable"}, + {"references int", resource(map[string]any{"references": 5}), "'int' object is not iterable"}, + {"references bool", resource(map[string]any{"references": true}), "'bool' object is not iterable"}, + {"referenced_by null", resource(map[string]any{"referenced_by": nil}), "'NoneType' object is not iterable"}, + {"file_path int", resource(map[string]any{"file_path": 12}), "'int' object has no attribute 'split'"}, + {"file_path null", resource(map[string]any{"file_path": nil}), "'NoneType' object has no attribute 'split'"}, + {"type int", resource(map[string]any{"type": 7}), "argument of type 'int' is not iterable"}, + // Both malformed: Python's file_path.split runs first. + {"file_path and type", resource(map[string]any{"file_path": nil, "type": 7}), "'NoneType' object has no attribute 'split'"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := malformedInventory(t, tc.res) + _, _, _, err := BuildGraphFromInventory(path, t.TempDir()) + if err == nil { + t.Fatalf("BuildGraphFromInventory succeeded; Python raises %s and falls back to the harness", tc.wantErr) + } + if err.Error() != tc.wantErr { + t.Errorf("error = %q, want %q (the exact Python exception text the fallback logs)", err, tc.wantErr) + } + }) + } +} + +func TestBuildGraphFromInventory_DuckTypesTheReferenceIterationLikePython(t *testing.T) { + target := resource(map[string]any{"id": "b", "type": "aws_iam_role"}) + + t.Run("a string references field iterates its characters", func(t *testing.T) { + // Python: `for ref in "b"` yields "b", which IS a known resource id, so + // exactly one edge is built. Coercing to no-references drops it. + path := malformedInventory(t, resource(map[string]any{"references": "b"}), target) + graphPath, nodes, edges, err := BuildGraphFromInventory(path, t.TempDir()) + if err != nil { + t.Fatalf("BuildGraphFromInventory: %v", err) + } + if nodes != 2 || edges != 1 { + t.Fatalf("nodes=%d edges=%d, want 2 and 1", nodes, edges) + } + body, err := os.ReadFile(graphPath) + if err != nil { + t.Fatalf("read graph: %v", err) + } + if !strings.Contains(string(body), `"type": "data_access"`) { + t.Errorf("edge type must be data_access (aws_s3_bucket -> b), got %s", body) + } + }) + + t.Run("a dict references field iterates its keys", func(t *testing.T) { + path := malformedInventory(t, + resource(map[string]any{"id": "a", "type": "t", "references": map[string]any{"b": 1}}), + resource(map[string]any{"id": "b", "type": "t"})) + _, nodes, edges, err := BuildGraphFromInventory(path, t.TempDir()) + if err != nil { + t.Fatalf("BuildGraphFromInventory: %v", err) + } + if nodes != 2 || edges != 1 { + t.Fatalf("nodes=%d edges=%d, want 2 and 1", nodes, edges) + } + }) + + t.Run("an absent file_path is the empty-string default, not an error", func(t *testing.T) { + r := resource(nil) + delete(r, "file_path") + path := malformedInventory(t, r) + if _, _, _, err := BuildGraphFromInventory(path, t.TempDir()); err != nil { + t.Fatalf("BuildGraphFromInventory: %v", err) + } + }) +} + +// The end-to-end consequence: a malformed inventory must reach the LLM harness, +// which is the branch Python takes and the branch a silent coercion removed. +func TestRunResourceGraphBuilder_MalformedInventoryFallsBackToTheHarness(t *testing.T) { + warnings := silenceWarnings(t) + + spy := newSpy(`{"graph_saved_path":"/tmp/x/graph.json","total_nodes":4,"total_edges":2}`) + path := malformedInventory(t, resource(map[string]any{"references": nil})) + + got, err := RunResourceGraphBuilder(context.Background(), spy.Fake, fixtureRepoPath, path) + if err != nil { + t.Fatalf("RunResourceGraphBuilder: %v", err) + } + if got.TotalNodes != 4 || got.TotalEdges != 2 { + t.Errorf("harness result not returned: %+v", got) + } + if len(spy.opts) != 1 { + t.Fatalf("harness calls = %d, want 1 (the deterministic build must have raised)", len(spy.opts)) + } + t.Cleanup(func() { _ = os.RemoveAll(spy.opts[0].Cwd) }) + if w := warnings.String(); !strings.Contains(w, "Deterministic graph builder failed ('NoneType' object is not iterable)") { + t.Errorf("warning = %q, want Python's log.warning text with the exception", w) + } +} diff --git a/go/internal/agents/recon/graphfast_test.go b/go/internal/agents/recon/graphfast_test.go new file mode 100644 index 0000000..952a4b4 --- /dev/null +++ b/go/internal/agents/recon/graphfast_test.go @@ -0,0 +1,372 @@ +package recon + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" +) + +// VALIDATION CONTRACT for BuildGraphFromInventory (from +// build_graph_from_inventory in _graph_builder_fast.py): +// +// 1. One node per inventory resource, in inventory order, carrying +// {resource_id, resource_type, provider, file_path, config_summary}, where +// config_summary is the config entries — IN CONFIG ORDER — whose lowercased +// key contains one of the 17 security keywords. +// 2. One edge per (resource -> reference) and per (referenced_by -> resource) +// where the other end is a known resource id and is not the resource itself, +// de-duplicated on "->", first occurrence wins. +// 3. Edge type = the FIRST _EDGE_TYPE_MAP keyword (in declaration order) that +// is a substring of either end, else "references". +// 4. Clusters group resources by _cluster_key and are emitted sorted by name; +// members keep inventory order. +// 5. The file is graph.json inside output_dir, json.dump(..., indent=2). +// 6. The return value is (path, len(nodes), len(edges)). +// 7. Malformed inventories degrade to empty lists rather than raising. + +const pythonGraphFixture = "testdata/python/graph.json" + +// The strongest available assertion: given the SAME input the Python builder +// consumed, the Go builder must produce the SAME BYTES. This isolates the +// port's only behavioral divergence to the Terraform parser — the graph builder +// itself is exact. +func TestBuildGraphFromInventory_ByteIdenticalToPythonOnPythonInventory(t *testing.T) { + out := t.TempDir() + graphPath, nodes, edges, err := BuildGraphFromInventory(pythonInventoryFixture, out) + if err != nil { + t.Fatalf("BuildGraphFromInventory: %v", err) + } + + got, err := os.ReadFile(graphPath) + if err != nil { + t.Fatal(err) + } + want, err := os.ReadFile(pythonGraphFixture) + if err != nil { + t.Fatalf("reading %s (regenerate with go/scripts/gen_golden.py): %v", pythonGraphFixture, err) + } + if string(got) != string(want) { + t.Errorf("graph.json differs from Python's byte-for-byte\n--- go ---\n%s\n--- python ---\n%s", got, want) + } + + // Contract items 5 and 6. + if graphPath != filepath.Join(out, "graph.json") { + t.Errorf("graph path = %q, want %q", graphPath, filepath.Join(out, "graph.json")) + } + summary := loadPythonFixture(t, pythonSummaryFixture) + if wantNodes, _ := summary.Get("total_nodes"); nodes != wantNodes { + t.Errorf("total_nodes = %d, Python says %v", nodes, wantNodes) + } + if wantEdges, _ := summary.Get("total_edges"); edges != wantEdges { + t.Errorf("total_edges = %d, Python says %v", edges, wantEdges) + } +} + +// Contract items 1-4 driven by the Go parser's own inventory, which is where the +// reference divergence shows up: Go's richer reference set yields more edges. +func TestBuildGraphFromInventory_OnTheGoParsersInventory(t *testing.T) { + work := t.TempDir() + invPath, _, _, err := ParseTerraformDirectory(vulnerableInfraFixture, work) + if err != nil { + t.Fatal(err) + } + graphPath, nodes, edges, err := BuildGraphFromInventory(invPath, work) + if err != nil { + t.Fatal(err) + } + if nodes != 7 { + t.Errorf("nodes = %d, want 7 (one per resource, same as Python)", nodes) + } + // Python reports 1 edge because its AST-repr config hides the traversals; + // the Go parser surfaces them, so the graph gains the five real ones. + if edges != 6 { + t.Errorf("edges = %d, want 6", edges) + } + + graph := decodeGraph(t, graphPath) + gotEdges := map[string]string{} + for _, e := range sectionOf(t, graph, "edges") { + obj := asObject(t, e) + src := pyfmt.Str(mustGet(t, obj, "source")) + dst := pyfmt.Str(mustGet(t, obj, "target")) + gotEdges[src+"->"+dst] = pyfmt.Str(mustGet(t, obj, "type")) + } + // "iam" is the first _EDGE_TYPE_MAP keyword, so anything IAM-shaped at + // either end is "trust" even when the other end is a bucket. The + // public-access-block pair matches on "policy", also a trust keyword. + wantEdges := map[string]string{ + "aws_instance.web_server->aws_iam_instance_profile.web_profile": "trust", + "aws_instance.web_server->aws_security_group.allow_all": "network_path", + "aws_iam_instance_profile.web_profile->aws_iam_role.web_role": "trust", + "aws_iam_role_policy.s3_full_access->aws_iam_role.web_role": "trust", + "aws_iam_role_policy.s3_full_access->aws_s3_bucket.customer_data": "trust", + "aws_s3_bucket_public_access_block.customer_data_block->aws_s3_bucket.customer_data": "data_access", + } + if !reflect.DeepEqual(gotEdges, wantEdges) { + t.Errorf("edges = %#v\n want %#v", gotEdges, wantEdges) + } +} + +// Contract item 3. +func TestInferEdgeType(t *testing.T) { + // The first _EDGE_TYPE_MAP keyword that is a substring of EITHER string + // decides; the comment on each row names the winning keyword. + cases := []struct { + source, target, want string + }{ + {"aws_iam_role", "aws_s3_bucket.x", "trust"}, // iam + {"aws_s3_bucket", "aws_iam_role.x", "trust"}, // iam, matched on the TARGET + {"aws_security_group", "aws_instance.x", "network_path"}, // security_group + {"aws_s3_bucket", "aws_s3_bucket.x", "data_access"}, // bucket + {"aws_lambda_function", "aws_lambda_function.x", "execution"}, // lambda + {"nothing_matches", "also_nothing", "references"}, // nothing + } + + for _, tc := range cases { + if got := inferEdgeType(tc.source, tc.target); got != tc.want { + t.Errorf("inferEdgeType(%q, %q) = %q, want %q", tc.source, tc.target, got, tc.want) + } + } + + // Declaration order is load-bearing: "role" (trust) precedes "subnet" + // (network_path), and "policy" (trust) precedes "s3" (data_access). + if got := inferEdgeType("aws_db_subnet_group", "aws_iam_role.x"); got != "trust" { + t.Errorf(`"role" must win over "subnet"; got %q`, got) + } + if got := inferEdgeType("aws_s3_bucket_policy", ""); got != "trust" { + t.Errorf(`"policy" must win over "s3"/"bucket"; got %q`, got) + } +} + +// Contract item 4. +func TestClusterKey(t *testing.T) { + cases := []struct { + rtype, filePath, want string + }{ + {"aws_vpc", "main.tf", "network/root"}, + {"aws_security_group", "net/sg.tf", "network/net"}, + {"aws_iam_role", "main.tf", "identity/root"}, + {"aws_s3_bucket", "modules/data/main.tf", "data/modules/data"}, + {"aws_lambda_function", "main.tf", "compute/root"}, + // "group" is an IDENTITY keyword, so a CloudWatch log GROUP clusters as + // identity — surprising, and exactly what Python does. + {"aws_cloudwatch_log_group", "main.tf", "identity/root"}, + // "flow_log" is a NETWORK keyword and is checked first, so a VPC flow + // log clusters as network even though "log" reads like observability. + {"aws_flow_log", "main.tf", "network/root"}, + // "policy" is an identity keyword; an s3 bucket policy is identity, not + // data, because identity is tested before data. + {"aws_s3_bucket_policy", "main.tf", "identity/root"}, + // An empty file_path splits into ONE part, so module_dir is "root". + {"aws_instance", "", "compute/root"}, + {"aws_instance", "main.tf", "compute/root"}, + {"unknown_thing", "a/b/c.tf", "general/a/b"}, + } + for _, tc := range cases { + res := pyfmt.Ordered{{K: "type", V: tc.rtype}, {K: "file_path", V: tc.filePath}} + got, err := clusterKey(res) + if err != nil { + t.Errorf("clusterKey(%q, %q): %v", tc.rtype, tc.filePath, err) + continue + } + if got != tc.want { + t.Errorf("clusterKey(%q, %q) = %q, want %q", tc.rtype, tc.filePath, got, tc.want) + } + } +} + +// Contract item 1: config_summary keeps CONFIG order, not alphabetical order, +// because it is interpolated into hunter prompts as a Python dict repr. +func TestBuildGraphFromInventory_ConfigSummaryFiltersAndKeepsOrder(t *testing.T) { + inv := pyfmt.Ordered{{K: "resources", V: []any{ + pyfmt.Ordered{ + {K: "id", V: "aws_s3_bucket.b"}, + {K: "type", V: "aws_s3_bucket"}, + {K: "provider", V: "aws"}, + {K: "file_path", V: "main.tf"}, + {K: "config", V: pyfmt.Ordered{ + {K: "zzz_encryption", V: true}, // "encrypt" + {K: "bucket", V: "x"}, // no keyword -> dropped + {K: "AclSetting", V: "public"}, // "acl", matched case-insensitively + {K: "tags", V: pyfmt.Ordered{}}, + {K: "aaa_versioning", V: false}, // "versioning" + }}, + {K: "references", V: []any{}}, + {K: "referenced_by", V: []any{}}, + }, + }}} + + dir := t.TempDir() + invPath := filepath.Join(dir, "inventory.json") + if err := os.WriteFile(invPath, []byte(pyfmt.Dumps(inv, 2)), 0o666); err != nil { + t.Fatal(err) + } + graphPath, nodes, edges, err := BuildGraphFromInventory(invPath, dir) + if err != nil { + t.Fatal(err) + } + if nodes != 1 || edges != 0 { + t.Fatalf("(nodes, edges) = (%d, %d), want (1, 0)", nodes, edges) + } + + graph := decodeGraph(t, graphPath) + node := asObject(t, sectionOf(t, graph, "nodes")[0]) + summary := asObject(t, mustGet(t, node, "config_summary")) + + gotKeys := make([]string, len(summary)) + for i, kv := range summary { + gotKeys[i] = kv.K + } + want := []string{"zzz_encryption", "AclSetting", "aaa_versioning"} + if !reflect.DeepEqual(gotKeys, want) { + t.Errorf("config_summary keys = %v, want %v (config order, keyword-filtered)", gotKeys, want) + } +} + +// Contract item 2: both directions produce edges, self-edges and unknown ends +// are skipped, and the "->" key de-duplicates. +func TestBuildGraphFromInventory_EdgeConstruction(t *testing.T) { + res := func(id string, refs, refBy []any) pyfmt.Ordered { + return pyfmt.Ordered{ + {K: "id", V: id}, + {K: "type", V: id}, + {K: "provider", V: "aws"}, + {K: "file_path", V: "main.tf"}, + {K: "config", V: pyfmt.Ordered{}}, + {K: "references", V: refs}, + {K: "referenced_by", V: refBy}, + } + } + inv := pyfmt.Ordered{{K: "resources", V: []any{ + // a -> b twice (deduplicated), a -> itself (skipped), a -> ghost (unknown, skipped) + res("a", []any{"b", "b", "a", "ghost"}, []any{}), + // b's referenced_by re-states a -> b, which is already seen. + res("b", []any{}, []any{"a", "a", "b", "ghost"}), + // c only appears through its referenced_by, which creates b -> c. + res("c", []any{}, []any{"b"}), + }}} + + dir := t.TempDir() + invPath := filepath.Join(dir, "inventory.json") + if err := os.WriteFile(invPath, []byte(pyfmt.Dumps(inv, 2)), 0o666); err != nil { + t.Fatal(err) + } + graphPath, _, edges, err := BuildGraphFromInventory(invPath, dir) + if err != nil { + t.Fatal(err) + } + if edges != 2 { + t.Fatalf("edges = %d, want 2", edges) + } + + graph := decodeGraph(t, graphPath) + var pairs []string + for _, e := range sectionOf(t, graph, "edges") { + obj := asObject(t, e) + pairs = append(pairs, pyfmt.Str(mustGet(t, obj, "source"))+"->"+pyfmt.Str(mustGet(t, obj, "target"))) + } + if want := []string{"a->b", "b->c"}; !reflect.DeepEqual(pairs, want) { + t.Errorf("edges = %v, want %v", pairs, want) + } +} + +// Contract item 4: clusters sorted by name, members in inventory order. +func TestBuildGraphFromInventory_ClustersSortedByName(t *testing.T) { + work := t.TempDir() + invPath, _, _, err := ParseTerraformDirectory(vulnerableInfraFixture, work) + if err != nil { + t.Fatal(err) + } + graphPath, _, _, err := BuildGraphFromInventory(invPath, work) + if err != nil { + t.Fatal(err) + } + + graph := decodeGraph(t, graphPath) + var names []string + for _, c := range sectionOf(t, graph, "clusters") { + names = append(names, pyfmt.Str(mustGet(t, asObject(t, c), "name"))) + } + want := []string{"compute/root", "data/root", "identity/root", "network/root"} + if !reflect.DeepEqual(names, want) { + t.Errorf("cluster names = %v, want %v", names, want) + } + + identity := asObject(t, sectionOf(t, graph, "clusters")[2]) + members := stringsOf(mustGet(t, identity, "members")) + wantMembers := []string{ + "aws_iam_role.web_role", + "aws_iam_instance_profile.web_profile", + "aws_iam_role_policy.s3_full_access", + } + if !reflect.DeepEqual(members, wantMembers) { + t.Errorf("identity/root members = %v, want %v (inventory order)", members, wantMembers) + } +} + +// Contract item 7. +func TestBuildGraphFromInventory_MalformedInventoriesDegradeToEmpty(t *testing.T) { + cases := map[string]string{ + "top level is a list": `[1, 2, 3]`, + "resources is not a list": `{"resources": {"a": 1}}`, + "resources is absent": `{"variables": []}`, + "elements are not dicts": `{"resources": [1, "two", null]}`, + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + invPath := filepath.Join(dir, "inventory.json") + if err := os.WriteFile(invPath, []byte(body), 0o666); err != nil { + t.Fatal(err) + } + graphPath, nodes, edges, err := BuildGraphFromInventory(invPath, dir) + if err != nil { + t.Fatalf("want a graceful empty graph, got %v", err) + } + if nodes != 0 || edges != 0 { + t.Errorf("(nodes, edges) = (%d, %d), want (0, 0)", nodes, edges) + } + raw, err := os.ReadFile(graphPath) + if err != nil { + t.Fatal(err) + } + want := "{\n \"nodes\": [],\n \"edges\": [],\n \"clusters\": []\n}" + if string(raw) != want { + t.Errorf("graph.json =\n%s\nwant\n%s", raw, want) + } + }) + } +} + +// A missing or unreadable inventory is the failure that sends +// run_resource_graph_builder to its harness fallback, so it must be an error +// rather than an empty graph. +func TestBuildGraphFromInventory_MissingFileIsAnError(t *testing.T) { + if _, _, _, err := BuildGraphFromInventory(filepath.Join(t.TempDir(), "nope.json"), t.TempDir()); err == nil { + t.Error("want an error for a missing inventory, got nil") + } + dir := t.TempDir() + bad := filepath.Join(dir, "inventory.json") + if err := os.WriteFile(bad, []byte("{not json"), 0o666); err != nil { + t.Fatal(err) + } + if _, _, _, err := BuildGraphFromInventory(bad, dir); err == nil { + t.Error("want an error for an unparsable inventory, got nil") + } +} + +func decodeGraph(t *testing.T, path string) pyfmt.Ordered { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + decoded, err := pyfmt.Load(raw) + if err != nil { + t.Fatalf("decoding %s: %v", path, err) + } + return asObject(t, decoded) +} diff --git a/go/internal/agents/recon/iac_reader.go b/go/internal/agents/recon/iac_reader.go new file mode 100644 index 0000000..7acc583 --- /dev/null +++ b/go/internal/agents/recon/iac_reader.go @@ -0,0 +1,119 @@ +package recon + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/harnessx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/prompts" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// warnOut is where the fallback warnings that Python emits with +// `log.warning(...)` are written — i.e. stderr. It is a variable only so tests +// can capture the bytes; production code must never reassign it. +// +// The exact bytes of a Python logging line depend on the process-wide logging +// configuration and are not part of the parity contract (they are human-facing +// diagnostics, like harnessx's stdout blocks). The message TEXT is verbatim. +var warnOut io.Writer = os.Stderr + +func logWarning(logger, format string, args ...any) { + _, _ = fmt.Fprintf(warnOut, "WARNING:%s:%s\n", logger, fmt.Sprintf(format, args...)) +} + +// iacReaderPromptPath is PROMPT_PATH in iac_reader.py, resolved against the +// embedded prompt tree instead of the installed package's prompts/ directory. +const iacReaderPromptPath = "recon/iac_reader.txt" + +// iacReaderTempPrefix is the tempfile.mkdtemp prefix in run_iac_reader. +const iacReaderTempPrefix = "cloudsecurity-recon-iac-reader-" + +// RunIaCReader ports run_iac_reader in +// src/cloudsecurity_af/agents/recon/iac_reader.py: +// +// work_dir = tempfile.mkdtemp(prefix="cloudsecurity-recon-iac-reader-") +// try: +// return _fast_parse(repo_path, work_dir) +// except Exception as exc: +// log.warning("Deterministic parser failed (%s), falling back to harness", exc) +// return await _harness_fallback(app, repo_path, work_dir) +// +// PYTHON PARITY — THE WORK DIRECTORY IS NOT CLEANED UP. iac_reader.py imports +// `shutil` and never calls it: unlike cloud_connector/drift_detector there is no +// `finally: shutil.rmtree(...)`, and there cannot be, because the returned +// ResourceInventory.inventory_saved_path points INTO work_dir and every +// downstream phase (the graph builder, the hunters' graph context, the +// orchestrator's provider detection) reads that file. Go therefore does NOT +// defer os.RemoveAll here. The directory is an OS temp dir and is reclaimed with +// the rest of /tmp. +func RunIaCReader(ctx context.Context, app appx.Harnesser, repoPath string) (schemas.ResourceInventory, error) { + workDir, err := os.MkdirTemp("", iacReaderTempPrefix) + if err != nil { + // Python parity: mkdtemp is OUTSIDE the try, so a failure here + // propagates instead of falling back to the harness. + return schemas.ResourceInventory{}, fmt.Errorf("cloudsecurity recon: creating iac-reader work dir: %w", err) + } + + inventory, fastErr := iacFastParseFn(repoPath, workDir) + if fastErr == nil { + return inventory, nil + } + logWarning("cloudsecurity_af.agents.recon.iac_reader", + "Deterministic parser failed (%v), falling back to harness", fastErr) + return iacHarnessFallback(ctx, app, repoPath, workDir) +} + +// iacFastParseFn is the fast path RunIaCReader tries first. It is a variable +// ONLY so the test suite can exercise the harness-fallback branch: with the +// real implementation the branch is unreachable from a test, because +// ParseTerraformDirectory fails only on filesystem errors inside a temp +// directory the function itself just created. Production code must never +// reassign it. +var iacFastParseFn = iacFastParse + +// iacFastParse ports _fast_parse. +func iacFastParse(repoPath, workDir string) (schemas.ResourceInventory, error) { + invPath, total, iacType, err := ParseTerraformDirectory(repoPath, workDir) + if err != nil { + return schemas.ResourceInventory{}, err + } + return schemas.ResourceInventory{ + InventorySavedPath: invPath, + TotalResources: total, + IaCType: iacType, + // Python parity: ResourceInventory(...) leaves iac_version at its + // default of None. + IaCVersion: nil, + }, nil +} + +// iacHarnessFallback ports _harness_fallback. +func iacHarnessFallback(ctx context.Context, app appx.Harnesser, repoPath, workDir string) (schemas.ResourceInventory, error) { + prompt, err := BuildIaCReaderPrompt(repoPath) + if err != nil { + return schemas.ResourceInventory{}, err + } + return harnessx.RunExtract[schemas.ResourceInventory]( + ctx, app, prompt, + harness.Options{Cwd: workDir, ProjectDir: repoPath}, + "IaC reader", + ) +} + +// BuildIaCReaderPrompt renders the IaC reader harness prompt. Exported for the +// golden test, which compares it byte-for-byte against the string the Python +// builder emits. +func BuildIaCReaderPrompt(repoPath string) (string, error) { + template, err := prompts.Load(iacReaderPromptPath) + if err != nil { + return "", err + } + return strings.ReplaceAll(template, "{{REPO_PATH}}", repoPath), nil +} diff --git a/go/internal/agents/recon/pyfmt_helpers_test.go b/go/internal/agents/recon/pyfmt_helpers_test.go new file mode 100644 index 0000000..78aa76c --- /dev/null +++ b/go/internal/agents/recon/pyfmt_helpers_test.go @@ -0,0 +1,20 @@ +package recon + +import ( + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" +) + +// mustGet fetches a key out of a pyfmt.Load result, failing the test when it is +// absent. It lived next to the package-local json.dumps/json.load copy that was +// folded into pyfmt at integration time; the helper stayed because the recon +// tests read decoded inventory/graph documents everywhere. +func mustGet(t *testing.T, o pyfmt.Ordered, key string) any { + t.Helper() + v, ok := o.Get(key) + if !ok { + t.Fatalf("key %q missing", key) + } + return v +} diff --git a/go/internal/agents/recon/resource_graph_builder.go b/go/internal/agents/recon/resource_graph_builder.go new file mode 100644 index 0000000..65a6d3b --- /dev/null +++ b/go/internal/agents/recon/resource_graph_builder.go @@ -0,0 +1,89 @@ +package recon + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/harnessx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/prompts" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// graphBuilderPromptPath is PROMPT_PATH in resource_graph_builder.py. +const graphBuilderPromptPath = "recon/resource_graph_builder.txt" + +// graphBuilderTempPrefix is the tempfile.mkdtemp prefix in +// run_resource_graph_builder. +const graphBuilderTempPrefix = "cloudsecurity-recon-graph-builder-" + +// RunResourceGraphBuilder ports run_resource_graph_builder in +// src/cloudsecurity_af/agents/recon/resource_graph_builder.py: +// +// work_dir = tempfile.mkdtemp(prefix="cloudsecurity-recon-graph-builder-") +// try: +// return _fast_build(inventory_path, work_dir) +// except Exception as exc: +// log.warning("Deterministic graph builder failed (%s), falling back to harness", exc) +// return await _harness_fallback(app, repo_path, inventory_path, work_dir) +// +// PYTHON PARITY: as in iac_reader.py the work directory is deliberately NOT +// removed — the returned ResourceGraph.graph_saved_path points into it and the +// HUNT phase reads that file for every hunter's graph context. +// +// Python parity: repo_path is accepted and used ONLY as the harness project_dir +// on the fallback path; the deterministic build ignores it. +func RunResourceGraphBuilder(ctx context.Context, app appx.Harnesser, repoPath, inventoryPath string) (schemas.ResourceGraph, error) { + workDir, err := os.MkdirTemp("", graphBuilderTempPrefix) + if err != nil { + return schemas.ResourceGraph{}, fmt.Errorf("cloudsecurity recon: creating graph-builder work dir: %w", err) + } + + graph, fastErr := graphFastBuild(inventoryPath, workDir) + if fastErr == nil { + return graph, nil + } + logWarning("cloudsecurity_af.agents.recon.resource_graph_builder", + "Deterministic graph builder failed (%v), falling back to harness", fastErr) + return graphHarnessFallback(ctx, app, repoPath, inventoryPath, workDir) +} + +// graphFastBuild ports _fast_build. +func graphFastBuild(inventoryPath, workDir string) (schemas.ResourceGraph, error) { + graphPath, totalNodes, totalEdges, err := BuildGraphFromInventory(inventoryPath, workDir) + if err != nil { + return schemas.ResourceGraph{}, err + } + return schemas.ResourceGraph{ + GraphSavedPath: graphPath, + TotalNodes: totalNodes, + TotalEdges: totalEdges, + }, nil +} + +// graphHarnessFallback ports _harness_fallback. +func graphHarnessFallback(ctx context.Context, app appx.Harnesser, repoPath, inventoryPath, workDir string) (schemas.ResourceGraph, error) { + prompt, err := BuildResourceGraphBuilderPrompt(inventoryPath) + if err != nil { + return schemas.ResourceGraph{}, err + } + return harnessx.RunExtract[schemas.ResourceGraph]( + ctx, app, prompt, + harness.Options{Cwd: workDir, ProjectDir: repoPath}, + "Resource graph builder", + ) +} + +// BuildResourceGraphBuilderPrompt renders the resource-graph-builder harness +// prompt. Exported for the golden test. +func BuildResourceGraphBuilderPrompt(inventoryPath string) (string, error) { + template, err := prompts.Load(graphBuilderPromptPath) + if err != nil { + return "", err + } + return strings.ReplaceAll(template, "{{INVENTORY_PATH}}", inventoryPath), nil +} diff --git a/go/internal/agents/recon/testdata/expressions/main.tf b/go/internal/agents/recon/testdata/expressions/main.tf new file mode 100644 index 0000000..67dec56 --- /dev/null +++ b/go/internal/agents/recon/testdata/expressions/main.tf @@ -0,0 +1,107 @@ +variable "env" { + type = string + default = "dev" + description = "environment" +} + +variable "count_n" { + type = number + default = 3 +} + +variable "no_default" { + type = list(string) +} + +output "bucket_arn" { + value = aws_s3_bucket.b.arn + description = "arn" +} + +output "plain" { + value = "hello" +} + +provider "google" { + region = "us-central1" + alias = "second" +} + +module "vpc" { + source = "terraform-aws-modules/vpc/aws" + version = "3.0.0" + cidr = "10.0.0.0/16" +} + +resource "aws_s3_bucket" "b" { + bucket = "x" + force_destroy = true + count = 2 + price = 1.5 + neg = -1 + interp = "${var.env}-suffix" + cond = var.env == "dev" ? "a" : "b" + fn = lower("ABC") + lst = [1, 2, 3] + obj = { a = 1, b = "two" } + nullv = null + quoted_key = { "with space" = 1 } + ref_list = [aws_iam_role.r.arn, "literal"] + local_ref = local.something + var_ref = var.env + each_ref = each.key + + versioning { + enabled = true + } + + ingress { + from_port = 1 + } + + ingress { + from_port = 2 + } + + dynamic "rule" { + for_each = [1] + content { + id = "r" + } + } +} + +resource "aws_iam_role" "r" { + name = "role" +} + +data "aws_ami" "ubuntu" { + most_recent = true + owners = ["099720109477"] +} + +resource "aws_iam_policy" "strings" { + escaped_newline = "a\nb" + escaped_slash = "a\\b" + escaped_quote = "a\"b" + unicode = "café" + pure_interp = "${var.env}" + heredoc_plain = < provider +// family. Order is irrelevant (it is a pure lookup). +var providerMap = map[string]string{ + "aws": "aws", + "azurerm": "azure", + "azuread": "azure", + "google": "gcp", + "kubernetes": "kubernetes", + "helm": "kubernetes", + "oci": "oci", + "alicloud": "alicloud", +} + +// providerFromType ports _provider_from_type. +// +// prefix = resource_type.split("_")[0] if "_" in resource_type else resource_type +// return _PROVIDER_MAP.get(prefix, prefix) +func providerFromType(resourceType string) string { + prefix := resourceType + if i := strings.Index(resourceType, "_"); i >= 0 { + prefix = resourceType[:i] + } + if mapped, ok := providerMap[prefix]; ok { + return mapped + } + return prefix +} + +// extractReferences ports _extract_references: every resource-looking token in +// any string anywhere in the config, de-duplicated and sorted. +func extractReferences(config any) []string { + refs := map[string]struct{}{} + walkForRefs(config, refs) + out := make([]string, 0, len(refs)) + for r := range refs { + out = append(out, r) + } + sort.Strings(out) + return out +} + +// walkForRefs ports _walk_for_refs. +func walkForRefs(obj any, refs map[string]struct{}) { + switch x := obj.(type) { + case string: + for _, m := range refPattern.FindAllStringSubmatch(x, -1) { + candidate := m[1] + skip := false + for _, p := range nonRefPrefixes { + if strings.HasPrefix(candidate, p) { + skip = true + break + } + } + if !skip { + refs[candidate] = struct{}{} + } + } + case pyfmt.Ordered: + for _, kv := range x { + walkForRefs(kv.V, refs) + } + case []any: + for _, item := range x { + walkForRefs(item, refs) + } + } + // Python parity: ints, floats, bools and None contribute nothing, and no + // other type can appear in the value model. +} + +// sanitize ports _sanitize: keep str/int/float/bool/None and containers as they +// are, stringify anything else. +// +// The Go value model only ever contains those kinds already (json.Number +// included — it is the stand-in for an arbitrary-precision Python int), so this +// is an identity walk in practice; it is ported so a future value kind cannot +// leak an unserializable object into inventory.json. +func sanitize(obj any) any { + switch x := obj.(type) { + case pyfmt.Ordered: + out := make(pyfmt.Ordered, len(x)) + for i, kv := range x { + out[i] = pyfmt.KV{K: kv.K, V: sanitize(kv.V)} + } + return out + case []any: + out := make([]any, len(x)) + for i, item := range x { + out[i] = sanitize(item) + } + return out + case string, int, int64, float64, bool, nil: + return x + case json.Number: + // The stand-in for a Python int too large for Go's int (ctyToValue). + // Python's _sanitize keeps it because `isinstance(obj, int)` is true + // whatever its magnitude. + return x + } + return pyfmt.Str(obj) +} + +// --------------------------------------------------------------------------- +// HCL AST -> value model +// --------------------------------------------------------------------------- + +// exprToValue ports _expr_to_value. +// +// PYTHON SHAPE (preserved exactly): +// +// Literal -> the Python value +// ObjectExpression -> dict, recursing per field +// ArrayExpression -> list, recursing per element +// anything else -> a STRING +// +// EXPRESSION RENDERING — THE ONE DELIBERATE DIVERGENCE IN THIS PORT. +// Python's "anything else" branch ends in `str(expr)`, which for pyhcl2 is the +// dataclass repr of the AST node, byte offsets and all. Running the Python +// parser over tests/fixtures/vulnerable_infra/main.tf produces, for +// `iam_instance_profile = aws_iam_instance_profile.web_profile.name`: +// +// "GetAttr(span=SourceSpan(start=219, end=260, source_id=SourceId(value=1)), +// on=GetAttr(span=SourceSpan(start=219, end=255, ...), ...)" +// +// i.e. a serialization of the parser's internal node graph, keyed to absolute +// byte offsets in the source file. That is not information any consumer wants +// (the harness prompts, the graph builder and the hunters all read `config` as +// if it held Terraform), and reproducing it in Go would mean re-implementing +// pyhcl2's repr including its span arithmetic. +// +// This port therefore renders a non-constant expression as its SOURCE TEXT — +// `aws_iam_instance_profile.web_profile.name` — which is what the design +// contract (§3, "anything non-constant → its source text") directs. Consequences, +// all of them documented and asserted in tfparse_test.go: +// +// - `config` values for non-constant attributes read as Terraform rather than +// as pyhcl2 reprs. +// - `references` (and therefore `referenced_by`, and therefore the graph's +// edges) are computed over that text, so the Go port finds the REAL +// references that Python's repr text accidentally hides, and does not find +// the spurious ones the repr text accidentally creates. On the fixture, +// Python reports references ["t2.micro"] for aws_instance.web_server (from +// the *literal* "t2.micro") and misses the two real ones; Go reports +// ["aws_iam_instance_profile.web_profile", "aws_security_group.allow_all"]. +// - Constant expressions are unaffected: strings, numbers, bools, lists and +// objects of constants evaluate to the same values in both. +// +// Two smaller, unrelated divergences fall out of the same branch, both of them +// Go producing the sane value where Python produces a repr string: +// +// - `null` -> Go JSON null, Python "Null(span=SourceSpan(...))". +// - a negated literal such as `-1` -> Go -1, Python +// "UnaryExpression(span=..., op=UnaryOperator(..., type='-'), ...)". +func exprToValue(expr hclsyntax.Expression, src []byte) any { + switch e := expr.(type) { + case *hclsyntax.ObjectConsExpr: + // Python: ObjectExpression -> {key: _expr_to_value(v)} + out := make(pyfmt.Ordered, 0, len(e.Items)) + for _, item := range e.Items { + key := objectKey(item.KeyExpr, src) + val := exprToValue(item.ValueExpr, src) + replaced := false + for i := range out { + if out[i].K == key { + out[i].V = val + replaced = true + break + } + } + if !replaced { + out = append(out, pyfmt.KV{K: key, V: val}) + } + } + return out + + case *hclsyntax.TupleConsExpr: + // Python: ArrayExpression -> [_expr_to_value(v) for v in values] + out := make([]any, 0, len(e.Exprs)) + for _, sub := range e.Exprs { + out = append(out, exprToValue(sub, src)) + } + return out + + case *hclsyntax.TemplateExpr: + // A quoted string or a heredoc. pyhcl2 represents every one of them — + // plain, escaped or interpolated — as a String literal whose `_raw` is + // the UNINTERPRETED text between the delimiters, which is what + // templateText returns. Verified against the interpreter: + // + // nl = "a\nb" -> "a\\nb" (the escape is NOT processed) + // quote = "a\"b" -> "a\\\"b" + // mixed = "x${var.y}\nz" -> "x${var.y}\\nz" + // + // so evaluating the literal would be WRONG here even though it is the + // semantically nicer value. + return templateText(e, src) + + case *hclsyntax.TemplateWrapExpr: + // `"${expr}"` with nothing around it. + return templateText(e, src) + } + + // Everything else: evaluate it if it is constant, otherwise fall back to + // source text (see the divergence note above). + if v, diags := expr.Value(nil); !diags.HasErrors() { + return ctyToValue(v, expr, src) + } + return sourceText(expr, src) +} + +// objectKey ports the key half of Python's ObjectExpression branch: +// +// key_str = getattr(k, "name", None) or getattr(getattr(k, "value", k), "_raw", str(k)) +// d[str(key_str)] = ... +// +// i.e. a bare identifier key contributes its name and a quoted key its text. +func objectKey(keyExpr hclsyntax.Expression, src []byte) string { + if kw := hcl.ExprAsKeyword(keyExpr); kw != "" { + return kw + } + inner := keyExpr + if k, ok := keyExpr.(*hclsyntax.ObjectConsKeyExpr); ok { + inner = k.Wrapped + } + // A quoted key is a template, and pyhcl2 takes its `_raw` — the + // uninterpreted text between the quotes — exactly as it does for a quoted + // VALUE. Going through templateText keeps keys and values consistent. + if t, ok := inner.(*hclsyntax.TemplateExpr); ok { + return templateText(t, src) + } + if v, diags := inner.Value(nil); !diags.HasErrors() && v.Type() == cty.String && !v.IsNull() { + return v.AsString() + } + return sourceText(inner, src) +} + +// ctyToValue converts an evaluated cty value into the port's value model. +// +// Number handling reproduces Python's int/float split: pyhcl2 yields an `int` +// for an integer literal and a `float` for one written with a decimal point, +// and json.dump renders those as `2` and `2.0` respectively. (pyhcl2 REJECTS +// exponent notation outright — see ParseTerraformDirectory's note on +// parser-acceptance divergence.) cty keeps only a big.Float, so the LITERAL +// TEXT decides: a mathematically integral value written without `.`/`e` is an +// int, everything else a float. +// +// A Python int is ARBITRARY PRECISION and json.dump writes its exact digits, so +// an integer literal beyond Go's `int` is carried as a json.Number holding the +// literal rather than degraded to a float64. Verified against the repo venv on +// a one-file fixture: `big_port = 12345678901234567890` writes exactly +// `12345678901234567890` into inventory.json in Python; returning +// `bf.Float64()` here wrote `1.2345678901234567e+19`, which then propagated +// into graph.json's `config_summary` (the substring "port" is a +// configSummaryKeywords match) and into the hunter prompt's `Config: {...}` +// line. pyfmt.Dumps re-emits an integral json.Number verbatim, and +// pyfmt.Load reads it back as one, so the value survives the whole +// inventory.json -> graph.json -> prompt chain. +// For a COMPOUND constant expression (`true ? 1 : 2`, `1 + 2`) the whole +// expression's text is what gets inspected, which is the right answer for every +// such expression Terraform actually contains — Python does not fold those at +// all, it stringifies them, so they are already on the documented divergence +// list. +func ctyToValue(v cty.Value, expr hclsyntax.Expression, src []byte) any { + if v.IsNull() { + return nil + } + if !v.IsKnown() { + return sourceText(expr, src) + } + t := v.Type() + switch { + case t == cty.Bool: + return v.True() + case t == cty.String: + return v.AsString() + case t == cty.Number: + bf := v.AsBigFloat() + if bf.IsInt() && !strings.ContainsAny(sourceText(expr, src), ".eE") { + // acc == big.Exact, plus a range check so a value beyond `int` on a + // 32-bit build does not wrap. + if i, acc := bf.Int64(); acc == 0 && int64(int(i)) == i { + return int(i) + } + // Out of `int` range: keep Python's arbitrary-precision digits. + if bi, acc := bf.Int(nil); acc == big.Exact { + return json.Number(bi.String()) + } + } + f, _ := bf.Float64() + return f + case t.IsTupleType(), t.IsListType(), t.IsSetType(): + out := []any{} + for it := v.ElementIterator(); it.Next(); { + _, ev := it.Element() + out = append(out, ctyToValue(ev, expr, src)) + } + return out + case t.IsObjectType(), t.IsMapType(): + // Only reachable for a value produced by evaluating a non-Object AST + // node (an ObjectConsExpr is destructured above and keeps source + // order). cty has no insertion order, so keys are SORTED here — + // deterministic, and documented. + keys := make([]string, 0) + vals := map[string]cty.Value{} + for it := v.ElementIterator(); it.Next(); { + k, ev := it.Element() + if k.Type() != cty.String || k.IsNull() { + continue + } + keys = append(keys, k.AsString()) + vals[k.AsString()] = ev + } + sort.Strings(keys) + out := make(pyfmt.Ordered, 0, len(keys)) + for _, k := range keys { + out = append(out, pyfmt.KV{K: k, V: ctyToValue(vals[k], expr, src)}) + } + return out + } + return sourceText(expr, src) +} + +// sourceText returns the exact bytes expr occupies in the file. +func sourceText(expr hclsyntax.Expression, src []byte) string { + return rangeText(expr.Range(), src) +} + +func rangeText(rng hcl.Range, src []byte) string { + start, end := rng.Start.Byte, rng.End.Byte + if start < 0 || end > len(src) || start > end { + return "" + } + return string(src[start:end]) +} + +// templateText returns a template expression's raw inner text, which is what +// pyhcl2 stores as a String literal's `_raw`: the characters between the +// delimiters, uninterpreted. +// +// - `"..."` -> the text between the quotes, escapes left alone. +// - `< the heredoc body, flush-dedented for the `<<-` flavor and +// with its final newline removed, matching pyhcl2 exactly. +// - anything else (which should not occur) -> the source text unchanged. +func templateText(expr hclsyntax.Expression, src []byte) string { + raw := sourceText(expr, src) + if body, ok := heredocBody(raw); ok { + return body + } + if len(raw) >= 2 && raw[0] == '"' && raw[len(raw)-1] == '"' { + return raw[1 : len(raw)-1] + } + return raw +} + +// heredocHeader matches a heredoc introducer: `<= 0; i-- { + trimmed := strings.TrimRight(strings.TrimLeft(lines[i], " \t"), "\r\n") + if trimmed == delim { + body = strings.Join(lines[:i], "") + break + } + } + + if flush { + body = flushDedent(body) + } + return strings.TrimSuffix(body, "\n"), true +} + +// flushDedent implements pyhcl2's `<<-` rule, which is NOT HCL's. +// +// Verified against the interpreter on four shapes: +// +// " a\n \n b" -> "a\n \nb" (whitespace-only line trimmed too) +// " a\n\n b" -> unchanged (an EMPTY line makes the min 0) +// "\t\ta\n\t\t\tb" -> unchanged (tabs are not counted) +// " a\n b" -> "a\n b" +// +// so the rule is: minimum number of leading SPACE characters over EVERY body +// line (the closing-marker line excluded, empty lines included as zero), then +// that many characters removed from every line. HCL instead skips blank lines +// and counts any unicode space, which is why this cannot delegate to +// hclsyntax's own flush handling. +func flushDedent(body string) string { + lines := strings.SplitAfter(body, "\n") + // SplitAfter leaves a trailing "" when body ends with a newline; that is + // not a line and must not drag the minimum down to zero. + if n := len(lines); n > 0 && lines[n-1] == "" { + lines = lines[:n-1] + } + if len(lines) == 0 { + return body + } + + minSpaces := -1 + for _, ln := range lines { + n := 0 + for n < len(ln) && ln[n] == ' ' { + n++ + } + if minSpaces < 0 || n < minSpaces { + minSpaces = n + } + } + if minSpaces <= 0 { + return body + } + + var b strings.Builder + for _, ln := range lines { + b.WriteString(ln[minSpaces:]) + } + return b.String() +} + +// blockToDict ports _block_to_dict: a block body becomes {attributes..., nested +// blocks...}. +// +// Python reads pyhcl2's `block.attributes` dict (insertion-ordered by source +// position) and then its `block.blocks` list. hclsyntax stores attributes in an +// unordered map, so they are re-sorted by source offset to recover the same +// order; blocks are already in source order. +func blockToDict(body *hclsyntax.Body, src []byte) pyfmt.Ordered { + result := pyfmt.Ordered{} + if body == nil { + return result + } + + attrs := make([]*hclsyntax.Attribute, 0, len(body.Attributes)) + for _, a := range body.Attributes { + attrs = append(attrs, a) + } + sort.Slice(attrs, func(i, j int) bool { + return attrs[i].SrcRange.Start.Byte < attrs[j].SrcRange.Start.Byte + }) + for _, a := range attrs { + result = setOrdered(result, a.Name, exprToValue(a.Expr, src)) + } + + for _, sub := range body.Blocks { + subName := sub.Type + subDict := blockToDict(sub.Body, src) + if len(sub.Labels) > 0 { + label := sub.Labels[0] + // Python: result.setdefault(sub_name, {})[label] = sub_dict + if existing, ok := result.Get(subName); ok { + if nested, isMap := existing.(pyfmt.Ordered); isMap { + result = setOrdered(result, subName, setOrdered(nested, label, subDict)) + continue + } + // Python parity DIVERGENCE: Python raises TypeError here (it + // would subscript a non-dict), which aborts the whole parse and + // sends run_iac_reader down its harness fallback. Go replaces + // the value instead, so one pathological file cannot cost the + // deterministic path for an entire repository. + } + result = setOrdered(result, subName, pyfmt.Ordered{{K: label, V: subDict}}) + continue + } + // Unlabeled: first one is a dict, repeats collapse into a list. + if existing, ok := result.Get(subName); ok { + if list, isList := existing.([]any); isList { + result = setOrdered(result, subName, append(list, subDict)) + } else { + result = setOrdered(result, subName, []any{existing, subDict}) + } + continue + } + result = setOrdered(result, subName, subDict) + } + + return result +} + +// setOrdered assigns key in Python dict order: an existing key keeps its +// position, a new key is appended. +func setOrdered(o pyfmt.Ordered, key string, val any) pyfmt.Ordered { + for i := range o { + if o[i].K == key { + o[i].V = val + return o + } + } + return append(o, pyfmt.KV{K: key, V: val}) +} + +// --------------------------------------------------------------------------- +// Directory walk +// --------------------------------------------------------------------------- + +// terraformFiles reproduces `sorted(Path(repo_path).rglob("*.tf"))`. +// +// Two pathlib details matter and are reproduced: +// - rglob does NOT skip dot-directories, so `.terraform/**` is included +// (verified against Python 3.11 on this machine). +// - PurePath ordering compares path COMPONENTS, not the raw string, so +// "a-b/c.tf" sorts after "a/b.tf" even though the raw strings sort the +// other way. +// +// pathlib's recursive selector also refuses to descend into symlinked +// directories, which filepath.WalkDir does for free (it never follows them). +func terraformFiles(repoPath string) ([]string, error) { + // Python parity: rglob on a missing path — or on a path that is a FILE — + // yields nothing rather than raising (verified on Python 3.11). + if st, err := os.Stat(repoPath); err != nil || !st.IsDir() { + return nil, nil + } + var rels []string + err := filepath.WalkDir(repoPath, func(p string, d fs.DirEntry, err error) error { + if err != nil { + // Python parity: rglob silently skips directories it cannot read. + if d != nil && d.IsDir() { + return fs.SkipDir + } + return nil + } + if d.IsDir() { + return nil + } + if !strings.HasSuffix(d.Name(), ".tf") { + return nil + } + rel, relErr := filepath.Rel(repoPath, p) + if relErr != nil { + return nil + } + rels = append(rels, filepath.ToSlash(rel)) + return nil + }) + if err != nil { + return nil, err + } + sort.Slice(rels, func(i, j int) bool { return lessPathParts(rels[i], rels[j]) }) + return rels, nil +} + +// lessPathParts compares two slash-separated relative paths component-wise, +// which is what pathlib.PurePath.__lt__ does. +func lessPathParts(a, b string) bool { + as, bs := strings.Split(a, "/"), strings.Split(b, "/") + for i := 0; i < len(as) && i < len(bs); i++ { + if as[i] != bs[i] { + return as[i] < bs[i] + } + } + return len(as) < len(bs) +} + +// --------------------------------------------------------------------------- +// parse_terraform_directory +// --------------------------------------------------------------------------- + +// ParseTerraformDirectory ports parse_terraform_directory: parse every *.tf +// under repoPath and write inventory.json into outputDir. +// +// Returns (inventoryPath, totalResources, iacType) — the Python tuple — plus an +// error for the failures Python raises on (the makedirs / open / write path). +// Python parity: a .tf file that fails to parse is SKIPPED, not fatal +// (`except Exception: continue`). +// +// DIVERGENCE (documented) — WHICH FILES FAIL. The skip rule is identical on +// both sides, but the two parsers do not agree on which files trigger it, and +// the unit of divergence is the WHOLE FILE: every resource, variable, output, +// provider and module declared in it is present in one inventory and absent +// from the other, which propagates to graph.json, to the hunters' RELEVANT +// RESOURCES / RELEVANT RELATIONSHIPS / INVENTORY STATS blocks and to the chain +// parent prompt's {{RESOURCE_GRAPH_JSON}}. It runs in BOTH directions; both +// were measured against pyhcl2 in the repo venv and hclsyntax here: +// +// - SCIENTIFIC NOTATION. `p = 1e3`, `1E3` and `1.5e-3` are valid HCL native +// syntax and hclsyntax parses them; pyhcl2 raises DiagnosticError, so +// Python drops the entire file. Go emits the resources, Python emits none. +// - A NEWLINE INSIDE A TERNARY. An expression whose `?` is followed by a +// line break parses in pyhcl2 and is an "Invalid expression" diagnostic +// for hclsyntax, so the divergence reverses: Python keeps the file, Go +// drops it. +// +// Go has the better behaviour in the first case and the worse one in the +// second, and neither is worth reproducing — the point of recording it is that +// a reviewer diffing the two nodes' inventories on a real repository can tell +// this apart from a bug. +func ParseTerraformDirectory(repoPath, outputDir string) (string, int, string, error) { + tfFiles, err := terraformFiles(repoPath) + if err != nil { + return "", 0, "", fmt.Errorf("cloudsecurity recon: walking %s: %w", repoPath, err) + } + + resources := []any{} + variables := []any{} + outputs := []any{} + providers := []any{} + modules := []any{} + + for _, rel := range tfFiles { + src, readErr := os.ReadFile(filepath.Join(repoPath, filepath.FromSlash(rel))) + if readErr != nil { + continue // Python: `except Exception: continue` + } + file, diags := hclsyntax.ParseConfig(src, rel, hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() || file == nil { + continue + } + body, ok := file.Body.(*hclsyntax.Body) + if !ok { + continue + } + + for _, block := range body.Blocks { + labels := block.Labels + cfg := blockToDict(block.Body, src) + + switch { + case block.Type == "resource" && len(labels) >= 2: + rtype, name := labels[0], labels[1] + resources = append(resources, pyfmt.Ordered{ + {K: "id", V: rtype + "." + name}, + {K: "type", V: rtype}, + {K: "name", V: name}, + {K: "provider", V: providerFromType(rtype)}, + {K: "file_path", V: rel}, + {K: "line_number", V: 0}, + {K: "config", V: sanitize(cfg)}, + {K: "references", V: toAnySlice(extractReferences(cfg))}, + {K: "referenced_by", V: []any{}}, + }) + + case block.Type == "data" && len(labels) >= 2: + dtype, name := labels[0], labels[1] + resources = append(resources, pyfmt.Ordered{ + {K: "id", V: "data." + dtype + "." + name}, + {K: "type", V: "data." + dtype}, + {K: "name", V: name}, + {K: "provider", V: providerFromType(dtype)}, + {K: "file_path", V: rel}, + {K: "line_number", V: 0}, + {K: "config", V: sanitize(cfg)}, + {K: "references", V: toAnySlice(extractReferences(cfg))}, + {K: "referenced_by", V: []any{}}, + }) + + case block.Type == "variable" && len(labels) >= 1: + variables = append(variables, pyfmt.Ordered{ + {K: "name", V: labels[0]}, + // Python: str(vcfg.get("type", "")) if vcfg.get("type") is not None else None + {K: "type", V: strOrNil(cfg, "type")}, + {K: "default", V: strOrNil(cfg, "default")}, + // Python parity: description is NOT str()-ed. + {K: "description", V: getOrNil(cfg, "description")}, + {K: "file_path", V: rel}, + }) + + case block.Type == "output" && len(labels) >= 1: + // Python: str(ocfg.get("value", "")) — no None guard, so a + // missing `value` becomes the empty STRING, not null. + val, ok := cfg.Get("value") + if !ok { + val = "" + } + outputs = append(outputs, pyfmt.Ordered{ + {K: "name", V: labels[0]}, + {K: "value", V: pyfmt.Str(val)}, + {K: "description", V: getOrNil(cfg, "description")}, + {K: "file_path", V: rel}, + }) + + case block.Type == "provider" && len(labels) >= 1: + providers = append(providers, pyfmt.Ordered{ + {K: "name", V: labels[0]}, + {K: "region", V: getOrNil(cfg, "region")}, + {K: "alias", V: getOrNil(cfg, "alias")}, + // Python parity: always None — the parser never reads a + // version constraint out of a provider block. + {K: "version", V: nil}, + }) + + case block.Type == "module" && len(labels) >= 1: + modSource, ok := cfg.Get("source") + if !ok { + modSource = "" + } + modules = append(modules, pyfmt.Ordered{ + {K: "name", V: labels[0]}, + {K: "source", V: pyfmt.Str(modSource)}, + {K: "version", V: getOrNil(cfg, "version")}, + {K: "file_path", V: rel}, + }) + } + } + } + + // Reverse references: for every resource, who points at it. + refTargets := map[string][]string{} + for _, r := range resources { + res := r.(pyfmt.Ordered) + id, _ := res.Get("id") + idStr, _ := id.(string) + refs, _ := res.Get("references") + for _, ref := range refs.([]any) { + refStr, _ := ref.(string) + refTargets[refStr] = append(refTargets[refStr], idStr) + } + } + // Python parity: `r["referenced_by"] = ...` overwrites an existing key, so + // the field keeps its declared position in the resource dict. + for _, r := range resources { + res := r.(pyfmt.Ordered) + id, _ := res.Get("id") + idStr, _ := id.(string) + setOrdered(res, "referenced_by", toAnySlice(refTargets[idStr])) + } + + inventory := pyfmt.Ordered{ + {K: "resources", V: resources}, + {K: "variables", V: variables}, + {K: "outputs", V: outputs}, + {K: "providers", V: providers}, + {K: "modules", V: modules}, + } + + if err := os.MkdirAll(outputDir, 0o777); err != nil { + return "", 0, "", fmt.Errorf("cloudsecurity recon: creating %s: %w", outputDir, err) + } + inventoryPath := filepath.Join(outputDir, "inventory.json") + if err := os.WriteFile(inventoryPath, []byte(pyfmt.Dumps(inventory, 2)), 0o666); err != nil { + return "", 0, "", fmt.Errorf("cloudsecurity recon: writing %s: %w", inventoryPath, err) + } + + return inventoryPath, len(resources), "terraform", nil +} + +// strOrNil is Python's `str(cfg.get(k, "")) if cfg.get(k) is not None else None`. +func strOrNil(cfg pyfmt.Ordered, key string) any { + v, ok := cfg.Get(key) + if !ok || v == nil { + return nil + } + return pyfmt.Str(v) +} + +// getOrNil is Python's `cfg.get(k)` — the raw value, or None when absent. +func getOrNil(cfg pyfmt.Ordered, key string) any { + v, ok := cfg.Get(key) + if !ok { + return nil + } + return v +} + +func toAnySlice(ss []string) []any { + out := make([]any, len(ss)) + for i, s := range ss { + out[i] = s + } + return out +} diff --git a/go/internal/agents/recon/tfparse_test.go b/go/internal/agents/recon/tfparse_test.go new file mode 100644 index 0000000..75d2194 --- /dev/null +++ b/go/internal/agents/recon/tfparse_test.go @@ -0,0 +1,952 @@ +package recon + +import ( + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" +) + +// VALIDATION CONTRACT for ParseTerraformDirectory (from parse_terraform_directory +// in src/cloudsecurity_af/agents/recon/_terraform_parser.py, expressed in terms +// of observable behavior rather than of the Go code): +// +// 1. Every *.tf under the repo is parsed, in pathlib sort order, and its path is +// recorded RELATIVE to the repo. +// 2. `resource` blocks with >=2 labels produce {id ".", type, name, +// provider, file_path, line_number 0, config, references, referenced_by}; +// `data` blocks produce the same with id "data.." and type +// "data.". +// 3. `variable`, `output`, `provider` and `module` blocks produce their own +// records with the exact key sets Python emits, including the str()-vs-raw +// asymmetries (variable.type/default are str()-ed, variable.description is +// not; output.value is str()-ed with "" for a missing value; +// provider.version is ALWAYS null). +// 4. `provider` is _PROVIDER_MAP[type prefix] or the prefix itself. +// 5. `references` is the sorted, de-duplicated set of resource-shaped tokens +// found in any string in the config, minus the var./local./each./self./ +// count./path./terraform. namespaces. `referenced_by` is its reverse index. +// 6. The file is inventory.json inside output_dir, written as +// json.dump(..., indent=2, default=str). +// 7. An unparsable file is skipped, not fatal. +// 8. The return value is (path, len(resources), "terraform"). +// +// The fixtures the assertions run against are generated by +// go/scripts/gen_golden.py from the REAL Python parser. + +const ( + pythonInventoryFixture = "testdata/python/inventory.json" + pythonExpressionsFixt = "testdata/python/expressions_inventory.json" + pythonSummaryFixture = "testdata/python/summary.json" + vulnerableInfraFixture = "testdata/vulnerable_infra" + expressionsFixtureDir = "testdata/expressions" + pyhcl2ReprMarker = "SourceSpan(" + pythonTreeInfraRelative = "../../../../tests/fixtures/vulnerable_infra/main.tf" +) + +// parseFixture runs the Go parser over dir and returns the decoded inventory. +func parseFixture(t *testing.T, dir string) (inv pyfmt.Ordered, path string, total int, iacType string) { + t.Helper() + out := t.TempDir() + path, total, iacType, err := ParseTerraformDirectory(dir, out) + if err != nil { + t.Fatalf("ParseTerraformDirectory(%q): %v", dir, err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + decoded, err := pyfmt.Load(raw) + if err != nil { + t.Fatalf("decoding %s: %v", path, err) + } + obj, ok := decoded.(pyfmt.Ordered) + if !ok { + t.Fatalf("inventory.json decoded as %T, want an object", decoded) + } + return obj, path, total, iacType +} + +func loadPythonFixture(t *testing.T, rel string) pyfmt.Ordered { + t.Helper() + raw, err := os.ReadFile(rel) + if err != nil { + t.Fatalf("reading %s (regenerate with go/scripts/gen_golden.py): %v", rel, err) + } + decoded, err := pyfmt.Load(raw) + if err != nil { + t.Fatalf("decoding %s: %v", rel, err) + } + obj, ok := decoded.(pyfmt.Ordered) + if !ok { + t.Fatalf("%s decoded as %T, want an object", rel, decoded) + } + return obj +} + +// Contract items 6 and 8. +func TestParseTerraformDirectory_ReturnValuesAndFileLocation(t *testing.T) { + out := t.TempDir() + path, total, iacType, err := ParseTerraformDirectory(vulnerableInfraFixture, out) + if err != nil { + t.Fatalf("ParseTerraformDirectory: %v", err) + } + + summary := loadPythonFixture(t, pythonSummaryFixture) + wantTotal, _ := summary.Get("total_resources") + wantType, _ := summary.Get("iac_type") + + if total != wantTotal { + t.Errorf("total_resources = %d, Python says %v", total, wantTotal) + } + if iacType != wantType { + t.Errorf("iac_type = %q, Python says %v", iacType, wantType) + } + if want := filepath.Join(out, "inventory.json"); path != want { + t.Errorf("path = %q, want %q", path, want) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("inventory.json was not written: %v", err) + } +} + +// Contract items 1-4 and 6: everything except the expression rendering must be +// identical to what the Python parser produced for the same fixture. +func TestParseTerraformDirectory_MatchesPythonOnVulnerableInfra(t *testing.T) { + got, _, _, _ := parseFixture(t, vulnerableInfraFixture) + want := loadPythonFixture(t, pythonInventoryFixture) + + assertSameKeyOrder(t, "$", got, want) + + gotRes := sectionOf(t, got, "resources") + wantRes := sectionOf(t, want, "resources") + if len(gotRes) != len(wantRes) { + t.Fatalf("resource count = %d, Python = %d", len(gotRes), len(wantRes)) + } + + for i := range wantRes { + g, w := asObject(t, gotRes[i]), asObject(t, wantRes[i]) + assertSameKeyOrder(t, "resources[%d]", g, w, i) + // Identity fields must match exactly. + for _, field := range []string{"id", "type", "name", "provider", "file_path", "line_number"} { + gv, _ := g.Get(field) + wv, _ := w.Get(field) + if !reflect.DeepEqual(gv, wv) { + t.Errorf("resources[%d].%s = %#v, Python = %#v", i, field, gv, wv) + } + } + // Config: same keys in the same order; same VALUES wherever Python + // produced something other than a pyhcl2 AST repr. + gcfg, _ := g.Get("config") + wcfg, _ := w.Get("config") + id, _ := g.Get("id") + compareIgnoringReprs(t, "resources["+pyfmt.Str(id)+"].config", gcfg, wcfg) + } + + // The non-resource sections have no non-constant expressions on this + // fixture, so they must be byte-identical. + for _, section := range []string{"variables", "outputs", "providers", "modules"} { + gv, _ := got.Get(section) + wv, _ := want.Get(section) + if !reflect.DeepEqual(gv, wv) { + t.Errorf("%s = %#v, Python = %#v", section, gv, wv) + } + } +} + +// Contract items 1-4 again, on the fixture that covers every HCL expression +// KIND rather than only the ones the benchmark repo happens to use. +func TestParseTerraformDirectory_MatchesPythonOnExpressionFixture(t *testing.T) { + got, _, _, _ := parseFixture(t, expressionsFixtureDir) + want := loadPythonFixture(t, pythonExpressionsFixt) + + assertSameKeyOrder(t, "$", got, want) + + for _, section := range []string{"resources", "variables", "outputs", "providers", "modules"} { + g := sectionOf(t, got, section) + w := sectionOf(t, want, section) + if len(g) != len(w) { + t.Fatalf("%s: %d entries, Python = %d", section, len(g), len(w)) + } + for i := range w { + path := section + "[" + itoa(i) + "]" + if section != "resources" { + compareIgnoringReprs(t, path, g[i], w[i]) + continue + } + // references/referenced_by are DERIVED from the config strings, so + // they inherit the expression-rendering divergence: Go sees the + // real traversals Python's AST reprs hide. They are asserted as a + // superset here and exactly in + // TestParseTerraformDirectory_ReferencesOnVulnerableInfra. + gr, wr := asObject(t, g[i]), asObject(t, w[i]) + assertSameKeyOrder(t, "%s", gr, wr, path) + for _, kv := range wr { + gv, present := gr.Get(kv.K) + if !present { + t.Errorf("%s.%s: missing in the Go output", path, kv.K) + continue + } + if kv.K == "references" || kv.K == "referenced_by" { + assertSuperset(t, path+"."+kv.K, stringsOf(gv), stringsOf(kv.V)) + continue + } + compareIgnoringReprs(t, path+"."+kv.K, gv, kv.V) + } + } + } +} + +// assertSuperset checks that Go still reports everything Python did — the +// expression-rendering divergence is additive, never subtractive. +func assertSuperset(t *testing.T, path string, got, want []string) { + t.Helper() + for _, w := range want { + if !contains(got, w) { + t.Errorf("%s: Go dropped %q, which Python reports (got %v)", path, w, got) + } + } +} + +// The documented divergence, pinned so it cannot grow silently: these — and +// ONLY these — are the leaves where Python emits a pyhcl2 AST repr and Go emits +// the expression's source text. +func TestParseTerraformDirectory_ExpressionRenderingDivergenceIsExactlyThis(t *testing.T) { + cases := []struct { + fixture string + python string + want []string + }{ + {vulnerableInfraFixture, pythonInventoryFixture, []string{ + "resources[0].config.iam_instance_profile", + "resources[0].config.vpc_security_group_ids[0]", + "resources[2].config.assume_role_policy", + "resources[3].config.role", + "resources[4].config.role", + "resources[4].config.policy", + "resources[6].config.bucket", + }}, + {expressionsFixtureDir, pythonExpressionsFixt, []string{ + "resources[0].config.neg", + "resources[0].config.cond", + "resources[0].config.fn", + "resources[0].config.nullv", + "resources[0].config.ref_list[0]", + "resources[0].config.local_ref", + "resources[0].config.var_ref", + "resources[0].config.each_ref", + "variables[0].type", + "variables[1].type", + "variables[2].type", + "outputs[0].value", + }}, + } + + for _, tc := range cases { + t.Run(tc.fixture, func(t *testing.T) { + got, _, _, _ := parseFixture(t, tc.fixture) + want := loadPythonFixture(t, tc.python) + + var divergent []string + collectReprPaths(&divergent, "", want) + sort.Strings(divergent) + expected := append([]string(nil), tc.want...) + sort.Strings(expected) + if !reflect.DeepEqual(divergent, expected) { + t.Fatalf("pyhcl2-repr leaves in the Python fixture:\n got: %v\nwant: %v", divergent, expected) + } + + // Every one of them must be a plain, repr-free value in Go. + for _, path := range divergent { + v := lookupPath(t, got, path) + if s, ok := v.(string); ok && strings.Contains(s, pyhcl2ReprMarker) { + t.Errorf("%s still contains a pyhcl2 repr in the Go output: %q", path, s) + } + } + }) + } +} + +// Contract item 5, spelled out per resource: Go's reference set is a SUPERSET of +// Python's on this fixture — it keeps the accidental matches Python finds inside +// string literals and adds the real traversals Python's AST reprs hid. +func TestParseTerraformDirectory_ReferencesOnVulnerableInfra(t *testing.T) { + got, _, _, _ := parseFixture(t, vulnerableInfraFixture) + want := loadPythonFixture(t, pythonInventoryFixture) + + goWant := map[string][]string{ + // "t2.micro" comes from the instance_type STRING LITERAL — Python finds + // it too. The other two are the real traversals. + "aws_instance.web_server": { + "aws_iam_instance_profile.web_profile", + "aws_security_group.allow_all", + "t2.micro", + }, + "aws_security_group.allow_all": {}, + // "ec2.amazonaws" comes from the "ec2.amazonaws.com" literal inside the + // jsonencode() argument; both sides find it. + "aws_iam_role.web_role": {"ec2.amazonaws"}, + "aws_iam_instance_profile.web_profile": {"aws_iam_role.web_role"}, + "aws_iam_role_policy.s3_full_access": { + "aws_iam_role.web_role", + "aws_s3_bucket.customer_data", + }, + "aws_s3_bucket.customer_data": {}, + "aws_s3_bucket_public_access_block.customer_data_block": {"aws_s3_bucket.customer_data"}, + } + + gotRes := sectionOf(t, got, "resources") + wantRes := sectionOf(t, want, "resources") + for i := range gotRes { + g, w := asObject(t, gotRes[i]), asObject(t, wantRes[i]) + id := pyfmt.Str(mustGet(t, g, "id")) + + gotRefs := stringsOf(mustGet(t, g, "references")) + if expected, ok := goWant[id]; !ok { + t.Errorf("unexpected resource %q", id) + } else if !reflect.DeepEqual(gotRefs, expected) { + t.Errorf("%s references = %v, want %v", id, gotRefs, expected) + } + + // Whatever Python found must still be found: the divergence is + // additive, never subtractive. + for _, pyRef := range stringsOf(mustGet(t, w, "references")) { + if !contains(gotRefs, pyRef) { + t.Errorf("%s: Go dropped the reference %q that Python reports", id, pyRef) + } + } + } + + // referenced_by is the exact reverse index of references. + reverse := map[string][]string{} + for _, r := range gotRes { + obj := asObject(t, r) + id := pyfmt.Str(mustGet(t, obj, "id")) + for _, ref := range stringsOf(mustGet(t, obj, "references")) { + reverse[ref] = append(reverse[ref], id) + } + } + for _, r := range gotRes { + obj := asObject(t, r) + id := pyfmt.Str(mustGet(t, obj, "id")) + got := stringsOf(mustGet(t, obj, "referenced_by")) + want := reverse[id] + if want == nil { + want = []string{} + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s referenced_by = %v, want %v", id, got, want) + } + } +} + +// Contract item 4, as a table straight out of _PROVIDER_MAP. +func TestProviderFromType(t *testing.T) { + cases := map[string]string{ + "aws_s3_bucket": "aws", + "azurerm_storage_account": "azure", + "azuread_application": "azure", + "google_storage_bucket": "gcp", + "kubernetes_deployment": "kubernetes", + "helm_release": "kubernetes", + "oci_core_instance": "oci", + "alicloud_instance": "alicloud", + "unknownprovider_thing": "unknownprovider", + "nounderscore": "nounderscore", // no "_" -> the whole type + "aws": "aws", + "_leading": "", // split("_")[0] is "" + "aws_iam_role_policy_attach": "aws", + } + for in, want := range cases { + if got := providerFromType(in); got != want { + t.Errorf("providerFromType(%q) = %q, want %q", in, got, want) + } + } +} + +// Contract item 5, in isolation. +func TestExtractReferences(t *testing.T) { + cases := []struct { + name string + input any + want []string + }{ + {"plain traversal", "aws_s3_bucket.b.arn", []string{"aws_s3_bucket.b"}}, + {"data traversal", "data.aws_ami.ubuntu.id", []string{"data.aws_ami.ubuntu"}}, + { + "excluded namespaces", + "var.x local.y each.key self.id count.index path.module terraform.workspace", + []string{}, + }, + { + // Every namespace prefix is excluded by startswith, so a nested + // var.-prefixed name is excluded too. + "namespace prefix match is a prefix test", + "var.something_long", + []string{}, + }, + {"uppercase is not a reference", "AWS_S3.Bucket", []string{}}, + {"digits may not lead", "1abc.def", []string{}}, + {"sorted and deduplicated", "b_x.y a_x.y b_x.y", []string{"a_x.y", "b_x.y"}}, + {"nested containers are walked", pyfmt.Ordered{ + {K: "a", V: []any{"aws_s3_bucket.one.id"}}, + {K: "b", V: pyfmt.Ordered{{K: "c", V: "aws_iam_role.two.arn"}}}, + {K: "d", V: 1}, + {K: "e", V: nil}, + {K: "f", V: true}, + }, []string{"aws_iam_role.two", "aws_s3_bucket.one"}}, + { + // The literal-string quirk that makes Python report "t2.micro". + "a dotted literal is indistinguishable from a reference", + "t2.micro", + []string{"t2.micro"}, + }, + { + // The regex stops at the second component, so a three-part + // traversal contributes only ".". + "three-part traversal yields two parts", + "aws_s3_bucket.customer_data.arn", + []string{"aws_s3_bucket.customer_data"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := extractReferences(tc.input); !reflect.DeepEqual(got, tc.want) { + t.Errorf("extractReferences = %v, want %v", got, tc.want) + } + }) + } +} + +// Contract item 1: pathlib ordering is component-wise, and rglob does not skip +// dot-directories. +func TestTerraformFiles_PathlibOrderAndHiddenDirectories(t *testing.T) { + root := t.TempDir() + for _, rel := range []string{ + "probe.tf", "sub/s.tf", ".hidden/h.tf", "_under/u.tf", + "a-b/c.tf", "a/b.tf", "not-terraform.txt", "deep/er/x.tf", + } { + full := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(full), 0o777); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte("# tf\n"), 0o666); err != nil { + t.Fatal(err) + } + } + + got, err := terraformFiles(root) + if err != nil { + t.Fatalf("terraformFiles: %v", err) + } + // sorted(Path(root).rglob("*.tf")) on Python 3.11 — component-wise, so + // "a/b.tf" precedes "a-b/c.tf" even though the raw strings sort the other + // way ('-' < '/'). + want := []string{ + ".hidden/h.tf", + "_under/u.tf", + "a/b.tf", + "a-b/c.tf", + "deep/er/x.tf", + "probe.tf", + "sub/s.tf", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("terraformFiles = %v\n want %v", got, want) + } +} + +// Contract item 1: a missing repo path yields an empty inventory, matching +// pathlib.rglob rather than raising. +func TestParseTerraformDirectory_MissingRepoIsEmptyNotFatal(t *testing.T) { + out := t.TempDir() + path, total, iacType, err := ParseTerraformDirectory(filepath.Join(t.TempDir(), "nope"), out) + if err != nil { + t.Fatalf("want no error for a missing repo, got %v", err) + } + if total != 0 || iacType != "terraform" { + t.Errorf("got (%d, %q), want (0, \"terraform\")", total, iacType) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want := "{\n \"resources\": [],\n \"variables\": [],\n \"outputs\": [],\n \"providers\": [],\n \"modules\": []\n}" + if string(raw) != want { + t.Errorf("empty inventory =\n%s\nwant\n%s", raw, want) + } +} + +// Contract item 7. +func TestParseTerraformDirectory_SkipsUnparsableFiles(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "broken.tf"), []byte("resource \"a\" {{{\n"), 0o666); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "ok.tf"), []byte("resource \"aws_s3_bucket\" \"b\" {\n bucket = \"x\"\n}\n"), 0o666); err != nil { + t.Fatal(err) + } + + out := t.TempDir() + _, total, _, err := ParseTerraformDirectory(root, out) + if err != nil { + t.Fatalf("an unparsable file must not be fatal: %v", err) + } + if total != 1 { + t.Errorf("total = %d, want 1 (the good file only)", total) + } +} + +// Contract item 2: labels below the required arity are ignored entirely. +func TestParseTerraformDirectory_IgnoresUnderLabelledBlocks(t *testing.T) { + root := t.TempDir() + src := ` +resource "aws_s3_bucket" { + bucket = "no-name-label" +} +data "aws_ami" { + most_recent = true +} +variable { + type = string +} +locals { + x = 1 +} +terraform { + required_version = ">= 1.0" +} +` + if err := os.WriteFile(filepath.Join(root, "main.tf"), []byte(src), 0o666); err != nil { + t.Fatal(err) + } + got, _, total, _ := parseFixture(t, root) + if total != 0 { + t.Errorf("total = %d, want 0", total) + } + for _, section := range []string{"resources", "variables", "outputs", "providers", "modules"} { + if entries := sectionOf(t, got, section); len(entries) != 0 { + t.Errorf("%s = %v, want empty", section, entries) + } + } +} + +// Contract item 3: the str()/raw asymmetries between the four non-resource +// record kinds. +func TestParseTerraformDirectory_NonResourceRecordShapes(t *testing.T) { + got, _, _, _ := parseFixture(t, expressionsFixtureDir) + + variables := sectionOf(t, got, "variables") + v0 := asObject(t, variables[1]) // variable "count_n" { type = number, default = 3 } + if v, _ := v0.Get("default"); v != "3" { + t.Errorf(`variable.default = %#v, want the STRING "3" (Python str()s it)`, v) + } + if v, _ := v0.Get("description"); v != nil { + t.Errorf("variable.description = %#v, want nil (absent, and NOT str()-ed)", v) + } + v2 := asObject(t, variables[2]) // variable "no_default" — no default at all + if v, _ := v2.Get("default"); v != nil { + t.Errorf("a missing variable default must stay nil, got %#v", v) + } + + outputs := sectionOf(t, got, "outputs") + o1 := asObject(t, outputs[1]) + if v, _ := o1.Get("value"); v != "hello" { + t.Errorf("output.value = %#v, want %q", v, "hello") + } + if v, _ := o1.Get("description"); v != nil { + t.Errorf("output.description = %#v, want nil", v) + } + + providers := sectionOf(t, got, "providers") + p0 := asObject(t, providers[0]) + if v, _ := p0.Get("version"); v != nil { + t.Error("provider.version must always be nil — the parser never reads one") + } + if v, _ := p0.Get("alias"); v != "second" { + t.Errorf("provider.alias = %#v, want %q", v, "second") + } + + modules := sectionOf(t, got, "modules") + m0 := asObject(t, modules[0]) + if v, _ := m0.Get("version"); v != "3.0.0" { + t.Errorf("module.version = %#v, want %q", v, "3.0.0") + } +} + +// Nested-block collapsing: labeled -> map keyed by the label, unlabeled repeats +// -> a list, single unlabeled -> a bare object. +func TestBlockToDict_NestedBlockCollapsing(t *testing.T) { + got, _, _, _ := parseFixture(t, expressionsFixtureDir) + cfg := asObject(t, mustGet(t, asObject(t, sectionOf(t, got, "resources")[0]), "config")) + + versioning, _ := cfg.Get("versioning") + if _, ok := versioning.(pyfmt.Ordered); !ok { + t.Errorf("a single unlabeled block must be an object, got %T", versioning) + } + + ingress, _ := cfg.Get("ingress") + list, ok := ingress.([]any) + if !ok || len(list) != 2 { + t.Fatalf("repeated unlabeled blocks must collapse into a 2-element list, got %#v", ingress) + } + if v, _ := asObject(t, list[0]).Get("from_port"); v != 1 { + t.Errorf("ingress[0].from_port = %#v, want 1", v) + } + if v, _ := asObject(t, list[1]).Get("from_port"); v != 2 { + t.Errorf("ingress[1].from_port = %#v, want 2", v) + } + + dyn, _ := cfg.Get("dynamic") + dynObj, ok := dyn.(pyfmt.Ordered) + if !ok { + t.Fatalf("a labeled block must be keyed by its label, got %T", dyn) + } + if _, ok := dynObj.Get("rule"); !ok { + t.Errorf(`dynamic block must be keyed "rule", got %v`, dynObj) + } +} + +// The Go copy of the Terraform fixture must not drift from the one the Python +// tests use. +func TestVulnerableInfraFixture_MatchesThePythonTree(t *testing.T) { + pythonCopy, err := os.ReadFile(pythonTreeInfraRelative) + if err != nil { + t.Skipf("Python tree not present in this checkout: %v", err) + } + goCopy, err := os.ReadFile(filepath.Join(vulnerableInfraFixture, "main.tf")) + if err != nil { + t.Fatal(err) + } + if string(goCopy) != string(pythonCopy) { + t.Error("testdata/vulnerable_infra/main.tf has drifted from tests/fixtures/vulnerable_infra/main.tf; rerun go/scripts/gen_golden.py") + } +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +func sectionOf(t *testing.T, o pyfmt.Ordered, key string) []any { + t.Helper() + v, ok := o.Get(key) + if !ok { + t.Fatalf("missing section %q", key) + } + items, ok := v.([]any) + if !ok { + t.Fatalf("section %q is %T, want a list", key, v) + } + return items +} + +func asObject(t *testing.T, v any) pyfmt.Ordered { + t.Helper() + o, ok := v.(pyfmt.Ordered) + if !ok { + t.Fatalf("value is %T, want an object", v) + } + return o +} + +func assertSameKeyOrder(t *testing.T, format string, got, want pyfmt.Ordered, args ...any) { + t.Helper() + gk := make([]string, len(got)) + for i, kv := range got { + gk[i] = kv.K + } + wk := make([]string, len(want)) + for i, kv := range want { + wk[i] = kv.K + } + if !reflect.DeepEqual(gk, wk) { + t.Errorf(format+": key order = %v, Python = %v", append(args, gk, wk)...) + } +} + +// compareIgnoringReprs asserts Go and Python agree everywhere except at the +// leaves where Python emitted a pyhcl2 AST repr. +func compareIgnoringReprs(t *testing.T, path string, got, want any) { + t.Helper() + if s, ok := want.(string); ok && strings.Contains(s, pyhcl2ReprMarker) { + return // documented divergence; pinned separately + } + switch w := want.(type) { + case pyfmt.Ordered: + g, ok := got.(pyfmt.Ordered) + if !ok { + t.Errorf("%s: Go value is %T, Python has an object", path, got) + return + } + assertSameKeyOrder(t, "%s", g, w, path) + for _, kv := range w { + gv, present := g.Get(kv.K) + if !present { + t.Errorf("%s.%s: missing in the Go output", path, kv.K) + continue + } + compareIgnoringReprs(t, path+"."+kv.K, gv, kv.V) + } + case []any: + g, ok := got.([]any) + if !ok { + t.Errorf("%s: Go value is %T, Python has a list", path, got) + return + } + if len(g) != len(w) { + t.Errorf("%s: %d elements, Python has %d", path, len(g), len(w)) + return + } + for i := range w { + compareIgnoringReprs(t, path+"["+itoa(i)+"]", g[i], w[i]) + } + default: + if !reflect.DeepEqual(got, want) { + t.Errorf("%s = %#v, Python = %#v", path, got, want) + } + } +} + +// collectReprPaths records every leaf of the Python fixture whose value is a +// pyhcl2 AST repr. +func collectReprPaths(out *[]string, path string, v any) { + switch x := v.(type) { + case pyfmt.Ordered: + for _, kv := range x { + child := kv.K + if path != "" { + child = path + "." + kv.K + } + collectReprPaths(out, child, kv.V) + } + case []any: + for i, item := range x { + collectReprPaths(out, path+"["+itoa(i)+"]", item) + } + case string: + if strings.Contains(x, pyhcl2ReprMarker) { + *out = append(*out, path) + } + } +} + +// lookupPath resolves a dotted/indexed path produced by collectReprPaths. +func lookupPath(t *testing.T, root pyfmt.Ordered, path string) any { + t.Helper() + var cur any = root + for _, seg := range strings.Split(path, ".") { + name, indices := splitIndices(seg) + if name != "" { + obj, ok := cur.(pyfmt.Ordered) + if !ok { + t.Fatalf("%s: %q is not an object", path, name) + } + v, present := obj.Get(name) + if !present { + t.Fatalf("%s: key %q missing", path, name) + } + cur = v + } + for _, idx := range indices { + list, ok := cur.([]any) + if !ok || idx >= len(list) { + t.Fatalf("%s: index %d out of range", path, idx) + } + cur = list[idx] + } + } + return cur +} + +func splitIndices(seg string) (string, []int) { + open := strings.IndexByte(seg, '[') + if open < 0 { + return seg, nil + } + name := seg[:open] + var indices []int + rest := seg[open:] + for len(rest) > 0 && rest[0] == '[' { + close := strings.IndexByte(rest, ']') + if close < 0 { + break + } + n := 0 + for _, c := range rest[1:close] { + n = n*10 + int(c-'0') + } + indices = append(indices, n) + rest = rest[close+1:] + } + return name, indices +} + +func stringsOf(v any) []string { + items, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(items)) + for _, it := range items { + if s, ok := it.(string); ok { + out = append(out, s) + } + } + return out +} + +func contains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + +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:]) +} + +// TestParseTerraformDirectory_IntegerLiteralsKeepPythonsExactDigits pins the +// arbitrary-precision half of Python's int. +// +// pyhcl2 evaluates an integer literal into `Integer._raw`, a Python int, and +// `json.dump` writes its exact digits however large it is. Ground truth from +// the repo venv on this exact fixture: +// +// "big_port": 12345678901234567890, +// "edge_hi": 9223372036854775807, +// "edge_ovr": 9223372036854775808, +// "small": 5432, +// "floaty": 1.5 +// +// Converting through float64 (what `bf.Float64()` does) wrote +// 1.2345678901234567e+19 and 9.223372036854776e+18 instead — and because +// "port" is a configSummaryKeywords match, the lossy value also reached +// graph.json's config_summary and the hunter prompt's `Config: {...}` line. +func TestParseTerraformDirectory_IntegerLiteralsKeepPythonsExactDigits(t *testing.T) { + root := t.TempDir() + src := `resource "aws_s3_bucket" "b" { + bucket = "x" + big_port = 12345678901234567890 + edge_hi = 9223372036854775807 + edge_ovr = 9223372036854775808 + small = 5432 + floaty = 1.5 +} +` + if err := os.WriteFile(filepath.Join(root, "main.tf"), []byte(src), 0o666); err != nil { + t.Fatal(err) + } + + out := t.TempDir() + path, _, _, err := ParseTerraformDirectory(root, out) + if err != nil { + t.Fatalf("ParseTerraformDirectory: %v", err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read inventory.json: %v", err) + } + text := string(raw) + for _, want := range []string{ + `"big_port": 12345678901234567890`, + `"edge_hi": 9223372036854775807`, + `"edge_ovr": 9223372036854775808`, + `"small": 5432`, + `"floaty": 1.5`, + } { + if !strings.Contains(text, want) { + t.Errorf("inventory.json is missing %s\ngot:\n%s", want, text) + } + } + if strings.Contains(text, "e+19") || strings.Contains(text, "e+18") { + t.Errorf("an integer literal was written as a float:\n%s", text) + } + + // The read side has to agree, or the value degrades on its way into + // graph.json's config_summary and the hunter prompt. + reloaded, err := pyfmt.Load(raw) + if err != nil { + t.Fatalf("pyfmt.Load: %v", err) + } + config := configOfFirstResource(t, reloaded) + big, _ := config.Get("big_port") + if got := pyfmt.Repr(big); got != "12345678901234567890" { + t.Errorf("repr(config[\"big_port\"]) = %s, want the exact digits", got) + } + if got := pyfmt.Dumps(config, 0); !strings.Contains(got, `"big_port": 12345678901234567890`) { + t.Errorf("re-dumping the loaded config lost the digits: %s", got) + } +} + +func configOfFirstResource(t *testing.T, decoded any) pyfmt.Ordered { + t.Helper() + obj, ok := decoded.(pyfmt.Ordered) + if !ok { + t.Fatalf("inventory decoded as %T", decoded) + } + resources, _ := obj.Get("resources") + list, ok := resources.([]any) + if !ok || len(list) == 0 { + t.Fatalf("resources = %#v", resources) + } + first, ok := list[0].(pyfmt.Ordered) + if !ok { + t.Fatalf("resources[0] = %T", list[0]) + } + config, _ := first.Get("config") + cfg, ok := config.(pyfmt.Ordered) + if !ok { + t.Fatalf("config = %T", config) + } + return cfg +} + +// TestParseTerraformDirectory_ParserAcceptanceDivergesFromPyhcl2 pins the +// whole-file divergence documented on ParseTerraformDirectory. +// +// Both nodes skip a .tf file their parser rejects, but the two parsers disagree +// about WHICH files those are. Measured against pyhcl2 in the repo venv: +// +// parse_file("resource \"a\" \"b\" { p = 1e3 }") -> DiagnosticError +// parse_file("resource \"a\" \"b\" { p = 1E3 }") -> DiagnosticError +// parse_file("resource \"a\" \"b\" { p = 1.5e-3 }") -> DiagnosticError +// parse_file("resource \"a\" \"b\" { p = 1000 }") -> OK +// +// so for the exponent files Python emits an EMPTY inventory while Go emits the +// resource. Go is the better behaviour (the spellings are valid HCL); this test +// exists so the divergence is a recorded, deliberate one rather than something +// a reviewer diffing two inventories has to rediscover. +func TestParseTerraformDirectory_ParserAcceptanceDivergesFromPyhcl2(t *testing.T) { + for _, literal := range []string{"1e3", "1E3", "1.5e-3"} { + root := t.TempDir() + src := "resource \"aws_s3_bucket\" \"b\" {\n p = " + literal + "\n}\n" + if err := os.WriteFile(filepath.Join(root, "main.tf"), []byte(src), 0o666); err != nil { + t.Fatal(err) + } + _, total, _, err := ParseTerraformDirectory(root, t.TempDir()) + if err != nil { + t.Fatalf("%s: %v", literal, err) + } + if total != 1 { + t.Errorf("p = %s -> total = %d, want 1 (hclsyntax accepts exponents; "+ + "pyhcl2 rejects them and Python drops the whole file)", literal, total) + } + } +} diff --git a/go/internal/agents/remediate/doc.go b/go/internal/agents/remediate/doc.go new file mode 100644 index 0000000..2baa7e6 --- /dev/null +++ b/go/internal/agents/remediate/doc.go @@ -0,0 +1,47 @@ +// Package remediate ports src/cloudsecurity_af/agents/remediate/** — the +// REMEDIATION phase's single agent. +// +// Python Go +// ------------------------------------------ ------------------------------ +// remediate/fix_generator.run_fix_generator RunFixGenerator +// remediate/fix_generator._build_prompt BuildFixGeneratorPrompt +// +// RunFixGenerator is what internal/reasoners wraps as the `run_fix_generator` +// router reasoner; internal/phases drives it once per remediable finding through +// app.Call, never in-process, exactly as remediation_phase does in Python. +// +// # Shape of the phase +// +// One harness call per finding, with cwd= and +// project_dir=, schema'd against RemediationSuggestion. There is no +// deterministic pre- or post-processing: the diffs, the breaking-change flag, +// the downtime estimate and the effort all come straight out of the model. +// Notably `finding_id` is a MANDATORY field of the prompt but is NOT stamped in +// by the Go or the Python code — the model is asked to echo it from +// {{FINDING_JSON}}. +// +// # Divergences from Python, in one place +// +// 1. model_dump(mode="json") vs model_dump(). This is the only agent in the +// repo that asks for the JSON dump mode. For VerifiedFinding the two are +// indistinguishable — every field is a str/int/float/bool, a str-Enum, a +// list, a nested BaseModel or None, and json.dumps renders a str-Enum by its +// value either way (verified against the interpreter). The Go port has one +// dumper and therefore one behavior; a future VerifiedFinding field with a +// type whose python and json modes differ (datetime, UUID, Decimal) would +// need revisiting. +// 2. The json.dumps site goes through pyfmt.Dumps, whose documented deviations +// are: `Any`-typed leaves that arrived as JSON numbers are float64 in Go, so +// an integer renders as "1.0" where Python renders "1" (only reachable +// through the DriftedResource nested in a VerifiedFinding); a Go map has no +// insertion order, so a map[string]any inside a finding is dumped with +// SORTED keys; and a NIL Go slice renders as `null` where pydantic's +// default_factory=list guarantees `[]` — unreachable in the live DAG, where +// every finding crossed a control-plane JSON boundary and was re-seeded by +// VerifiedFinding.UnmarshalJSON. Struct field order — which is what +// model_dump() order actually is — is preserved exactly. +// +// Everything else — the prompt bytes, the substitution ORDER, the tempdir +// prefix, the cwd/project_dir pair, the extract agent name ("FixGenerator") and +// the cleanup semantics — is byte-for-byte the Python behavior. +package remediate diff --git a/go/internal/agents/remediate/fix_generator.go b/go/internal/agents/remediate/fix_generator.go new file mode 100644 index 0000000..ccaf434 --- /dev/null +++ b/go/internal/agents/remediate/fix_generator.go @@ -0,0 +1,130 @@ +package remediate + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/harnessx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/prompts" + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// fixGeneratorPromptPath is PROMPT_PATH in fix_generator.py, resolved against +// the embedded prompt tree instead of the installed package's prompts/ +// directory. +const fixGeneratorPromptPath = "remediate/fix_generator.txt" + +// fixGeneratorTempPrefix is the tempfile.mkdtemp prefix in run_fix_generator. +const fixGeneratorTempPrefix = "cloudsecurity-fix-generator-" + +// fixGeneratorAgentName is the `agent_name` passed to extract_harness_result; it +// appears verbatim in every error message and diagnostic line. +const fixGeneratorAgentName = "FixGenerator" + +// RunFixGenerator ports run_fix_generator in +// src/cloudsecurity_af/agents/remediate/fix_generator.py: +// +// template = PROMPT_PATH.read_text(encoding="utf-8") +// prompt = _build_prompt(template, finding, repo_path) +// harness_cwd = tempfile.mkdtemp(prefix="cloudsecurity-fix-generator-") +// try: +// result = await app.harness(prompt=prompt, schema=RemediationSuggestion, +// cwd=harness_cwd, project_dir=repo_path) +// return extract_harness_result(result, RemediationSuggestion, "FixGenerator") +// finally: +// shutil.rmtree(harness_cwd, ignore_errors=True) +// +// The returned RemediationSuggestion is exactly what the model produced; this +// function stamps nothing in afterwards, not even finding_id. See doc.go. +func RunFixGenerator( + ctx context.Context, + app appx.Harnesser, + repoPath string, + finding schemas.VerifiedFinding, +) (schemas.RemediationSuggestion, error) { + prompt, err := BuildFixGeneratorPrompt(finding, repoPath) + if err != nil { + // Python parity: PROMPT_PATH.read_text() raising surfaces as a failed + // reasoner, and it happens BEFORE mkdtemp. + return schemas.RemediationSuggestion{}, err + } + + harnessCwd, err := os.MkdirTemp("", fixGeneratorTempPrefix) + if err != nil { + return schemas.RemediationSuggestion{}, fmt.Errorf("cloudsecurity remediate: creating fix-generator work dir: %w", err) + } + // Python: `finally: shutil.rmtree(harness_cwd, ignore_errors=True)`. + defer func() { _ = os.RemoveAll(harnessCwd) }() // ignore_errors=True + + return harnessx.RunExtract[schemas.RemediationSuggestion]( + ctx, app, prompt, + // Python parity: cwd is the throwaway tempdir, project_dir is the + // repository whose IaC the patch targets. + harness.Options{Cwd: harnessCwd, ProjectDir: repoPath}, + fixGeneratorAgentName, + ) +} + +// BuildFixGeneratorPrompt ports _build_prompt. Exported for the golden test, +// which compares it byte-for-byte against the string the Python builder emits. +// +// replacements = { +// "{{TITLE}}": finding.title, +// "{{DESCRIPTION}}": finding.description, +// "{{VERDICT}}": finding.verdict.value, +// "{{SEVERITY}}": finding.severity.value, +// "{{CATEGORY}}": finding.category, +// "{{IAC_FILE}}": finding.iac_file, +// "{{IAC_LINE}}": str(finding.iac_line), +// "{{CONFIG_SNIPPET}}": finding.config_snippet, +// "{{SARIF_RULE_ID}}": finding.sarif_rule_id, +// "{{RISK_SCORE}}": str(finding.risk_score), +// "{{FINDING_JSON}}": json.dumps(finding.model_dump(mode="json"), indent=2), +// "{{REPO_PATH}}": repo_path, +// } +// for needle, value in replacements.items(): +// prompt = prompt.replace(needle, value) +// +// PYTHON PARITY — SUBSTITUTION ORDER IS LOAD-BEARING. Python 3.7+ dicts iterate +// in insertion order, so the 12 replacements run in exactly the order written +// above over the same accumulating string, and a value containing a later +// placeholder is substituted a second time. The Go port keeps the order. +// +// PYTHON PARITY — {{RISK_SCORE}} IS `str(float)`, not a rounded or formatted +// number: 0.0 renders as "0.0", 8.5 as "8.5", 7.25 as "7.25". pyfmt.FormatFloat +// is repr(float), which is what str(float) has been since Python 3.1. +func BuildFixGeneratorPrompt(finding schemas.VerifiedFinding, repoPath string) (string, error) { + template, err := prompts.Load(fixGeneratorPromptPath) + if err != nil { + return "", err + } + + // Ordered exactly like the Python dict literal. + replacements := []struct{ needle, value string }{ + {"{{TITLE}}", finding.Title}, + {"{{DESCRIPTION}}", finding.Description}, + {"{{VERDICT}}", finding.Verdict.String()}, + {"{{SEVERITY}}", finding.Severity.String()}, + {"{{CATEGORY}}", finding.Category}, + {"{{IAC_FILE}}", finding.IaCFile}, + {"{{IAC_LINE}}", strconv.Itoa(finding.IaCLine)}, + {"{{CONFIG_SNIPPET}}", finding.ConfigSnippet}, + {"{{SARIF_RULE_ID}}", finding.SARIFRuleID}, + {"{{RISK_SCORE}}", pyfmt.FormatFloat(finding.RiskScore)}, + {"{{FINDING_JSON}}", pyfmt.Dumps(finding, 2)}, + {"{{REPO_PATH}}", repoPath}, + } + + prompt := template + for _, r := range replacements { + prompt = strings.ReplaceAll(prompt, r.needle, r.value) + } + return prompt, nil +} diff --git a/go/internal/agents/remediate/fix_generator_test.go b/go/internal/agents/remediate/fix_generator_test.go new file mode 100644 index 0000000..1d55306 --- /dev/null +++ b/go/internal/agents/remediate/fix_generator_test.go @@ -0,0 +1,296 @@ +package remediate + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +// remediateInputs mirrors go/scripts/gen_golden.py's remediate inputs.json — the +// exact pydantic models the Python builder was driven with, so the golden +// comparison is against the same values rather than a hand transcription. +type remediateInputs struct { + VerifiedFull schemas.VerifiedFinding `json:"verified_full"` + VerifiedBare schemas.VerifiedFinding `json:"verified_bare"` + RepoPath string `json:"repo_path"` +} + +func loadInputs(t *testing.T) remediateInputs { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", "golden", "inputs.json")) + if err != nil { + t.Fatalf("read inputs.json: %v", err) + } + var in remediateInputs + if err := json.Unmarshal(raw, &in); err != nil { + t.Fatalf("decode inputs.json: %v", err) + } + return in +} + +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", name, err) + } + return string(raw) +} + +// suggestionJSON is a schema-valid RemediationSuggestion the fake harness +// returns. finding_id is deliberately EMPTY: run_fix_generator must not stamp +// one in afterwards. +const suggestionJSON = `{ + "finding_id": "", + "description": "Scope the trust policy.", + "diffs": [{"file_path": "main.tf", "original_lines": "a", "patched_lines": "b", "start_line": 12, "end_line": 12}], + "breaking_change": false, + "downtime_estimate": "none", + "effort": "trivial", + "alternative_approaches": [] +}` + +// --------------------------------------------------------------------------- +// Prompt golden — the bytes that reach the model +// --------------------------------------------------------------------------- + +// TestBuildFixGeneratorPrompt_Golden pins _build_prompt byte-for-byte for a +// fully populated finding (nested attack path, drift, proof and remediation) and +// for one left entirely at its pydantic defaults. +func TestBuildFixGeneratorPrompt_Golden(t *testing.T) { + in := loadInputs(t) + cases := []struct { + name string + finding schemas.VerifiedFinding + want string + }{ + {"a_fully_populated", in.VerifiedFull, "fix_prompt_a.txt"}, + {"b_all_defaults", in.VerifiedBare, "fix_prompt_b.txt"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := BuildFixGeneratorPrompt(tc.finding, in.RepoPath) + if err != nil { + t.Fatalf("BuildFixGeneratorPrompt: %v", err) + } + if want := golden(t, tc.want); got != want { + t.Errorf("prompt differs from Python\n%s", firstDiff(got, want)) + } + }) + } +} + +// TestBuildFixGeneratorPrompt_RiskScoreIsStrFloat pins `str(finding.risk_score)`: +// Python's str() of a float always shows a decimal point, so a zero score is +// "0.0" and an integral one is "9.0" — never "0" or "9". +func TestBuildFixGeneratorPrompt_RiskScoreIsStrFloat(t *testing.T) { + cases := []struct { + score float64 + want string + }{ + {0, "- Risk score: 0.0"}, + {9, "- Risk score: 9.0"}, + {7.25, "- Risk score: 7.25"}, + {-1.5, "- Risk score: -1.5"}, + } + for _, tc := range cases { + f := schemas.NewVerifiedFinding() + f.Verdict = schemas.VerdictConfirmed + f.RiskScore = tc.score + got, err := BuildFixGeneratorPrompt(f, "/repo") + if err != nil { + t.Fatalf("BuildFixGeneratorPrompt: %v", err) + } + if !strings.Contains(got, tc.want) { + t.Errorf("risk_score %v did not render as %q", tc.score, tc.want) + } + } +} + +// TestBuildFixGeneratorPrompt_SubstitutionOrder pins the parity quirk that the +// 12 replacements run in the Python dict's insertion order over one accumulating +// string: a {{REPO_PATH}} embedded in the title is still ahead of the loop and +// gets substituted, while a {{TITLE}} embedded in it is behind it and survives. +func TestBuildFixGeneratorPrompt_SubstitutionOrder(t *testing.T) { + f := schemas.NewVerifiedFinding() + f.Verdict = schemas.VerdictLikely + f.Severity = "low" + f.Title = "{{TITLE}} at {{REPO_PATH}}" + + got, err := BuildFixGeneratorPrompt(f, "/srv/repo") + if err != nil { + t.Fatalf("BuildFixGeneratorPrompt: %v", err) + } + if !strings.Contains(got, "- Title: {{TITLE}} at /srv/repo") { + t.Errorf("expected the later {{REPO_PATH}} to be substituted and the already-consumed {{TITLE}} to survive:\n%s", got) + } +} + +// TestBuildFixGeneratorPrompt_EnumsRenderAsValues pins that {{VERDICT}} and +// {{SEVERITY}} are the enums' `.value`, not their Python repr +// ("Verdict.CONFIRMED") — the Go string types already are the value. +func TestBuildFixGeneratorPrompt_EnumsRenderAsValues(t *testing.T) { + f := schemas.NewVerifiedFinding() + f.Verdict = schemas.VerdictNotExploitable + f.Severity = "critical" + + got, err := BuildFixGeneratorPrompt(f, "/repo") + if err != nil { + t.Fatalf("BuildFixGeneratorPrompt: %v", err) + } + for _, want := range []string{"- Verdict: not_exploitable", "- Severity: critical"} { + if !strings.Contains(got, want) { + t.Errorf("prompt is missing %q", want) + } + } +} + +// --------------------------------------------------------------------------- +// RunFixGenerator +// --------------------------------------------------------------------------- + +func okApp() *appx.Fake { + return &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return json.RawMessage(suggestionJSON), nil + })} +} + +// TestRunFixGenerator_HarnessOptions pins the tempdir prefix, that project_dir +// is the REPOSITORY (not the tempdir), that exactly one harness call happens, +// and that the tempdir is removed afterwards. +func TestRunFixGenerator_HarnessOptions(t *testing.T) { + in := loadInputs(t) + app := okApp() + + got, err := RunFixGenerator(context.Background(), app, "/repo/under/audit", in.VerifiedFull) + if err != nil { + t.Fatalf("RunFixGenerator: %v", err) + } + if len(app.Harnesses) != 1 { + t.Fatalf("made %d harness calls, want 1", len(app.Harnesses)) + } + call := app.Harnesses[0] + if !strings.Contains(call.Prompt, "You are the CloudSecurity remediation generator.") { + t.Errorf("prompt does not come from the fix-generator template:\n%s", call.Prompt) + } + if base := filepath.Base(call.Opts.Cwd); !strings.HasPrefix(base, fixGeneratorTempPrefix) { + t.Errorf("cwd = %q, want a tempdir named %q*", call.Opts.Cwd, fixGeneratorTempPrefix) + } + if call.Opts.ProjectDir != "/repo/under/audit" { + t.Errorf("project_dir = %q, want the repo path", call.Opts.ProjectDir) + } + if _, err := os.Stat(call.Opts.Cwd); !os.IsNotExist(err) { + t.Errorf("temp dir %q survived the call (stat err = %v)", call.Opts.Cwd, err) + } + if got.Description != "Scope the trust policy." || len(got.Diffs) != 1 || got.Effort != "trivial" { + t.Errorf("returned %+v, want the model's RemediationSuggestion verbatim", got) + } +} + +// TestRunFixGenerator_DoesNotStampFindingID pins the boundary: `finding_id` is a +// MANDATORY field of the prompt but nothing in the Python agent fills it in +// afterwards, so an empty one must survive the call untouched. +func TestRunFixGenerator_DoesNotStampFindingID(t *testing.T) { + in := loadInputs(t) + app := okApp() + + got, err := RunFixGenerator(context.Background(), app, "/repo", in.VerifiedFull) + if err != nil { + t.Fatalf("RunFixGenerator: %v", err) + } + if got.FindingID != "" { + t.Errorf("finding_id = %q, want it left exactly as the model returned it", got.FindingID) + } +} + +// TestRunFixGenerator_HarnessErrorUsesTheAgentName pins the agent name that +// reaches extract_harness_result and therefore the error string the phase logs +// before it drops the remediation. +func TestRunFixGenerator_HarnessErrorUsesTheAgentName(t *testing.T) { + in := loadInputs(t) + app := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return nil, errors.New("boom") + })} + + _, err := RunFixGenerator(context.Background(), app, "/repo", in.VerifiedBare) + if want := "FixGenerator harness error: boom"; err == nil || err.Error() != want { + t.Errorf("error = %v, want %q", err, want) + } +} + +// TestRunFixGenerator_TempDirIsUniquePerCall guards against a shared work +// directory: remediation_phase runs several fix generators concurrently and +// Python's mkdtemp gives each its own. +func TestRunFixGenerator_TempDirIsUniquePerCall(t *testing.T) { + in := loadInputs(t) + app := okApp() + + for i := 0; i < 2; i++ { + if _, err := RunFixGenerator(context.Background(), app, "/repo", in.VerifiedBare); err != nil { + t.Fatalf("RunFixGenerator: %v", err) + } + } + if app.Harnesses[0].Opts.Cwd == app.Harnesses[1].Opts.Cwd { + t.Errorf("both calls shared cwd %q", app.Harnesses[0].Opts.Cwd) + } +} + +// --------------------------------------------------------------------------- +// json.dumps divergences, pinned explicitly rather than hidden +// --------------------------------------------------------------------------- + +// TestFindingJSON_DumpModesAreIndistinguishable pins the doc.go claim that +// model_dump(mode="json") — which only this agent asks for — produces the same +// bytes as model_dump() for VerifiedFinding: the golden was generated from the +// json mode, and every field is a scalar, a str-Enum, a list, a nested model or +// None. The assertions below name the field kinds that would break first if a +// future field needed real json-mode coercion. +func TestFindingJSON_DumpModesAreIndistinguishable(t *testing.T) { + in := loadInputs(t) + dump := pyfmt.Dumps(in.VerifiedFull, 2) + + for _, want := range []string{ + `"verdict": "confirmed"`, // str-Enum -> its value + `"severity": "critical"`, // str-Enum -> its value + `"method": "static_analysis"`, // nested model's str-Enum + `"risk_score": 9.25`, // float + `"iac_line": 12`, // int + `"breaking_change": false`, // bool + `"drop_reason": null`, // Optional[str] = None + `"scripts_executed": []`, // default_factory=list + `"combined_severity": "critical"`, // enum inside the nested AttackPath + } { + if !strings.Contains(dump, want) { + t.Errorf("VerifiedFinding dump is missing %s", want) + } + } +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +func firstDiff(got, want string) string { + g, w := strings.Split(got, "\n"), strings.Split(want, "\n") + for i := 0; i < len(g) && i < len(w); i++ { + if g[i] != w[i] { + return fmt.Sprintf("first difference at line %d:\n go: %q\n python: %q", i+1, g[i], w[i]) + } + } + return fmt.Sprintf("line counts differ: go %d lines, python %d lines", len(g), len(w)) +} diff --git a/go/internal/agents/remediate/testdata/golden/fix_prompt_a.txt b/go/internal/agents/remediate/testdata/golden/fix_prompt_a.txt new file mode 100644 index 0000000..d1a6127 --- /dev/null +++ b/go/internal/agents/remediate/testdata/golden/fix_prompt_a.txt @@ -0,0 +1,204 @@ +ROLE: +You are the CloudSecurity remediation generator. + +OBJECTIVE: +Produce a minimal, actionable IaC remediation patch for a verified finding. + +INPUTS: +- Repo path: /fixture/repo +- Title: Role trusts * & assumes admin +- Description: aws_iam_role.admin has a wildcard trust policy. +- Verdict: confirmed +- Severity: critical +- Category: overprivilege +- Risk score: 9.25 +- SARIF rule: cloudsecurity/iam/overprivilege +- IaC location: main.tf:12 +- Config snippet: +resource "aws_iam_role" "admin" { + assume_role_policy = "*" +} +- Full finding JSON: +{ + "id": "verified-1", + "title": "Role trusts * & assumes admin", + "verdict": "confirmed", + "severity": "critical", + "category": "overprivilege", + "resources": [ + { + "resource_id": "aws_iam_role.admin", + "resource_type": "aws_iam_role", + "attribute": "assume_role_policy", + "current_value": "{\"Principal\": \"*\"}", + "recommended_value": "scoped principal" + } + ], + "attack_path": { + "id": "path-1", + "title": "Wildcard role -> public bucket", + "description": "An anonymous principal assumes the admin role and reads the data lake.", + "steps": [ + { + "step_number": 1, + "resource_id": "aws_iam_role.admin", + "resource_type": "aws_iam_role", + "action": "sts:AssumeRole as any principal", + "permission_used": "assume_role_policy: *", + "description": "" + }, + { + "step_number": 2, + "resource_id": "aws_s3_bucket.data", + "resource_type": "aws_s3_bucket", + "action": "s3:GetObject", + "permission_used": "role policy allows s3:*", + "description": "Exfiltrate the data lake." + } + ], + "entry_point": "aws_iam_role.admin", + "target": "aws_s3_bucket.data", + "findings_involved": [ + "finding-1", + "finding-2" + ], + "combined_severity": "critical", + "blast_radius": { + "data_stores_reachable": [ + "aws_s3_bucket.data" + ], + "compute_reachable": [], + "estimated_data_volume": "~2 TB", + "services_affected": [ + "s3", + "iam" + ] + } + }, + "drift": { + "resource_id": "aws_s3_bucket.data", + "resource_type": "aws_s3_bucket", + "iac_config": { + "acl": "private", + "versioning": true + }, + "live_config": { + "acl": "public-read", + "versioning": false + }, + "diffs": [ + { + "attribute": "acl", + "iac_value": "private", + "live_value": "public-read", + "security_impact": "bucket became world readable" + }, + { + "attribute": "retention_days", + "iac_value": 30.0, + "live_value": null, + "security_impact": null + }, + { + "attribute": "mfa_delete", + "iac_value": true, + "live_value": false, + "security_impact": null + } + ], + "security_relevant": true, + "significance": "critical" + }, + "proof": { + "method": "static_analysis", + "evidence": [ + "main.tf:12 assume_role_policy allows *" + ], + "scripts_executed": [], + "verification_tier": "static" + }, + "compliance_mappings": [ + "CIS-1.16", + "SOC2-CC6.1" + ], + "risk_score": 9.25, + "remediation": { + "finding_id": "verified-1", + "description": "Scope the trust policy.", + "diffs": [ + { + "file_path": "main.tf", + "original_lines": " assume_role_policy = \"*\"", + "patched_lines": " assume_role_policy = data.aws_iam_policy_document.scoped.json", + "start_line": 12, + "end_line": 12 + } + ], + "breaking_change": false, + "downtime_estimate": "none", + "effort": "trivial", + "alternative_approaches": [ + "Use a permission boundary" + ] + }, + "sarif_rule_id": "cloudsecurity/iam/overprivilege", + "sarif_security_severity": 9.0, + "iac_file": "main.tf", + "iac_line": 12, + "config_snippet": "resource \"aws_iam_role\" \"admin\" {\n assume_role_policy = \"*\"\n}", + "description": "aws_iam_role.admin has a wildcard trust policy.", + "fingerprint": "fp-iam-1", + "hunter_strategy": "iam", + "drop_reason": null +} + +TASK: +1) Read the relevant IaC file(s) and full surrounding context. +2) Design the smallest safe change that remediates the issue. +3) Output concrete IaCDiff entries with original_lines and patched_lines. +4) Assess if the fix is a breaking change. +5) Estimate downtime impact. +6) Check if the fix introduces circular dependencies or invalid reference ordering. +7) Estimate blast radius of the fix across connected modules/resources. +8) Provide alternatives when there are multiple valid remediation strategies. + +REMEDIATION DESIGN REQUIREMENTS: +1) Keep patches minimal and localized to the true root-cause configuration. +2) Preserve existing naming, style, and module interface conventions. +3) Prefer secure defaults when introducing new attributes. +4) Avoid introducing hidden operational coupling unless required for security. + +PROVIDER-SPECIFIC GUIDANCE: +1) Terraform fixes must respect interpolation, variable flow, module outputs, and lifecycle semantics. +2) CloudFormation fixes must preserve intrinsic function correctness, parameter contracts, and stack update behavior. +3) For mixed IaC repos, keep remediation syntax native to each file's framework. +4) If equivalent remediations exist, prefer the one with lowest migration risk and clearest intent. + +DEPENDENCY AND BLAST RADIUS ANALYSIS: +1) Check for circular dependencies created by new references, depends_on entries, or policy attachments. +2) Evaluate whether fixes alter shared modules consumed by multiple environments. +3) Identify resources likely to be replaced versus updated in place. +4) Estimate impact on identity paths, network connectivity, data availability, and deployment pipelines. +5) Reflect major downstream effects in description and downtime_estimate. + +QUALITY RULES: +1) original_lines and patched_lines must be concrete and directly applicable. +2) Keep diffs self-contained and avoid broad refactors unrelated to remediation. +3) If uncertainty exists, provide a conservative safe patch and document trade-off in description. +4) Ensure suggested changes remain consistent with the verified finding context. + +OUTPUT: +Return one JSON object matching RemediationSuggestion. + +MANDATORY FIELDS: +- finding_id +- description +- diffs (each with file_path, original_lines, patched_lines, start_line, end_line) +- breaking_change +- downtime_estimate + +CONSTRAINTS: +- Preserve provider-specific IaC syntax and semantics. +- Avoid broad refactors; keep the patch focused. +- Do not generate changes that mutate unrelated resources. +- Do not output markdown fences or additional prose. diff --git a/go/internal/agents/remediate/testdata/golden/fix_prompt_b.txt b/go/internal/agents/remediate/testdata/golden/fix_prompt_b.txt new file mode 100644 index 0000000..bdef315 --- /dev/null +++ b/go/internal/agents/remediate/testdata/golden/fix_prompt_b.txt @@ -0,0 +1,98 @@ +ROLE: +You are the CloudSecurity remediation generator. + +OBJECTIVE: +Produce a minimal, actionable IaC remediation patch for a verified finding. + +INPUTS: +- Repo path: /fixture/repo +- Title: +- Description: +- Verdict: inconclusive +- Severity: info +- Category: +- Risk score: 0.0 +- SARIF rule: +- IaC location: :0 +- Config snippet: + +- Full finding JSON: +{ + "id": "verified-2", + "title": "", + "verdict": "inconclusive", + "severity": "info", + "category": "", + "resources": [], + "attack_path": null, + "drift": null, + "proof": { + "method": "static_analysis", + "evidence": [], + "scripts_executed": [], + "verification_tier": "static" + }, + "compliance_mappings": [], + "risk_score": 0.0, + "remediation": null, + "sarif_rule_id": "", + "sarif_security_severity": 0.0, + "iac_file": "", + "iac_line": 0, + "config_snippet": "", + "description": "", + "fingerprint": "fp-bare-2", + "hunter_strategy": "", + "drop_reason": null +} + +TASK: +1) Read the relevant IaC file(s) and full surrounding context. +2) Design the smallest safe change that remediates the issue. +3) Output concrete IaCDiff entries with original_lines and patched_lines. +4) Assess if the fix is a breaking change. +5) Estimate downtime impact. +6) Check if the fix introduces circular dependencies or invalid reference ordering. +7) Estimate blast radius of the fix across connected modules/resources. +8) Provide alternatives when there are multiple valid remediation strategies. + +REMEDIATION DESIGN REQUIREMENTS: +1) Keep patches minimal and localized to the true root-cause configuration. +2) Preserve existing naming, style, and module interface conventions. +3) Prefer secure defaults when introducing new attributes. +4) Avoid introducing hidden operational coupling unless required for security. + +PROVIDER-SPECIFIC GUIDANCE: +1) Terraform fixes must respect interpolation, variable flow, module outputs, and lifecycle semantics. +2) CloudFormation fixes must preserve intrinsic function correctness, parameter contracts, and stack update behavior. +3) For mixed IaC repos, keep remediation syntax native to each file's framework. +4) If equivalent remediations exist, prefer the one with lowest migration risk and clearest intent. + +DEPENDENCY AND BLAST RADIUS ANALYSIS: +1) Check for circular dependencies created by new references, depends_on entries, or policy attachments. +2) Evaluate whether fixes alter shared modules consumed by multiple environments. +3) Identify resources likely to be replaced versus updated in place. +4) Estimate impact on identity paths, network connectivity, data availability, and deployment pipelines. +5) Reflect major downstream effects in description and downtime_estimate. + +QUALITY RULES: +1) original_lines and patched_lines must be concrete and directly applicable. +2) Keep diffs self-contained and avoid broad refactors unrelated to remediation. +3) If uncertainty exists, provide a conservative safe patch and document trade-off in description. +4) Ensure suggested changes remain consistent with the verified finding context. + +OUTPUT: +Return one JSON object matching RemediationSuggestion. + +MANDATORY FIELDS: +- finding_id +- description +- diffs (each with file_path, original_lines, patched_lines, start_line, end_line) +- breaking_change +- downtime_estimate + +CONSTRAINTS: +- Preserve provider-specific IaC syntax and semantics. +- Avoid broad refactors; keep the patch focused. +- Do not generate changes that mutate unrelated resources. +- Do not output markdown fences or additional prose. diff --git a/go/internal/agents/remediate/testdata/golden/inputs.json b/go/internal/agents/remediate/testdata/golden/inputs.json new file mode 100644 index 0000000..2ca52b8 --- /dev/null +++ b/go/internal/agents/remediate/testdata/golden/inputs.json @@ -0,0 +1,163 @@ +{ + "verified_full": { + "id": "verified-1", + "title": "Role trusts * & assumes admin", + "verdict": "confirmed", + "severity": "critical", + "category": "overprivilege", + "resources": [ + { + "resource_id": "aws_iam_role.admin", + "resource_type": "aws_iam_role", + "attribute": "assume_role_policy", + "current_value": "{\"Principal\": \"*\"}", + "recommended_value": "scoped principal" + } + ], + "attack_path": { + "id": "path-1", + "title": "Wildcard role -> public bucket", + "description": "An anonymous principal assumes the admin role and reads the data lake.", + "steps": [ + { + "step_number": 1, + "resource_id": "aws_iam_role.admin", + "resource_type": "aws_iam_role", + "action": "sts:AssumeRole as any principal", + "permission_used": "assume_role_policy: *", + "description": "" + }, + { + "step_number": 2, + "resource_id": "aws_s3_bucket.data", + "resource_type": "aws_s3_bucket", + "action": "s3:GetObject", + "permission_used": "role policy allows s3:*", + "description": "Exfiltrate the data lake." + } + ], + "entry_point": "aws_iam_role.admin", + "target": "aws_s3_bucket.data", + "findings_involved": [ + "finding-1", + "finding-2" + ], + "combined_severity": "critical", + "blast_radius": { + "data_stores_reachable": [ + "aws_s3_bucket.data" + ], + "compute_reachable": [], + "estimated_data_volume": "~2 TB", + "services_affected": [ + "s3", + "iam" + ] + } + }, + "drift": { + "resource_id": "aws_s3_bucket.data", + "resource_type": "aws_s3_bucket", + "iac_config": { + "acl": "private", + "versioning": true + }, + "live_config": { + "acl": "public-read", + "versioning": false + }, + "diffs": [ + { + "attribute": "acl", + "iac_value": "private", + "live_value": "public-read", + "security_impact": "bucket became world readable" + }, + { + "attribute": "retention_days", + "iac_value": 30.0, + "live_value": null, + "security_impact": null + }, + { + "attribute": "mfa_delete", + "iac_value": true, + "live_value": false, + "security_impact": null + } + ], + "security_relevant": true, + "significance": "critical" + }, + "proof": { + "method": "static_analysis", + "evidence": [ + "main.tf:12 assume_role_policy allows *" + ], + "scripts_executed": [], + "verification_tier": "static" + }, + "compliance_mappings": [ + "CIS-1.16", + "SOC2-CC6.1" + ], + "risk_score": 9.25, + "remediation": { + "finding_id": "verified-1", + "description": "Scope the trust policy.", + "diffs": [ + { + "file_path": "main.tf", + "original_lines": " assume_role_policy = \"*\"", + "patched_lines": " assume_role_policy = data.aws_iam_policy_document.scoped.json", + "start_line": 12, + "end_line": 12 + } + ], + "breaking_change": false, + "downtime_estimate": "none", + "effort": "trivial", + "alternative_approaches": [ + "Use a permission boundary" + ] + }, + "sarif_rule_id": "cloudsecurity/iam/overprivilege", + "sarif_security_severity": 9.0, + "iac_file": "main.tf", + "iac_line": 12, + "config_snippet": "resource \"aws_iam_role\" \"admin\" {\n assume_role_policy = \"*\"\n}", + "description": "aws_iam_role.admin has a wildcard trust policy.", + "fingerprint": "fp-iam-1", + "hunter_strategy": "iam", + "drop_reason": null + }, + "verified_bare": { + "id": "verified-2", + "title": "", + "verdict": "inconclusive", + "severity": "info", + "category": "", + "resources": [], + "attack_path": null, + "drift": null, + "proof": { + "method": "static_analysis", + "evidence": [], + "scripts_executed": [], + "verification_tier": "static" + }, + "compliance_mappings": [], + "risk_score": 0.0, + "remediation": null, + "sarif_rule_id": "", + "sarif_security_severity": 0.0, + "iac_file": "", + "iac_line": 0, + "config_snippet": "", + "description": "", + "fingerprint": "fp-bare-2", + "hunter_strategy": "", + "drop_reason": null + }, + "repo_path": "/fixture/repo" +} \ No newline at end of file diff --git a/go/internal/agents/util/doc.go b/go/internal/agents/util/doc.go new file mode 100644 index 0000000..bc532ab --- /dev/null +++ b/go/internal/agents/util/doc.go @@ -0,0 +1,35 @@ +// Package util ports the non-harness half of src/cloudsecurity_af/agents/_utils.py. +// +// Python Go +// ------------------------------------------ --------------------------------- +// _utils.extract_harness_result harnessx.Extract (foundation pkg) +// _utils.build_graph_context_for_hunter BuildGraphContextForHunter +// +// extract_harness_result deliberately does NOT live here: the design contract +// maps it onto internal/harnessx, where it is fused with the app.harness call +// as harnessx.RunExtract. What is left in _utils.py is one pure function — +// build_graph_context_for_hunter — which every one of the seven HUNT agents +// calls to turn the RECON phase's graph.json + inventory.json into the three +// text blocks its prompt template interpolates. +// +// # Why this needs a Python-JSON value model +// +// The function renders each node's `config_summary` with an f-string: +// +// node_lines.append(f" Config: {node.get('config_summary')}") +// +// and `config_summary` is a DICT in the file the RECON graph builder writes +// (see internal/agents/recon/graphfast.go), so the rendered text is a CPython +// dict repr whose KEY ORDER is the order the attributes appear in the .tf file. +// Decoding the file into map[string]any would destroy that order and change the +// bytes the LLM sees, so this package carries an order-preserving decoder +// (pyfmt.Load) producing pyfmt.Ordered objects, and renders through +// pyfmt.Str — Python's str(), i.e. exactly what an f-string interpolation does. +// +// # Additions beyond the Python file +// +// ResolvePath (path.go) is NOT a port of anything in _utils.py: it is the +// shared implementation of `str(Path(p).resolve())`, which the seven hunters +// need for their harness Cwd and which app.py / orchestrator.py also use. It +// lives here so there is exactly one copy in the port. +package util diff --git a/go/internal/agents/util/graphcontext.go b/go/internal/agents/util/graphcontext.go new file mode 100644 index 0000000..86e25dc --- /dev/null +++ b/go/internal/agents/util/graphcontext.go @@ -0,0 +1,295 @@ +package util + +import ( + "os" + "sort" + "strconv" + "strings" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" +) + +// BuildGraphContextForHunter ports build_graph_context_for_hunter in +// src/cloudsecurity_af/agents/_utils.py. +// +// It reads the RECON phase's two artifacts — the resource graph and the +// resource inventory, both JSON files on disk — filters them down to one +// hunter's domain, and returns the three text blocks that hunter's prompt +// template interpolates: +// +// nodeLines -> {{RESOURCE_GRAPH_SUMMARY}} +// inventoryStats -> {{INVENTORY_STATS}} +// edgeLines -> {{RELEVANT_EDGES}} +// +// (Python returns them as the tuple (node_lines, inventory_stats, edge_lines), +// i.e. the STATS ARE IN THE MIDDLE while the prompt places them last. The +// hunters destructure it as +// `resource_graph_summary, inventory_stats, relevant_edges = ...`; the Go +// signature keeps the same order so the call sites read the same.) +// +// FILTERING. A node is "relevant" when its resource_type contains any of the +// lowercased domain keywords as a substring; an EMPTY keyword list (after +// dropping falsy entries — which is what the compliance hunter's `[""]` +// reduces to) matches EVERY node. An edge is relevant when either endpoint is +// a relevant node id. The non-relevant endpoints of relevant edges become the +// "1-hop neighbors" block. +// +// FAILURE IS SILENT. Python wraps each json.load in a bare `except Exception` +// and falls back to an empty document, then re-checks isinstance(dict). A +// missing, unreadable, malformed or non-object file therefore yields the +// "none matched this hunter domain" / "no edges matched this hunter domain" +// context rather than an error — which is why this function has no error +// return, and why the hunters keep running when RECON produced nothing. +// +// Python parity — LOWERCASING. Python's str.lower() applies full Unicode case +// mapping; Go's strings.ToLower is a per-rune simple mapping. The keyword +// tables are ASCII literals and Terraform resource types are ASCII, so the two +// agree on every input this port sees. +// +// Python parity — TYPE ERRORS. Python raises on a few shapes a hand-written +// graph file could hold (a non-string resource_type, a non-sized value under +// "nodes", an unhashable resource_id). Go renders them with str()/repr() and +// keeps going instead of failing the reasoner; each divergence is commented at +// its site in pyvalue.go. No file this port writes can reach them. +// +// DIVERGENCE — NEIGHBOR ORDER. See pyfmt.KeySet: Python iterates a +// `set` (hash order, randomized per process); Go emits first-encounter order. +func BuildGraphContextForHunter(graphPath, inventoryPath string, domainKeywords []string) (nodeLines, inventoryStats, edgeLines string) { + graphData := loadObject(graphPath, defaultGraph()) + inventoryData := loadObject(inventoryPath, defaultInventory()) + + // lowered_keywords = [k.lower() for k in domain_keywords if k] + var loweredKeywords []string + for _, keyword := range domainKeywords { + if keyword == "" { // Python: `if keyword` — a falsy entry is dropped + continue + } + loweredKeywords = append(loweredKeywords, strings.ToLower(keyword)) + } + + // def _matches(node_type): ... + matches := func(nodeType any) bool { + if len(loweredKeywords) == 0 { + // Python parity: this returns BEFORE node_type.lower(), which is + // why the compliance hunter's `[""]` never hits the string path. + return true + } + lowered := strings.ToLower(pyfmt.Str(nodeType)) + for _, keyword := range loweredKeywords { + if strings.Contains(lowered, keyword) { + return true + } + } + return false + } + + rawNodes, _ := pyList(dictGetDefault(graphData, "nodes", []any{})) + + // all_nodes_by_id[node.get("resource_id", "")] = node — note the "" default. + allNodesByID := map[pyKey]pyfmt.Ordered{} + for _, n := range rawNodes { + if node, ok := pyDict(n); ok { + allNodesByID[keyOf(dictGetDefault(node, "resource_id", ""))] = node + } + } + + // relevant_nodes / relevant_node_ids — note the MISSING default here, so a + // node with no resource_id contributes Python None to the id set. + var relevantNodes []pyfmt.Ordered + relevantNodeIDs := newKeySet() + for _, n := range rawNodes { + node, ok := pyDict(n) + if !ok || !matches(dictGetDefault(node, "resource_type", "")) { + continue + } + relevantNodes = append(relevantNodes, node) + relevantNodeIDs.Add(dictGet(node, "resource_id")) + } + + rawEdges, _ := pyList(dictGetDefault(graphData, "edges", []any{})) + + var relevantEdges []pyfmt.Ordered + for _, e := range rawEdges { + edge, ok := pyDict(e) + if !ok { + continue + } + if relevantNodeIDs.Has(dictGet(edge, "source")) || relevantNodeIDs.Has(dictGet(edge, "target")) { + relevantEdges = append(relevantEdges, edge) + } + } + + // neighbor_ids — note the "" default, which differs from the None default + // used to build relevant_node_ids above. A malformed edge with no "source" + // is therefore tested for membership as "" here and as None there. + neighborIDs := newKeySet() + for _, edge := range relevantEdges { + source := dictGetDefault(edge, "source", "") + target := dictGetDefault(edge, "target", "") + if !relevantNodeIDs.Has(source) { + neighborIDs.Add(source) + } + if !relevantNodeIDs.Has(target) { + neighborIDs.Add(target) + } + } + + var neighborNodes []pyfmt.Ordered + for _, id := range neighborIDs.Keys() { + if node, ok := allNodesByID[id]; ok { + neighborNodes = append(neighborNodes, node) + } + } + + // ---- node lines -------------------------------------------------------- + lines := []string{"RELEVANT RESOURCES:"} + if len(relevantNodes) == 0 { + lines = append(lines, " - none matched this hunter domain") + } + for _, node := range relevantNodes { + lines = append(lines, nodeHeadline(node), nodeConfigLine(node)) + } + if len(neighborNodes) > 0 { + // Python parity: the "\n" is INSIDE the appended element, so the + // "\n".join below turns it into a blank separator line. + lines = append(lines, "\nCONNECTED RESOURCES (1-hop neighbors):") + for _, node := range neighborNodes { + lines = append(lines, nodeHeadline(node), nodeConfigLine(node)) + } + } + + // ---- edge lines -------------------------------------------------------- + edges := []string{"RELEVANT RELATIONSHIPS:"} + if len(relevantEdges) == 0 { + edges = append(edges, " - no edges matched this hunter domain") + } + for _, edge := range relevantEdges { + edges = append(edges, " - "+pyfmt.Str(dictGet(edge, "source"))+ + " --["+pyfmt.Str(dictGetDefault(edge, "type", "references"))+"]--> "+ + pyfmt.Str(dictGet(edge, "target"))) + if description := dictGet(edge, "description"); pyTruthy(description) { + edges = append(edges, " "+pyfmt.Str(description)) + } + } + + // ---- inventory stats --------------------------------------------------- + rawResources, _ := pyList(dictGetDefault(inventoryData, "resources", []any{})) + providers := providerList(rawResources) + providersText := "none" + if len(providers) > 0 { + providersText = strings.Join(providers, ", ") + } + stats := strings.Join([]string{ + "Total resources: " + strconv.Itoa(pyLen(dictGetDefault(inventoryData, "resources", []any{}))), + "Providers: " + providersText, + "Modules: " + strconv.Itoa(pyLen(dictGetDefault(inventoryData, "modules", []any{}))), + "Variables: " + strconv.Itoa(pyLen(dictGetDefault(inventoryData, "variables", []any{}))), + "Outputs: " + strconv.Itoa(pyLen(dictGetDefault(inventoryData, "outputs", []any{}))), + // Python parity: the graph counts come from the RAW documents, not + // from the isinstance-filtered raw_nodes/raw_edges, so a graph whose + // "nodes" is an object counts its KEYS here while contributing no + // nodes above. + "Graph nodes: " + strconv.Itoa(pyLen(dictGetDefault(graphData, "nodes", []any{}))), + "Graph edges: " + strconv.Itoa(pyLen(dictGetDefault(graphData, "edges", []any{}))), + "Filtered nodes: " + strconv.Itoa(len(relevantNodes)), + "Filtered edges: " + strconv.Itoa(len(relevantEdges)), + }, "\n") + + return strings.Join(lines, "\n"), stats, strings.Join(edges, "\n") +} + +// nodeHeadline renders +// f" - {node.get('resource_id')} ({node.get('resource_type')}) @ {node.get('file_path')}". +// The three lookups have NO default, so an absent key prints as "None". +func nodeHeadline(node pyfmt.Ordered) string { + return " - " + pyfmt.Str(dictGet(node, "resource_id")) + + " (" + pyfmt.Str(dictGet(node, "resource_type")) + ")" + + " @ " + pyfmt.Str(dictGet(node, "file_path")) +} + +// nodeConfigLine renders f" Config: {node.get('config_summary')}". +// +// config_summary is a DICT in every graph.json the RECON graph builder writes, +// so this is a CPython dict repr — `{'associate_public_ip_address': True}` — +// with the key order the .tf file had. pyfmt.Str is Python's str(), which for +// a container is repr(); pyfmt.Load kept the order in a pyfmt.Ordered. +func nodeConfigLine(node pyfmt.Ordered) string { + return " Config: " + pyfmt.Str(dictGet(node, "config_summary")) +} + +// providerList ports +// +// sorted({r.get("provider") for r in raw_resources if isinstance(r, dict) and r.get("provider")}) +// +// i.e. the distinct TRUTHY provider values, sorted. Python's sorted() compares +// the values themselves and raises TypeError on a mixed-type set; Go dedupes on +// the Python-key identity and sorts the str() renderings, which is identical +// for the all-strings case every real inventory has. +func providerList(rawResources []any) []string { + seen := newKeySet() + var out []string + for _, r := range rawResources { + resource, ok := pyDict(r) + if !ok { + continue + } + provider := dictGet(resource, "provider") + if !pyTruthy(provider) { + continue + } + if seen.Has(provider) { + continue + } + seen.Add(provider) + out = append(out, pyfmt.Str(provider)) + } + // sort.Strings compares bytes, which for UTF-8 is code-point order — the + // same order Python's sorted() gives a set of str. + sort.Strings(out) + return out +} + +// loadObject is the `try: json.load(open(path)) except Exception: default` +// plus the `if not isinstance(data, dict): data = default` re-check, in one +// step. Anything that is not a JSON object — including a valid JSON array, +// number or string — becomes the default. +func loadObject(path string, fallback pyfmt.Ordered) pyfmt.Ordered { + data, err := os.ReadFile(path) + if err != nil { + return fallback + } + value, err := pyfmt.Load(data) + if err != nil { + return fallback + } + obj, ok := pyDict(value) + if !ok { + return fallback + } + return obj +} + +// defaultGraph is _default_graph. +func defaultGraph() pyfmt.Ordered { + return pyfmt.Ordered{ + {K: "nodes", V: []any{}}, + {K: "edges", V: []any{}}, + {K: "clusters", V: []any{}}, + } +} + +// defaultInventory is _default_inventory. +// +// Python parity: the last key is "provider_configs", which is NOT the key the +// real inventory writer emits ("providers"). It is dead either way — the stats +// block never reads it — but it is reproduced so the default document is the +// same object. +func defaultInventory() pyfmt.Ordered { + return pyfmt.Ordered{ + {K: "resources", V: []any{}}, + {K: "modules", V: []any{}}, + {K: "variables", V: []any{}}, + {K: "outputs", V: []any{}}, + {K: "provider_configs", V: []any{}}, + } +} diff --git a/go/internal/agents/util/graphcontext_test.go b/go/internal/agents/util/graphcontext_test.go new file mode 100644 index 0000000..b2ec60b --- /dev/null +++ b/go/internal/agents/util/graphcontext_test.go @@ -0,0 +1,376 @@ +package util + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// The committed fixture pair, mirrored into internal/agents/hunt/testdata by +// go/scripts/gen_golden.py. See that script's GRAPH_CONTEXT_CASES for the +// Python side of every golden asserted here. +const ( + fixtureGraph = "testdata/fixture/graph.json" + fixtureInventory = "testdata/fixture/inventory.json" + fixtureNotAnObject = "testdata/fixture/not_an_object.json" + fixtureAbsent = "testdata/fixture/absent.json" // deliberately does not exist +) + +func readGolden(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "golden", name)) + if err != nil { + t.Fatalf("reading golden %s: %v (regenerate with go/scripts/gen_golden.py)", name, err) + } + return string(b) +} + +// TestBuildGraphContextForHunter_MatchesThePythonGoldens is the byte-for-byte +// parity gate: every case here is generated by driving the REAL Python +// build_graph_context_for_hunter over the same committed fixture (see +// GRAPH_CONTEXT_CASES in go/scripts/gen_golden.py), so a golden can only move +// when the Python function moves. +func TestBuildGraphContextForHunter_MatchesThePythonGoldens(t *testing.T) { + cases := []struct { + name string + graph string + inventory string + keywords []string + }{ + {"iam", fixtureGraph, fixtureInventory, []string{"iam", "role", "policy"}}, + {"data", fixtureGraph, fixtureInventory, []string{"s3", "bucket", "kms"}}, + {"all", fixtureGraph, fixtureInventory, []string{""}}, + {"nomatch", fixtureGraph, fixtureInventory, []string{"nonexistent_type_xyz"}}, + {"missing_files", fixtureAbsent, fixtureAbsent, []string{"iam"}}, + {"not_an_object", fixtureNotAnObject, fixtureNotAnObject, []string{"iam"}}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + nodes, stats, edges := BuildGraphContextForHunter(tc.graph, tc.inventory, tc.keywords) + for _, part := range []struct { + label, got, golden string + }{ + {"nodes", nodes, tc.name + "_nodes.txt"}, + {"stats", stats, tc.name + "_stats.txt"}, + {"edges", edges, tc.name + "_edges.txt"}, + } { + if want := readGolden(t, part.golden); part.got != want { + t.Errorf("%s block differs from Python\n--- got ---\n%s\n--- want ---\n%s", + part.label, part.got, want) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// tests/test_graph_context.py, ported. +// +// THE PYTHON FILE IS STALE: it calls +// build_graph_context_for_hunter(graph, inventory, keywords) with pydantic +// MODELS, and imports ResourceNode/ResourceEdge/ResourceCluster, all of which +// were removed. The function has taken (graph_path, inventory_path, keywords) +// and read JSON files for some time, so the module does not import. Each test +// below therefore keeps the Python ASSERTION and re-expresses the SETUP against +// the current signature, using the committed fixture in place of the models the +// Python test built. +// --------------------------------------------------------------------------- + +func TestBuildGraphContext_IAMKeywordsFilter(t *testing.T) { + summary, _, edges := BuildGraphContextForHunter(fixtureGraph, fixtureInventory, []string{"iam", "role"}) + mustContain(t, "summary", summary, "aws_iam_role.admin") + mustNotContain(t, "summary", summary, "aws_s3_bucket.data") + mustContain(t, "edges", edges, "aws_iam_role.admin") +} + +func TestBuildGraphContext_NetworkKeywordsFilter(t *testing.T) { + summary, _, _ := BuildGraphContextForHunter(fixtureGraph, fixtureInventory, []string{"vpc", "subnet"}) + mustContain(t, "summary", summary, "aws_vpc.main") + mustNotContain(t, "summary", summary, "aws_iam_role.admin") +} + +func TestBuildGraphContext_S3KeywordsReturnBucket(t *testing.T) { + summary, _, _ := BuildGraphContextForHunter(fixtureGraph, fixtureInventory, []string{"s3", "bucket"}) + mustContain(t, "summary", summary, "aws_s3_bucket.data") +} + +func TestBuildGraphContext_EmptyKeywordMatchesAll(t *testing.T) { + summary, _, _ := BuildGraphContextForHunter(fixtureGraph, fixtureInventory, []string{""}) + for _, id := range []string{ + "aws_iam_role.admin", "aws_iam_policy.broad", "aws_s3_bucket.data", + "aws_vpc.main", "aws_subnet.private", "aws_instance.web", + "aws_kms_key.master", "aws_cloudtrail.audit", "aws_secretsmanager_secret.db", + } { + mustContain(t, "summary", summary, id) + } +} + +func TestBuildGraphContext_NoMatchReturnsNoneMessage(t *testing.T) { + summary, _, _ := BuildGraphContextForHunter(fixtureGraph, fixtureInventory, []string{"nonexistent_type_xyz"}) + mustContain(t, "summary", summary, "none matched") +} + +func TestBuildGraphContext_InventoryStatsFormat(t *testing.T) { + _, stats, _ := BuildGraphContextForHunter(fixtureGraph, fixtureInventory, []string{"iam"}) + // The fixture inventory lists 12 resources while the graph holds 9 nodes: + // the two counts come from DIFFERENT documents and must not be conflated. + for _, want := range []string{ + "Total resources: 12", "Graph nodes: 9", "Graph edges: 4", + "Modules: 2", "Variables: 2", "Outputs: 1", + "Filtered nodes: 2", "Filtered edges: 1", + } { + mustContain(t, "stats", stats, want) + } +} + +func TestBuildGraphContext_EdgeIncludesConnectedEdges(t *testing.T) { + // An edge counts as relevant when EITHER endpoint is in the domain, so the + // data domain pulls in the instance->key edge whose source is outside it, + // and the instance then shows up as a 1-hop neighbor. + summary, _, edges := BuildGraphContextForHunter(fixtureGraph, fixtureInventory, []string{"s3", "bucket", "kms"}) + mustContain(t, "edges", edges, "encrypted_by") + mustContain(t, "edges", edges, "aws_instance.web") + mustContain(t, "summary", summary, "CONNECTED RESOURCES (1-hop neighbors):") + mustContain(t, "summary", summary, "aws_instance.web") +} + +func TestBuildGraphContext_ConfigSummaryInOutput(t *testing.T) { + summary, _, _ := BuildGraphContextForHunter(fixtureGraph, fixtureInventory, []string{"iam"}) + mustContain(t, "summary", summary, "AdministratorAccess") +} + +func TestBuildGraphContext_EmptyGraph(t *testing.T) { + // Python built empty models; the current signature reaches the same state + // through the `except Exception` fallback on an unreadable path. + summary, stats, edges := BuildGraphContextForHunter(fixtureAbsent, fixtureAbsent, []string{"iam"}) + mustContain(t, "summary", summary, "none matched") + mustContain(t, "stats", stats, "Total resources: 0") + mustContain(t, "edges", edges, "no edges matched") +} + +// --------------------------------------------------------------------------- +// Behaviors the (stale) Python tests never covered but the prompt text depends +// on. Each one is pinned by the Python-generated goldens above as well; these +// name the rule so a failure says WHAT broke. +// --------------------------------------------------------------------------- + +// The graph builder writes config_summary as a dict, so the f-string renders a +// CPython dict repr — single quotes, None/True/False, and the .tf file's key +// order, NOT alphabetical order. +func TestBuildGraphContext_ConfigSummaryIsAPythonDictRepr(t *testing.T) { + summary, _, _ := BuildGraphContextForHunter(fixtureGraph, fixtureInventory, []string{"aws_iam_policy"}) + const want = " Config: {'policy': '{\"Statement\":[{\"Action\":\"*\",\"Resource\":\"*\"}]}', 'description': None}" + mustContain(t, "summary", summary, want) +} + +func TestBuildGraphContext_ConfigSummaryKeyOrderIsNotSorted(t *testing.T) { + summary, _, _ := BuildGraphContextForHunter(fixtureGraph, fixtureInventory, []string{"aws_iam_role"}) + // Sorted order would be assume_role_policy, managed_policy_arns, name. + const want = " Config: {'name': 'admin', 'assume_role_policy': " + mustContain(t, "summary", summary, want) +} + +func TestBuildGraphContext_NestedValuesUsePythonRepr(t *testing.T) { + summary, _, _ := BuildGraphContextForHunter(fixtureGraph, fixtureInventory, []string{"aws_kms_key"}) + // bool -> False, int -> 7, float -> 90.0 (not 90), non-ASCII kept verbatim. + const want = " Config: {'enable_key_rotation': False, 'deletion_window_in_days': 7, " + + "'rotation_period_days': 90.0, 'description': 'clé principale'}" + mustContain(t, "summary", summary, want) +} + +// `edge.get("type", "references")` — the only .get in the edge block with a +// default, and the fixture's last edge has no "type". +func TestBuildGraphContext_EdgeTypeDefaultsToReferences(t *testing.T) { + _, _, edges := BuildGraphContextForHunter(fixtureGraph, fixtureInventory, []string{"aws_instance"}) + mustContain(t, "edges", edges, " - aws_instance.web --[references]--> aws_kms_key.master") +} + +// `if edge.get("description")` — an ABSENT or EMPTY description emits no line. +func TestBuildGraphContext_FalsyEdgeDescriptionEmitsNoLine(t *testing.T) { + _, _, edges := BuildGraphContextForHunter(fixtureGraph, fixtureInventory, []string{"s3", "bucket", "kms"}) + lines := strings.Split(edges, "\n") + for _, line := range lines { + if strings.HasPrefix(line, " ") { + t.Errorf("no description line expected, got %q\nfull block:\n%s", line, edges) + } + } + // …and a truthy one does emit. + _, _, iamEdges := BuildGraphContextForHunter(fixtureGraph, fixtureInventory, []string{"iam"}) + mustContain(t, "edges", iamEdges, "\n Admin role has the broad policy attached") +} + +// The "\n" that Python puts INSIDE the CONNECTED RESOURCES header becomes a +// blank separator line after "\n".join. +func TestBuildGraphContext_BlankLineBeforeTheNeighborBlock(t *testing.T) { + summary, _, _ := BuildGraphContextForHunter(fixtureGraph, fixtureInventory, []string{"s3", "bucket", "kms"}) + mustContain(t, "summary", summary, "\n\nCONNECTED RESOURCES (1-hop neighbors):\n") +} + +// A readable file that is not a JSON OBJECT takes the second fallback (the +// isinstance re-check), which is a different branch from the `except`. +func TestBuildGraphContext_NonObjectDocumentFallsBackToTheDefault(t *testing.T) { + summary, stats, edges := BuildGraphContextForHunter(fixtureNotAnObject, fixtureNotAnObject, []string{"iam"}) + mustContain(t, "summary", summary, "none matched") + mustContain(t, "edges", edges, "no edges matched") + mustContain(t, "stats", stats, "Providers: none") + mustContain(t, "stats", stats, "Graph nodes: 0") +} + +// sorted({r["provider"] for r in resources if r["provider"]}) — dedup, sort, +// and drop the falsy ones ("" and null in the fixture). +func TestBuildGraphContext_ProvidersAreDedupedSortedAndFiltered(t *testing.T) { + _, stats, _ := BuildGraphContextForHunter(fixtureGraph, fixtureInventory, []string{"iam"}) + mustContain(t, "stats", stats, "\nProviders: aws, google\n") +} + +// Every .get in the node block is defaultless, so absent keys print as Python's +// None. +func TestBuildGraphContext_AbsentNodeFieldsRenderAsNone(t *testing.T) { + dir := t.TempDir() + graph := filepath.Join(dir, "graph.json") + writeFile(t, graph, `{"nodes": [{"resource_type": "aws_iam_role"}], "edges": []}`) + + summary, stats, _ := BuildGraphContextForHunter(graph, fixtureAbsent, []string{"iam"}) + mustContain(t, "summary", summary, " - None (aws_iam_role) @ None") + mustContain(t, "summary", summary, " Config: None") + mustContain(t, "stats", stats, "Filtered nodes: 1") +} + +// Non-dict entries inside "nodes"/"edges" are skipped by the isinstance guards, +// but the COUNTS in the stats block come from the raw lists, so the two +// disagree on purpose. +func TestBuildGraphContext_NonDictEntriesAreSkippedButStillCounted(t *testing.T) { + dir := t.TempDir() + graph := filepath.Join(dir, "graph.json") + writeFile(t, graph, `{"nodes": ["oops", {"resource_id": "a", "resource_type": "aws_iam_role"}], + "edges": [42, {"source": "a", "target": "b"}]}`) + + summary, stats, edges := BuildGraphContextForHunter(graph, fixtureAbsent, []string{"iam"}) + mustContain(t, "summary", summary, " - a (aws_iam_role) @ None") + mustNotContain(t, "summary", summary, "oops") + mustContain(t, "edges", edges, " - a --[references]--> b") + mustContain(t, "stats", stats, "Graph nodes: 2") + mustContain(t, "stats", stats, "Graph edges: 2") + mustContain(t, "stats", stats, "Filtered nodes: 1") + mustContain(t, "stats", stats, "Filtered edges: 1") +} + +// A "nodes" key that is not a list is ignored for filtering but still measured +// by len() for the stats line — Python reads graph_data["nodes"] twice, once +// guarded and once not. +func TestBuildGraphContext_NonListNodesAreIgnoredButLenIsStillTaken(t *testing.T) { + dir := t.TempDir() + graph := filepath.Join(dir, "graph.json") + writeFile(t, graph, `{"nodes": {"a": 1, "b": 2}, "edges": []}`) + + summary, stats, _ := BuildGraphContextForHunter(graph, fixtureAbsent, []string{"iam"}) + mustContain(t, "summary", summary, "none matched") + mustContain(t, "stats", stats, "Graph nodes: 2") // len({"a":1,"b":2}) == 2 + mustContain(t, "stats", stats, "Filtered nodes: 0") +} + +// The neighbor block is the one place Python iterates a set. Go emits +// first-encounter order; assert it is STABLE and follows the edge scan +// (source before target, edges in document order). +func TestBuildGraphContext_NeighborOrderIsDeterministic(t *testing.T) { + dir := t.TempDir() + graph := filepath.Join(dir, "graph.json") + writeFile(t, graph, `{"nodes": [ + {"resource_id": "hub", "resource_type": "aws_iam_role"}, + {"resource_id": "zeta", "resource_type": "aws_s3_bucket"}, + {"resource_id": "alpha", "resource_type": "aws_s3_bucket"}, + {"resource_id": "mid", "resource_type": "aws_s3_bucket"} + ], "edges": [ + {"source": "zeta", "target": "hub"}, + {"source": "hub", "target": "alpha"}, + {"source": "mid", "target": "hub"} + ]}`) + + first, _, _ := BuildGraphContextForHunter(graph, fixtureAbsent, []string{"iam"}) + want := strings.Join([]string{ + "RELEVANT RESOURCES:", + " - hub (aws_iam_role) @ None", + " Config: None", + "", + "CONNECTED RESOURCES (1-hop neighbors):", + " - zeta (aws_s3_bucket) @ None", + " Config: None", + " - alpha (aws_s3_bucket) @ None", + " Config: None", + " - mid (aws_s3_bucket) @ None", + " Config: None", + }, "\n") + if first != want { + t.Fatalf("neighbor block\n--- got ---\n%s\n--- want ---\n%s", first, want) + } + for i := 0; i < 50; i++ { + again, _, _ := BuildGraphContextForHunter(graph, fixtureAbsent, []string{"iam"}) + if again != first { + t.Fatalf("neighbor order is not stable across runs:\n%s\nvs\n%s", first, again) + } + } +} + +// A neighbor id with no node of its own is dropped by +// `if nid in all_nodes_by_id`. +func TestBuildGraphContext_UnknownNeighborIDsAreDropped(t *testing.T) { + dir := t.TempDir() + graph := filepath.Join(dir, "graph.json") + writeFile(t, graph, `{"nodes": [{"resource_id": "hub", "resource_type": "aws_iam_role"}], + "edges": [{"source": "hub", "target": "ghost"}]}`) + + summary, _, edges := BuildGraphContextForHunter(graph, fixtureAbsent, []string{"iam"}) + mustNotContain(t, "summary", summary, "CONNECTED RESOURCES") + mustContain(t, "edges", edges, " - hub --[references]--> ghost") +} + +// The two id lookups use DIFFERENT defaults: all_nodes_by_id keys on +// node.get("resource_id", "") while relevant_node_ids collects +// node.get("resource_id") — Python None. An id-less node therefore lands under +// "" in the map and under None in the set, and an edge with no "source" reads +// as None in the relevance filter but as "" in the neighbor loop. The observable +// consequence: the id-less node is pulled in as its own 1-hop neighbor. +func TestBuildGraphContext_MissingIDDefaultsDifferInTheMapAndTheSet(t *testing.T) { + dir := t.TempDir() + graph := filepath.Join(dir, "graph.json") + writeFile(t, graph, `{"nodes": [{"resource_type": "aws_iam_role", "file_path": "iam.tf"}], + "edges": [{"target": "unrelated"}]}`) + + summary, stats, edges := BuildGraphContextForHunter(graph, fixtureAbsent, []string{"iam"}) + // The edge has no "source": edge.get("source") is None, which IS in + // relevant_node_ids, so the edge is relevant. + mustContain(t, "stats", stats, "Filtered edges: 1") + mustContain(t, "edges", edges, " - None --[references]--> unrelated") + // In the neighbor loop the same endpoint reads as "" (not in the set), and + // "" IS a key of all_nodes_by_id — so the node reappears as a neighbor. + mustContain(t, "summary", summary, "CONNECTED RESOURCES (1-hop neighbors):") + if got := strings.Count(summary, " - None (aws_iam_role) @ iam.tf"); got != 2 { + t.Errorf("expected the id-less node listed twice (relevant + neighbor), got %d\n%s", got, summary) + } +} + +// --------------------------------------------------------------------------- + +func mustContain(t *testing.T, label, haystack, needle string) { + t.Helper() + if !strings.Contains(haystack, needle) { + t.Errorf("%s block does not contain %q\n--- block ---\n%s", label, needle, haystack) + } +} + +func mustNotContain(t *testing.T, label, haystack, needle string) { + t.Helper() + if strings.Contains(haystack, needle) { + t.Errorf("%s block unexpectedly contains %q\n--- block ---\n%s", label, needle, haystack) + } +} + +func writeFile(t *testing.T, path, body string) { + t.Helper() + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("writing %s: %v", path, err) + } +} diff --git a/go/internal/agents/util/path.go b/go/internal/agents/util/path.go new file mode 100644 index 0000000..d1a33fc --- /dev/null +++ b/go/internal/agents/util/path.go @@ -0,0 +1,69 @@ +package util + +import ( + "os" + "path/filepath" +) + +// ResolvePath ports Python's `str(Path(p).resolve())` (pathlib, non-strict — +// the 3.6+ default). +// +// This is an ADDITION, not a port of anything in _utils.py: the seven HUNT +// agents all compute their harness working directory with +// +// harness_cwd = str(Path(repo_path).resolve()) +// +// and app.py / orchestrator.py resolve the repo path the same way. One +// implementation lives here so the port cannot drift between them. +// +// Semantics, verified against ~/.agentfield/packages/cloudsecurity-af/venv +// (CPython 3.11.12): +// +// Path("rp/real").resolve() -> /rp/real relative -> absolute +// Path("rp/link").resolve() -> /rp/real symlinks are followed +// Path("rp/link/").resolve() -> /rp/real trailing slash dropped +// Path("rp/nope/deeper").resolve() -> /rp/nope/deeper NON-STRICT: a path +// that does not exist +// still resolves +// Path("").resolve() -> "" is "." +// Path("/abs/nonexistent/x") -> /abs/nonexistent/x unchanged +// +// Implementation: hand the un-Cleaned absolute path to filepath.EvalSymlinks, +// whose walk resolves each component and pops on ".." exactly as realpath(3) +// does. When the path does not exist EvalSymlinks fails, so we resolve the +// longest existing ANCESTOR and re-attach the missing tail — Python's +// non-strict behavior. +// +// DIVERGENCE: in the non-existent-path fallback the tail is joined lexically, +// so a ".." that would have crossed a symlink inside the MISSING part of the +// path is collapsed lexically rather than by walking. No call site constructs +// such a path (they are all repo roots and temp dirs). +func ResolvePath(p string) string { + abs := p + if !filepath.IsAbs(abs) { + if cwd, err := os.Getwd(); err == nil { + // Deliberately NOT filepath.Join/Abs: those Clean the result, + // which would collapse ".." lexically BEFORE symlinks are + // followed. EvalSymlinks does its own Clean at the end. + abs = cwd + string(filepath.Separator) + abs + } + } + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + return resolved + } + + cleaned := filepath.Clean(abs) + var tail []string + current := cleaned + for { + parent := filepath.Dir(current) + if parent == current { // reached the root + return cleaned + } + tail = append([]string{filepath.Base(current)}, tail...) + current = parent + if resolved, err := filepath.EvalSymlinks(current); err == nil { + return filepath.Join(append([]string{resolved}, tail...)...) + } + } +} diff --git a/go/internal/agents/util/path_test.go b/go/internal/agents/util/path_test.go new file mode 100644 index 0000000..8cd071f --- /dev/null +++ b/go/internal/agents/util/path_test.go @@ -0,0 +1,84 @@ +package util + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// The expected values were captured from the repo's own interpreter: +// +// ~/.agentfield/packages/cloudsecurity-af/venv/bin/python +// >>> from pathlib import Path; str(Path(p).resolve()) +// +// with rp/real a directory and rp/link a symlink to it. +func TestResolvePath_MatchesPathlibResolve(t *testing.T) { + dir := t.TempDir() + // t.TempDir() can itself sit under a symlink (/tmp -> /private/tmp on + // macOS, and WSL mount points), so the expectations are anchored on the + // resolved root rather than on dir itself. + root, err := filepath.EvalSymlinks(dir) + if err != nil { + t.Fatalf("resolving the temp dir: %v", err) + } + if err := os.MkdirAll(filepath.Join(root, "rp", "real"), 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.Symlink("real", filepath.Join(root, "rp", "link")); err != nil { + t.Fatalf("symlink: %v", err) + } + + cases := []struct { + name string + in string + want string + }{ + {"existing directory", filepath.Join(root, "rp", "real"), filepath.Join(root, "rp", "real")}, + {"symlink is followed", filepath.Join(root, "rp", "link"), filepath.Join(root, "rp", "real")}, + {"trailing separator", filepath.Join(root, "rp", "link") + "/", filepath.Join(root, "rp", "real")}, + {"dot segments", filepath.Join(root, "rp", "real", "..", "real"), filepath.Join(root, "rp", "real")}, + // NON-STRICT: pathlib resolves a path that does not exist. + {"missing tail", filepath.Join(root, "rp", "nope", "deeper"), filepath.Join(root, "rp", "nope", "deeper")}, + {"missing tail under a symlink", filepath.Join(root, "rp", "link", "nope"), filepath.Join(root, "rp", "real", "nope")}, + {"fully missing absolute path", "/abs/nonexistent/x", "/abs/nonexistent/x"}, + } + for _, tc := range cases { + if got := ResolvePath(tc.in); got != tc.want { + t.Errorf("%s: ResolvePath(%q) = %q, want %q", tc.name, tc.in, got, tc.want) + } + } +} + +func TestResolvePath_RelativePathsResolveAgainstTheWorkingDirectory(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + resolvedCwd, err := filepath.EvalSymlinks(cwd) + if err != nil { + t.Fatalf("resolving cwd: %v", err) + } + + // Python: Path("") is Path("."), so both resolve to the working directory. + for _, in := range []string{"", "."} { + if got := ResolvePath(in); got != resolvedCwd { + t.Errorf("ResolvePath(%q) = %q, want %q", in, got, resolvedCwd) + } + } + if got, want := ResolvePath("testdata"), filepath.Join(resolvedCwd, "testdata"); got != want { + t.Errorf("ResolvePath(%q) = %q, want %q", "testdata", got, want) + } + if got := ResolvePath("no/such/dir"); !strings.HasPrefix(got, resolvedCwd) { + t.Errorf("ResolvePath(%q) = %q, want it under %q", "no/such/dir", got, resolvedCwd) + } +} + +// The result is always absolute, which is what makes it safe as a harness Cwd. +func TestResolvePath_IsAlwaysAbsolute(t *testing.T) { + for _, in := range []string{"", ".", "..", "relative/path", "/already/absolute"} { + if got := ResolvePath(in); !filepath.IsAbs(got) { + t.Errorf("ResolvePath(%q) = %q, which is not absolute", in, got) + } + } +} diff --git a/go/internal/agents/util/pyvalue.go b/go/internal/agents/util/pyvalue.go new file mode 100644 index 0000000..a30a06f --- /dev/null +++ b/go/internal/agents/util/pyvalue.go @@ -0,0 +1,125 @@ +package util + +import ( + "encoding/json" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" +) + +// This file is the minimal Python-value layer build_graph_context_for_hunter +// needs in order to behave like `json.load(f)` followed by dict/list/f-string +// operations on whatever came back. +// +// The value model is exactly the one json.load produces: +// +// nil | bool | string | int | float64 | []any | pyfmt.Ordered +// +// pyfmt.Ordered (a []pyfmt.KV) stands in for a Python dict and preserves +// INSERTION ORDER, which is observable in the prompt text (see doc.go). +// +// CONSOLIDATION (integration): this file used to carry its own unexported, +// order-preserving json.load copy, as did internal/agents/recon and +// internal/agents/chain. All three were byte-identical apart from their doc +// comments and were folded into pyfmt.Load, whose tests are the merged union of +// the three local suites. The helpers below — the dict/list/truthiness/set +// layer — are what actually belongs to this package. + +// --------------------------------------------------------------------------- +// dict / list / truthiness helpers +// --------------------------------------------------------------------------- + +// pyDict is the `isinstance(x, dict)` test plus the unwrap, in one step. +func pyDict(v any) (pyfmt.Ordered, bool) { + o, ok := v.(pyfmt.Ordered) + return o, ok +} + +// pyList is the `isinstance(x, list)` test plus the unwrap. +func pyList(v any) ([]any, bool) { + l, ok := v.([]any) + return l, ok +} + +// dictGet ports `d.get(key)` — the value, or nil when absent (Python None). +func dictGet(d pyfmt.Ordered, key string) any { + v, _ := d.Get(key) + return v +} + +// dictGetDefault ports `d.get(key, fallback)`. +// +// The two forms are NOT interchangeable in the ported function and the +// difference is observable: build_graph_context_for_hunter reads a node's id +// as `node.get("resource_id", "")` when keying all_nodes_by_id but as +// `node.get("resource_id")` when building relevant_node_ids, so a node with no +// id contributes "" to one and None to the other. +func dictGetDefault(d pyfmt.Ordered, key string, fallback any) any { + if v, ok := d.Get(key); ok { + return v + } + return fallback +} + +// pyLen ports len(x) for the kinds json.load can produce. +// +// Python parity: len() of a non-sized object (None, a number, a bool) raises +// TypeError. There is no error channel on build_graph_context_for_hunter, and +// every call site here is a `.get(key, [])` whose default is a list, so the +// only way to reach a non-sized value is a hand-written graph/inventory file +// with e.g. `"nodes": 3`. Go reports 0 for those instead of crashing the +// reasoner; the divergence is deliberate and confined to malformed input. +func pyLen(v any) int { + switch x := v.(type) { + case pyfmt.Ordered: + return len(x) + case []any: + return len(x) + case string: + return len([]rune(x)) // Python len() counts code points + } + return 0 +} + +// pyTruthy ports Python's truth-value testing for this value model: None, +// False, 0, 0.0, "" and every empty container are falsy; 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 int: + return x != 0 + case json.Number: + // An arbitrary-precision Python int (see pyfmt.loadNumber): truthy + // unless it is zero. + f, err := x.Float64() + return err != nil || f != 0 + case float64: + return x != 0 + case []any: + return len(x) > 0 + case pyfmt.Ordered: + return len(x) > 0 + } + return true +} + +// --------------------------------------------------------------------------- +// set / dict keys +// --------------------------------------------------------------------------- + +// pyKey / keySet are the Python set-key semantics this file needs, shared with +// internal/agents/chain's _filter_graph_for_findings port. The implementation +// (and the divergence notes) live in pyfmt.SetKey / pyfmt.KeySet; these aliases +// keep the Python-shaped lowercase spelling the ported code below reads with. +type pyKey = pyfmt.SetKey + +func keyOf(v any) pyKey { return pyfmt.KeyOf(v) } + +type keySet = pyfmt.KeySet + +func newKeySet() *keySet { return pyfmt.NewKeySet() } diff --git a/go/internal/agents/util/pyvalue_test.go b/go/internal/agents/util/pyvalue_test.go new file mode 100644 index 0000000..8832147 --- /dev/null +++ b/go/internal/agents/util/pyvalue_test.go @@ -0,0 +1,79 @@ +package util + +import ( + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" +) + +func TestPyLen(t *testing.T) { + cases := []struct { + name string + in any + want int + }{ + {"list", []any{1, 2, 3}, 3}, + {"dict", pyfmt.Ordered{{K: "a"}, {K: "b"}}, 2}, + {"str counts code points", "héllo", 5}, + {"empty", []any{}, 0}, + // Python raises TypeError for these; the port reports 0 rather than + // failing the reasoner (see pyLen's doc comment). + {"none", nil, 0}, + {"int", 5, 0}, + } + for _, tc := range cases { + if got := pyLen(tc.in); got != tc.want { + t.Errorf("%s: pyLen = %d, want %d", tc.name, got, tc.want) + } + } +} + +func TestPyTruthy(t *testing.T) { + falsy := []any{nil, false, "", 0, 0.0, []any{}, pyfmt.Ordered{}} + for _, v := range falsy { + if pyTruthy(v) { + t.Errorf("pyTruthy(%#v) = true, want false", v) + } + } + truthy := []any{true, "x", 1, -1, 0.5, []any{nil}, pyfmt.Ordered{{K: "a"}}} + for _, v := range truthy { + if !pyTruthy(v) { + t.Errorf("pyTruthy(%#v) = false, want true", v) + } + } +} + +// A Python set keyed on ids must not conflate None, "" and the string "None", +// nor a number with its string form. +func TestKeySet_DistinguishesPythonKinds(t *testing.T) { + s := newKeySet() + for _, v := range []any{nil, "", "None", 5, "5"} { + s.Add(v) + } + if got := len(s.Keys()); got != 5 { + t.Fatalf("expected 5 distinct keys, got %d", got) + } + s.Add(nil) // idempotent + if got := len(s.Keys()); got != 5 { + t.Fatalf("re-adding changed the size to %d", got) + } + if !s.Has("5") || !s.Has(5) || !s.Has(nil) { + t.Error("membership lost a member") + } + if s.Has("x") { + t.Error("membership invented a member") + } +} + +func TestDictGet_DefaultlessVersusDefaulted(t *testing.T) { + d := pyfmt.Ordered{{K: "present", V: "yes"}} + if got := dictGet(d, "absent"); got != nil { + t.Errorf("dictGet on a missing key = %#v, want nil (Python None)", got) + } + if got := dictGetDefault(d, "absent", ""); got != "" { + t.Errorf("dictGetDefault on a missing key = %#v, want \"\"", got) + } + if got := dictGetDefault(d, "present", "fallback"); got != "yes" { + t.Errorf("dictGetDefault on a present key = %#v, want \"yes\"", got) + } +} diff --git a/go/internal/agents/util/testdata/fixture/graph.json b/go/internal/agents/util/testdata/fixture/graph.json new file mode 100644 index 0000000..edc3a0c --- /dev/null +++ b/go/internal/agents/util/testdata/fixture/graph.json @@ -0,0 +1,148 @@ +{ + "nodes": [ + { + "resource_id": "aws_iam_role.admin", + "resource_type": "aws_iam_role", + "provider": "aws", + "file_path": "iam.tf", + "config_summary": { + "name": "admin", + "assume_role_policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "*" + } + } + ] + }, + "managed_policy_arns": [ + "arn:aws:iam::aws:policy/AdministratorAccess" + ] + } + }, + { + "resource_id": "aws_iam_policy.broad", + "resource_type": "aws_iam_policy", + "provider": "aws", + "file_path": "iam.tf", + "config_summary": { + "policy": "{\"Statement\":[{\"Action\":\"*\",\"Resource\":\"*\"}]}", + "description": null + } + }, + { + "resource_id": "aws_s3_bucket.data", + "resource_type": "aws_s3_bucket", + "provider": "aws", + "file_path": "storage.tf", + "config_summary": { + "acl": "public-read", + "versioning": false, + "tags": { + "owner": "data-team", + "env": "prod" + } + } + }, + { + "resource_id": "aws_vpc.main", + "resource_type": "aws_vpc", + "provider": "aws", + "file_path": "network.tf", + "config_summary": { + "cidr_block": "10.0.0.0/16", + "enable_dns_hostnames": true + } + }, + { + "resource_id": "aws_subnet.private", + "resource_type": "aws_subnet", + "provider": "aws", + "file_path": "network.tf", + "config_summary": { + "cidr_block": "10.0.1.0/24", + "map_public_ip_on_launch": false + } + }, + { + "resource_id": "aws_instance.web", + "resource_type": "aws_instance", + "provider": "aws", + "file_path": "compute.tf", + "config_summary": { + "instance_type": "t3.micro", + "associate_public_ip_address": true, + "metadata_options": { + "http_tokens": "optional" + } + } + }, + { + "resource_id": "aws_kms_key.master", + "resource_type": "aws_kms_key", + "provider": "aws", + "file_path": "security.tf", + "config_summary": { + "enable_key_rotation": false, + "deletion_window_in_days": 7, + "rotation_period_days": 90.0, + "description": "clé principale" + } + }, + { + "resource_id": "aws_cloudtrail.audit", + "resource_type": "aws_cloudtrail", + "provider": "aws", + "file_path": "logging.tf", + "config_summary": { + "is_multi_region_trail": false, + "enable_log_file_validation": false + } + }, + { + "resource_id": "aws_secretsmanager_secret.db", + "resource_type": "aws_secretsmanager_secret", + "provider": "aws", + "file_path": "secrets.tf", + "config_summary": { + "recovery_window_in_days": 0, + "rotation_rules": null + } + } + ], + "edges": [ + { + "source": "aws_iam_role.admin", + "target": "aws_iam_policy.broad", + "type": "policy_attachment", + "description": "Admin role has the broad policy attached" + }, + { + "source": "aws_vpc.main", + "target": "aws_subnet.private", + "type": "contains" + }, + { + "source": "aws_s3_bucket.data", + "target": "aws_kms_key.master", + "type": "encrypted_by", + "description": "" + }, + { + "source": "aws_instance.web", + "target": "aws_kms_key.master" + } + ], + "clusters": [ + { + "name": "identity/root", + "members": [ + "aws_iam_role.admin", + "aws_iam_policy.broad" + ] + } + ] +} diff --git a/go/internal/agents/util/testdata/fixture/inventory.json b/go/internal/agents/util/testdata/fixture/inventory.json new file mode 100644 index 0000000..ae6f551 --- /dev/null +++ b/go/internal/agents/util/testdata/fixture/inventory.json @@ -0,0 +1,142 @@ +{ + "resources": [ + { + "id": "aws_iam_role.admin", + "type": "aws_iam_role", + "name": "admin", + "provider": "aws", + "file_path": "iam.tf", + "line_number": 0 + }, + { + "id": "aws_iam_policy.broad", + "type": "aws_iam_policy", + "name": "broad", + "provider": "aws", + "file_path": "iam.tf", + "line_number": 0 + }, + { + "id": "aws_s3_bucket.data", + "type": "aws_s3_bucket", + "name": "data", + "provider": "aws", + "file_path": "storage.tf", + "line_number": 0 + }, + { + "id": "aws_vpc.main", + "type": "aws_vpc", + "name": "main", + "provider": "aws", + "file_path": "network.tf", + "line_number": 0 + }, + { + "id": "aws_subnet.private", + "type": "aws_subnet", + "name": "private", + "provider": "aws", + "file_path": "network.tf", + "line_number": 0 + }, + { + "id": "aws_instance.web", + "type": "aws_instance", + "name": "web", + "provider": "aws", + "file_path": "compute.tf", + "line_number": 0 + }, + { + "id": "aws_kms_key.master", + "type": "aws_kms_key", + "name": "master", + "provider": "aws", + "file_path": "security.tf", + "line_number": 0 + }, + { + "id": "aws_cloudtrail.audit", + "type": "aws_cloudtrail", + "name": "audit", + "provider": "aws", + "file_path": "logging.tf", + "line_number": 0 + }, + { + "id": "aws_secretsmanager_secret.db", + "type": "aws_secretsmanager_secret", + "name": "db", + "provider": "aws", + "file_path": "secrets.tf", + "line_number": 0 + }, + { + "id": "google_storage_bucket.exports", + "type": "google_storage_bucket", + "name": "exports", + "provider": "google", + "file_path": "gcp.tf", + "line_number": 0 + }, + { + "id": "local_file.notes", + "type": "local_file", + "name": "notes", + "provider": "", + "file_path": "misc.tf", + "line_number": 0 + }, + { + "id": "null_resource.bootstrap", + "type": "null_resource", + "name": "bootstrap", + "provider": null, + "file_path": "misc.tf", + "line_number": 0 + } + ], + "variables": [ + { + "name": "region", + "type": "string", + "default": "us-east-1" + }, + { + "name": "environment", + "type": "string", + "default": "prod" + } + ], + "outputs": [ + { + "name": "bucket_arn", + "value": "aws_s3_bucket.data.arn" + } + ], + "providers": [ + { + "name": "aws", + "region": "us-east-1", + "alias": null, + "version": null + }, + { + "name": "google", + "region": "us-central1", + "alias": null, + "version": null + } + ], + "modules": [ + { + "name": "vpc", + "source": "./modules/vpc" + }, + { + "name": "logging", + "source": "./modules/logging" + } + ] +} diff --git a/go/internal/agents/util/testdata/fixture/not_an_object.json b/go/internal/agents/util/testdata/fixture/not_an_object.json new file mode 100644 index 0000000..da812d2 --- /dev/null +++ b/go/internal/agents/util/testdata/fixture/not_an_object.json @@ -0,0 +1 @@ +["not", "an", "object"] diff --git a/go/internal/agents/util/testdata/golden/all_edges.txt b/go/internal/agents/util/testdata/golden/all_edges.txt new file mode 100644 index 0000000..5168c33 --- /dev/null +++ b/go/internal/agents/util/testdata/golden/all_edges.txt @@ -0,0 +1,6 @@ +RELEVANT RELATIONSHIPS: + - aws_iam_role.admin --[policy_attachment]--> aws_iam_policy.broad + Admin role has the broad policy attached + - aws_vpc.main --[contains]--> aws_subnet.private + - aws_s3_bucket.data --[encrypted_by]--> aws_kms_key.master + - aws_instance.web --[references]--> aws_kms_key.master \ No newline at end of file diff --git a/go/internal/agents/util/testdata/golden/all_nodes.txt b/go/internal/agents/util/testdata/golden/all_nodes.txt new file mode 100644 index 0000000..5749964 --- /dev/null +++ b/go/internal/agents/util/testdata/golden/all_nodes.txt @@ -0,0 +1,19 @@ +RELEVANT RESOURCES: + - aws_iam_role.admin (aws_iam_role) @ iam.tf + Config: {'name': 'admin', 'assume_role_policy': {'Version': '2012-10-17', 'Statement': [{'Effect': 'Allow', 'Principal': {'AWS': '*'}}]}, 'managed_policy_arns': ['arn:aws:iam::aws:policy/AdministratorAccess']} + - aws_iam_policy.broad (aws_iam_policy) @ iam.tf + Config: {'policy': '{"Statement":[{"Action":"*","Resource":"*"}]}', 'description': None} + - aws_s3_bucket.data (aws_s3_bucket) @ storage.tf + Config: {'acl': 'public-read', 'versioning': False, 'tags': {'owner': 'data-team', 'env': 'prod'}} + - aws_vpc.main (aws_vpc) @ network.tf + Config: {'cidr_block': '10.0.0.0/16', 'enable_dns_hostnames': True} + - aws_subnet.private (aws_subnet) @ network.tf + Config: {'cidr_block': '10.0.1.0/24', 'map_public_ip_on_launch': False} + - aws_instance.web (aws_instance) @ compute.tf + Config: {'instance_type': 't3.micro', 'associate_public_ip_address': True, 'metadata_options': {'http_tokens': 'optional'}} + - aws_kms_key.master (aws_kms_key) @ security.tf + Config: {'enable_key_rotation': False, 'deletion_window_in_days': 7, 'rotation_period_days': 90.0, 'description': 'clé principale'} + - aws_cloudtrail.audit (aws_cloudtrail) @ logging.tf + Config: {'is_multi_region_trail': False, 'enable_log_file_validation': False} + - aws_secretsmanager_secret.db (aws_secretsmanager_secret) @ secrets.tf + Config: {'recovery_window_in_days': 0, 'rotation_rules': None} \ No newline at end of file diff --git a/go/internal/agents/util/testdata/golden/all_stats.txt b/go/internal/agents/util/testdata/golden/all_stats.txt new file mode 100644 index 0000000..271185d --- /dev/null +++ b/go/internal/agents/util/testdata/golden/all_stats.txt @@ -0,0 +1,9 @@ +Total resources: 12 +Providers: aws, google +Modules: 2 +Variables: 2 +Outputs: 1 +Graph nodes: 9 +Graph edges: 4 +Filtered nodes: 9 +Filtered edges: 4 \ No newline at end of file diff --git a/go/internal/agents/util/testdata/golden/data_edges.txt b/go/internal/agents/util/testdata/golden/data_edges.txt new file mode 100644 index 0000000..8e8d2aa --- /dev/null +++ b/go/internal/agents/util/testdata/golden/data_edges.txt @@ -0,0 +1,3 @@ +RELEVANT RELATIONSHIPS: + - aws_s3_bucket.data --[encrypted_by]--> aws_kms_key.master + - aws_instance.web --[references]--> aws_kms_key.master \ No newline at end of file diff --git a/go/internal/agents/util/testdata/golden/data_nodes.txt b/go/internal/agents/util/testdata/golden/data_nodes.txt new file mode 100644 index 0000000..e2d9ada --- /dev/null +++ b/go/internal/agents/util/testdata/golden/data_nodes.txt @@ -0,0 +1,9 @@ +RELEVANT RESOURCES: + - aws_s3_bucket.data (aws_s3_bucket) @ storage.tf + Config: {'acl': 'public-read', 'versioning': False, 'tags': {'owner': 'data-team', 'env': 'prod'}} + - aws_kms_key.master (aws_kms_key) @ security.tf + Config: {'enable_key_rotation': False, 'deletion_window_in_days': 7, 'rotation_period_days': 90.0, 'description': 'clé principale'} + +CONNECTED RESOURCES (1-hop neighbors): + - aws_instance.web (aws_instance) @ compute.tf + Config: {'instance_type': 't3.micro', 'associate_public_ip_address': True, 'metadata_options': {'http_tokens': 'optional'}} \ No newline at end of file diff --git a/go/internal/agents/util/testdata/golden/data_stats.txt b/go/internal/agents/util/testdata/golden/data_stats.txt new file mode 100644 index 0000000..9dc9dc8 --- /dev/null +++ b/go/internal/agents/util/testdata/golden/data_stats.txt @@ -0,0 +1,9 @@ +Total resources: 12 +Providers: aws, google +Modules: 2 +Variables: 2 +Outputs: 1 +Graph nodes: 9 +Graph edges: 4 +Filtered nodes: 2 +Filtered edges: 2 \ No newline at end of file diff --git a/go/internal/agents/util/testdata/golden/iam_edges.txt b/go/internal/agents/util/testdata/golden/iam_edges.txt new file mode 100644 index 0000000..136263f --- /dev/null +++ b/go/internal/agents/util/testdata/golden/iam_edges.txt @@ -0,0 +1,3 @@ +RELEVANT RELATIONSHIPS: + - aws_iam_role.admin --[policy_attachment]--> aws_iam_policy.broad + Admin role has the broad policy attached \ No newline at end of file diff --git a/go/internal/agents/util/testdata/golden/iam_nodes.txt b/go/internal/agents/util/testdata/golden/iam_nodes.txt new file mode 100644 index 0000000..30c2beb --- /dev/null +++ b/go/internal/agents/util/testdata/golden/iam_nodes.txt @@ -0,0 +1,5 @@ +RELEVANT RESOURCES: + - aws_iam_role.admin (aws_iam_role) @ iam.tf + Config: {'name': 'admin', 'assume_role_policy': {'Version': '2012-10-17', 'Statement': [{'Effect': 'Allow', 'Principal': {'AWS': '*'}}]}, 'managed_policy_arns': ['arn:aws:iam::aws:policy/AdministratorAccess']} + - aws_iam_policy.broad (aws_iam_policy) @ iam.tf + Config: {'policy': '{"Statement":[{"Action":"*","Resource":"*"}]}', 'description': None} \ No newline at end of file diff --git a/go/internal/agents/util/testdata/golden/iam_stats.txt b/go/internal/agents/util/testdata/golden/iam_stats.txt new file mode 100644 index 0000000..b36265a --- /dev/null +++ b/go/internal/agents/util/testdata/golden/iam_stats.txt @@ -0,0 +1,9 @@ +Total resources: 12 +Providers: aws, google +Modules: 2 +Variables: 2 +Outputs: 1 +Graph nodes: 9 +Graph edges: 4 +Filtered nodes: 2 +Filtered edges: 1 \ No newline at end of file diff --git a/go/internal/agents/util/testdata/golden/missing_files_edges.txt b/go/internal/agents/util/testdata/golden/missing_files_edges.txt new file mode 100644 index 0000000..1b3a50f --- /dev/null +++ b/go/internal/agents/util/testdata/golden/missing_files_edges.txt @@ -0,0 +1,2 @@ +RELEVANT RELATIONSHIPS: + - no edges matched this hunter domain \ No newline at end of file diff --git a/go/internal/agents/util/testdata/golden/missing_files_nodes.txt b/go/internal/agents/util/testdata/golden/missing_files_nodes.txt new file mode 100644 index 0000000..231e597 --- /dev/null +++ b/go/internal/agents/util/testdata/golden/missing_files_nodes.txt @@ -0,0 +1,2 @@ +RELEVANT RESOURCES: + - none matched this hunter domain \ No newline at end of file diff --git a/go/internal/agents/util/testdata/golden/missing_files_stats.txt b/go/internal/agents/util/testdata/golden/missing_files_stats.txt new file mode 100644 index 0000000..fc0d9d1 --- /dev/null +++ b/go/internal/agents/util/testdata/golden/missing_files_stats.txt @@ -0,0 +1,9 @@ +Total resources: 0 +Providers: none +Modules: 0 +Variables: 0 +Outputs: 0 +Graph nodes: 0 +Graph edges: 0 +Filtered nodes: 0 +Filtered edges: 0 \ No newline at end of file diff --git a/go/internal/agents/util/testdata/golden/nomatch_edges.txt b/go/internal/agents/util/testdata/golden/nomatch_edges.txt new file mode 100644 index 0000000..1b3a50f --- /dev/null +++ b/go/internal/agents/util/testdata/golden/nomatch_edges.txt @@ -0,0 +1,2 @@ +RELEVANT RELATIONSHIPS: + - no edges matched this hunter domain \ No newline at end of file diff --git a/go/internal/agents/util/testdata/golden/nomatch_nodes.txt b/go/internal/agents/util/testdata/golden/nomatch_nodes.txt new file mode 100644 index 0000000..231e597 --- /dev/null +++ b/go/internal/agents/util/testdata/golden/nomatch_nodes.txt @@ -0,0 +1,2 @@ +RELEVANT RESOURCES: + - none matched this hunter domain \ No newline at end of file diff --git a/go/internal/agents/util/testdata/golden/nomatch_stats.txt b/go/internal/agents/util/testdata/golden/nomatch_stats.txt new file mode 100644 index 0000000..c5b00ef --- /dev/null +++ b/go/internal/agents/util/testdata/golden/nomatch_stats.txt @@ -0,0 +1,9 @@ +Total resources: 12 +Providers: aws, google +Modules: 2 +Variables: 2 +Outputs: 1 +Graph nodes: 9 +Graph edges: 4 +Filtered nodes: 0 +Filtered edges: 0 \ No newline at end of file diff --git a/go/internal/agents/util/testdata/golden/not_an_object_edges.txt b/go/internal/agents/util/testdata/golden/not_an_object_edges.txt new file mode 100644 index 0000000..1b3a50f --- /dev/null +++ b/go/internal/agents/util/testdata/golden/not_an_object_edges.txt @@ -0,0 +1,2 @@ +RELEVANT RELATIONSHIPS: + - no edges matched this hunter domain \ No newline at end of file diff --git a/go/internal/agents/util/testdata/golden/not_an_object_nodes.txt b/go/internal/agents/util/testdata/golden/not_an_object_nodes.txt new file mode 100644 index 0000000..231e597 --- /dev/null +++ b/go/internal/agents/util/testdata/golden/not_an_object_nodes.txt @@ -0,0 +1,2 @@ +RELEVANT RESOURCES: + - none matched this hunter domain \ No newline at end of file diff --git a/go/internal/agents/util/testdata/golden/not_an_object_stats.txt b/go/internal/agents/util/testdata/golden/not_an_object_stats.txt new file mode 100644 index 0000000..fc0d9d1 --- /dev/null +++ b/go/internal/agents/util/testdata/golden/not_an_object_stats.txt @@ -0,0 +1,9 @@ +Total resources: 0 +Providers: none +Modules: 0 +Variables: 0 +Outputs: 0 +Graph nodes: 0 +Graph edges: 0 +Filtered nodes: 0 +Filtered edges: 0 \ No newline at end of file diff --git a/go/internal/output/golden_test.go b/go/internal/output/golden_test.go new file mode 100644 index 0000000..56e0074 --- /dev/null +++ b/go/internal/output/golden_test.go @@ -0,0 +1,171 @@ +package output + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// This file is the byte-for-byte parity gate for internal/output. +// +// scripts/gen_golden_output.py builds three CloudSecurityScanResult 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/cloudsecurity_af/output/**: +// +// PYTHONPATH=src ~/.agentfield/packages/cloudsecurity-af/venv/bin/python go/scripts/gen_golden_output.py +// +// The fixtures are, in increasing nastiness: +// +// scan_result a fully populated tier-2 scan: four findings (one +// not_exploitable, two sharing a rule id), an attack path, +// drift, remediation, compliance mappings, cost breakdown +// scan_result_empty every "nothing to report" branch at once, and a +// whole-second timestamp +// scan_result_edge escaping, float spellings, the rule-id fallback, iac_line +// 0, an empty iac_file, a non-UTC timestamp and every +// truthiness guard + +// goldenFixtures names the fixtures every generator is checked against. +var goldenFixtures = []string{"scan_result", "scan_result_empty", "scan_result_edge"} + +// loadFixture reads testdata/.json into the Go model. +func loadFixture(t *testing.T, name string) schemas.CloudSecurityScanResult { + 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.CloudSecurityScanResult + 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 — a 6KB 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. The pretty mode is CPython's json.dumps spelling; the compact +// mode is pydantic's model_dump_json spelling, which is a different serializer +// entirely (see pydantic.go). +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)) + }) + } +} + +// TestFixtureRoundTrip proves the Go structs lose nothing the fixture carries: +// re-serialising a loaded fixture with pydantic's own spelling reproduces the +// golden compact 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_compact.json", GenerateJSON(loadFixture(t, name), false)) + }) + } +} + +// TestRenderJSONParsesTheFullDocument covers render_json, which Python defines +// as json.loads(generate_json(result, pretty=True)). +func TestRenderJSONParsesTheFullDocument(t *testing.T) { + for _, name := range goldenFixtures { + t.Run(name, func(t *testing.T) { + result := loadFixture(t, name) + got, err := RenderJSON(result) + if err != nil { + t.Fatalf("RenderJSON: %v", err) + } + var want map[string]any + if err := json.Unmarshal([]byte(readGolden(t, name+".full.json")), &want); err != nil { + t.Fatalf("parse golden: %v", err) + } + gotJSON, _ := json.Marshal(got) + wantJSON, _ := json.Marshal(want) + if string(gotJSON) != string(wantJSON) { + t.Fatalf("RenderJSON mismatch\n got %s\nwant %s", gotJSON, wantJSON) + } + }) + } +} diff --git a/go/internal/output/json_output.go b/go/internal/output/json_output.go new file mode 100644 index 0000000..1fb84a8 --- /dev/null +++ b/go/internal/output/json_output.go @@ -0,0 +1,185 @@ +package output + +import ( + "encoding/json" + "fmt" + "sort" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// This file ports src/cloudsecurity_af/output/json_output.py. + +// GenerateJSON ports generate_json: the full serialization of a scan result. +// +// full_json = result.model_dump_json() +// if not pretty: return full_json +// return json.dumps(json.loads(full_json), indent=2) +// +// The two modes are NOT the same document reflowed — they are two different +// serializers (see pydantic.go). pretty=false returns pydantic's own compact +// spelling, raw UTF-8 and all; pretty=true returns what CPython's json.dumps +// makes of it after a round-trip, which re-escapes every non-ASCII character +// and re-renders every float as repr(float). +// +// Python parity: `pretty` has no Go default — Python's is True. Callers that +// mirror `generate_json(result)` must pass true. +func GenerateJSON(result schemas.CloudSecurityScanResult, pretty bool) string { + tree := pyTree(result) + if !pretty { + return pydanticDumps(tree) + } + return pyfmt.Dumps(tree, 2) +} + +// GenerateSummaryJSON ports generate_summary_json: the compact summary with +// statistics and one line per finding / attack path. +// +// It is a hand-built dict, not a model dump, so the key order below is the +// Python literal's order and part of the artifact. +// +// Python parity: `result.timestamp.isoformat()` here is the ISO spelling with a +// numeric UTC offset ("+00:00"), NOT the "Z" spelling model_dump_json uses. +func GenerateSummaryJSON(result schemas.CloudSecurityScanResult) string { + findings := make([]any, 0, len(result.Findings)) + for _, f := range result.Findings { + findings = append(findings, obj{ + {K: "id", V: f.ID}, + {K: "title", V: f.Title}, + {K: "severity", V: string(f.Severity)}, + {K: "verdict", V: string(f.Verdict)}, + {K: "risk_score", V: f.RiskScore}, + {K: "category", V: f.Category}, + {K: "iac_file", V: f.IaCFile}, + {K: "iac_line", V: f.IaCLine}, + {K: "hunter_strategy", V: f.HunterStrategy}, + {K: "has_attack_path", V: f.AttackPath != nil}, + {K: "has_drift", V: f.Drift != nil}, + }) + } + + paths := make([]any, 0, len(result.AttackPaths)) + for _, p := range result.AttackPaths { + paths = append(paths, obj{ + {K: "id", V: p.ID}, + {K: "title", V: p.Title}, + {K: "entry_point", V: p.EntryPoint}, + {K: "target", V: p.Target}, + {K: "combined_severity", V: string(p.CombinedSeverity)}, + {K: "steps_count", V: len(p.Steps)}, + {K: "findings_involved", V: p.FindingsInvolved}, + }) + } + + summary := obj{ + {K: "repository", V: result.Repository}, + {K: "commit_sha", V: result.CommitSHA}, + {K: "timestamp", V: result.Timestamp.ISOFormat()}, + {K: "depth_profile", V: result.DepthProfile}, + {K: "tier", V: result.Tier}, + {K: "providers_detected", V: result.ProvidersDetected}, + {K: "summary", V: obj{ + {K: "total_resources_scanned", V: result.TotalResourcesScanned}, + {K: "total_findings", V: len(result.Findings)}, + {K: "confirmed", V: result.Confirmed}, + {K: "likely", V: result.Likely}, + {K: "inconclusive", V: result.Inconclusive}, + {K: "not_exploitable", V: result.NotExploitable}, + {K: "noise_reduction_pct", V: result.NoiseReductionPct}, + // Python parity: by_severity is seeded `{s.value: 0 for s in + // Severity}` and json.dumps keeps that insertion order — + // critical, high, medium, low, info, not the alphabetical + // critical, high, info, low, medium a Go map would produce. + {K: "by_severity", V: orderedCounts(result.BySeverity, schemas.BySeverityOrder())}, + }}, + {K: "findings", V: findings}, + {K: "attack_paths", V: paths}, + {K: "drift", V: obj{ + {K: "drifted_resources", V: result.DriftResources}, + {K: "shadow_it_resources", V: result.ShadowITResources}, + }}, + {K: "compliance_frameworks_checked", V: result.ComplianceFrameworksChecked}, + {K: "performance", V: obj{ + {K: "duration_seconds", V: result.DurationSeconds}, + {K: "cost_usd", V: result.CostUSD}, + // Same rule as by_severity: cost_breakdown is seeded from + // _PHASE_ORDER and json.dumps keeps that order. + {K: "cost_breakdown", V: orderedCosts(result.CostBreakdown, schemas.CostBreakdownOrder)}, + {K: "agent_invocations", V: result.AgentInvocations}, + }}, + } + return pyfmt.Dumps(summary, 2) +} + +// RenderJSON ports render_json: the parsed form of the pretty full JSON, for an +// API response. +// +// Python parity gap: Python returns a dict, which preserves the model's field +// order all the way out through FastAPI's encoder. A Go map cannot, so a caller +// that re-serialises this value gets sorted keys. Use GenerateJSON when the +// bytes matter; use this only where the consumer looks values up by key. +// +// The error is impossible in practice (GenerateJSON always emits a valid +// document); it is returned rather than swallowed so a future change to the +// serializer cannot fail silently. +func RenderJSON(result schemas.CloudSecurityScanResult) (map[string]any, error) { + var out map[string]any + if err := json.Unmarshal([]byte(GenerateJSON(result, true)), &out); err != nil { + return nil, fmt.Errorf("output: render_json: %w", err) + } + return out, nil +} + +// orderedCounts / orderedCosts render a dict-typed result field with Python's +// insertion order restored: the known keys first, then any key outside that set +// in code-point order (a tail the live path cannot produce). +func orderedCounts(m map[string]int, order []string) pyfmt.Ordered { + out := make(pyfmt.Ordered, 0, len(m)) + seen := make(map[string]bool, len(order)) + for _, k := range order { + seen[k] = true + if v, present := m[k]; present { + out = append(out, pyfmt.KV{K: k, V: v}) + } + } + for _, k := range restKeys(len(m), seen, func(yield func(string)) { + for k := range m { + yield(k) + } + }) { + out = append(out, pyfmt.KV{K: k, V: m[k]}) + } + return out +} + +func orderedCosts(m map[string]float64, order []string) pyfmt.Ordered { + out := make(pyfmt.Ordered, 0, len(m)) + seen := make(map[string]bool, len(order)) + for _, k := range order { + seen[k] = true + if v, present := m[k]; present { + out = append(out, pyfmt.KV{K: k, V: v}) + } + } + for _, k := range restKeys(len(m), seen, func(yield func(string)) { + for k := range m { + yield(k) + } + }) { + out = append(out, pyfmt.KV{K: k, V: m[k]}) + } + return out +} + +// restKeys collects the keys not covered by `seen`, sorted. +func restKeys(size int, seen map[string]bool, each func(func(string))) []string { + rest := make([]string, 0, size) + each(func(k string) { + if !seen[k] { + rest = append(rest, k) + } + }) + sort.Strings(rest) + return rest +} diff --git a/go/internal/output/json_output_test.go b/go/internal/output/json_output_test.go new file mode 100644 index 0000000..8a92886 --- /dev/null +++ b/go/internal/output/json_output_test.go @@ -0,0 +1,305 @@ +package output + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// Behaviour tests for output/json_output.py, derived from the Python source. +// The byte-level agreement is golden_test.go's job. + +// TestGenerateJSONModesAreDifferentSerializers pins the single most surprising +// thing about generate_json: `pretty` does not reflow one document, it swaps +// the serializer. The compact form is pydantic's model_dump_json (no spaces, +// raw UTF-8, "…Z" datetimes); the pretty form is CPython's json.dumps of what +// json.loads made of it (indent 2, ensure_ascii, repr floats). +func TestGenerateJSONModesAreDifferentSerializers(t *testing.T) { + result := schemas.NewCloudSecurityScanResult() + result.Repository = "héllo — 世界" + result.Timestamp = schemas.NewTimestamp(time.Date(2026, 5, 6, 7, 8, 9, 123456000, time.UTC)) + result.NoiseReductionPct = 1e-05 + + compact := GenerateJSON(result, false) + pretty := GenerateJSON(result, true) + + // Separators. + if strings.Contains(compact, `": `) || strings.Contains(compact, ", \"") { + t.Errorf("pydantic's spelling has no whitespace after separators:\n%s", compact) + } + if !strings.Contains(pretty, "\n \"repository\": ") { + t.Errorf("json.dumps(indent=2) spelling missing:\n%s", pretty[:200]) + } + + // ensure_ascii. + if !strings.Contains(compact, "héllo — 世界") { + t.Error("pydantic writes non-ASCII raw") + } + if strings.Contains(pretty, "héllo") || !strings.Contains(pretty, `\u00e9llo`) { + t.Errorf("json.dumps escapes every non-ASCII rune; got %s", pretty[:200]) + } + + // Datetime spelling. + if !strings.Contains(compact, `"2026-05-06T07:08:09.123456Z"`) { + t.Errorf("pydantic writes the Z spelling:\n%s", compact) + } + if !strings.Contains(pretty, `"2026-05-06T07:08:09.123456Z"`) { + t.Error("the pretty form re-reads pydantic's string verbatim, so it keeps the Z") + } + + // Float spelling. + if !strings.Contains(compact, `"noise_reduction_pct":0.00001`) { + t.Errorf("pydantic renders 1e-05 as 0.00001:\n%s", compact) + } + if !strings.Contains(pretty, `"noise_reduction_pct": 1e-05`) { + t.Error("json.dumps renders 1e-05 as 1e-05") + } + + // Both must nonetheless describe the same document. + var a, b any + if err := json.Unmarshal([]byte(compact), &a); err != nil { + t.Fatalf("compact is not valid JSON: %v", err) + } + if err := json.Unmarshal([]byte(pretty), &b); err != nil { + t.Fatalf("pretty is not valid JSON: %v", err) + } + ja, _ := json.Marshal(a) + jb, _ := json.Marshal(b) + if string(ja) != string(jb) { + t.Fatalf("the two spellings disagree about the document:\n %s\n %s", ja, jb) + } +} + +// TestGenerateJSONKeepsFieldOrder pins that the full dump follows pydantic's +// FIELD DECLARATION order, not alphabetical order — the Go struct's field order +// is the contract. +func TestGenerateJSONKeepsFieldOrder(t *testing.T) { + got := GenerateJSON(schemas.NewCloudSecurityScanResult(), false) + want := []string{"repository", "commit_sha", "branch", "timestamp", "depth_profile", "tier", + "providers_detected", "findings", "attack_paths", "total_resources_scanned"} + pos := -1 + for _, key := range want { + at := strings.Index(got, `"`+key+`":`) + if at < 0 { + t.Fatalf("key %q missing from:\n%s", key, got) + } + if at < pos { + t.Fatalf("key %q is out of declaration order in:\n%s", key, got) + } + pos = at + } +} + +// TestSummaryJSONShape pins generate_summary_json's key set and the derived +// values it computes rather than copies. +func TestSummaryJSONShape(t *testing.T) { + path := schemas.NewAttackPath() + path.ID = "p1" + path.Title = "ALB to bucket" + path.EntryPoint = "aws_lb.public" + path.Target = "aws_s3_bucket.pii" + path.CombinedSeverity = scoring.SeverityCritical + path.FindingsInvolved = []string{"f1"} + path.Steps = []schemas.AttackStep{{StepNumber: 1}, {StepNumber: 2}} + + drift := schemas.DriftedResource{ResourceID: "r", Significance: "high"} + f := schemas.NewVerifiedFinding() + f.ID = "f1" + f.Title = "Open bucket" + f.Severity = scoring.SeverityHigh + f.Verdict = schemas.VerdictLikely + f.RiskScore = 7.5 + f.Category = "public_exposure" + f.IaCFile = "s3.tf" + f.IaCLine = 4 + f.HunterStrategy = "data" + f.AttackPath = &path + f.Drift = &drift + + result := schemas.NewCloudSecurityScanResult() + result.Repository = "org/repo" + result.CommitSHA = "abc" + result.Timestamp = schemas.NewTimestamp(time.Date(2026, 5, 6, 7, 8, 9, 0, time.UTC)) + result.DepthProfile = "standard" + result.Tier = 2 + result.ProvidersDetected = []string{"aws"} + result.Findings = []schemas.VerifiedFinding{f} + result.AttackPaths = []schemas.AttackPath{path} + result.TotalResourcesScanned = 12 + result.Confirmed, result.Likely, result.Inconclusive, result.NotExploitable = 0, 1, 0, 0 + result.NoiseReductionPct = 50.0 + result.BySeverity = map[string]int{"high": 1} + result.DriftResources, result.ShadowITResources = 2, 1 + result.ComplianceFrameworksChecked = []string{"CIS-AWS"} + result.DurationSeconds = 1.5 + result.CostUSD = 0.25 + result.CostBreakdown = map[string]float64{"hunt": 0.25} + result.AgentInvocations = 9 + + var doc map[string]any + if err := json.Unmarshal([]byte(GenerateSummaryJSON(result)), &doc); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + topLevel := []string{"repository", "commit_sha", "timestamp", "depth_profile", "tier", + "providers_detected", "summary", "findings", "attack_paths", "drift", + "compliance_frameworks_checked", "performance"} + for _, key := range topLevel { + if _, ok := doc[key]; !ok { + t.Errorf("summary is missing %q", key) + } + } + if len(doc) != len(topLevel) { + t.Errorf("summary has %d keys, want %d — an extra key is a parity break", len(doc), len(topLevel)) + } + + // The timestamp here is isoformat(), NOT pydantic's Z spelling. + if got := doc["timestamp"]; got != "2026-05-06T07:08:09+00:00" { + t.Errorf("timestamp = %v, want the isoformat spelling", got) + } + + summary, _ := doc["summary"].(map[string]any) + if got := summary["total_findings"]; got != float64(1) { + t.Errorf("total_findings = %v, want 1 (len(findings), not a stored count)", got) + } + + findings, _ := doc["findings"].([]any) + first, _ := findings[0].(map[string]any) + if first["has_attack_path"] != true || first["has_drift"] != true { + t.Errorf("has_attack_path/has_drift = %v/%v, want true/true", + first["has_attack_path"], first["has_drift"]) + } + if first["severity"] != "high" || first["verdict"] != "likely" { + t.Errorf("enums must be rendered as their values, got %v/%v", first["severity"], first["verdict"]) + } + + paths, _ := doc["attack_paths"].([]any) + firstPath, _ := paths[0].(map[string]any) + if got := firstPath["steps_count"]; got != float64(2) { + t.Errorf("steps_count = %v, want 2 (len(steps), the steps themselves are omitted)", got) + } + if _, present := firstPath["steps"]; present { + t.Error("the summary must not carry the full steps list") + } +} + +// TestSummaryJSONNilFlags pins that has_attack_path / has_drift are false when +// the pointers are nil (Python: `is not None`). +func TestSummaryJSONNilFlags(t *testing.T) { + f := schemas.NewVerifiedFinding() + f.Title = "T" + f.Verdict = schemas.VerdictConfirmed + f.Severity = scoring.SeverityLow + + result := schemas.NewCloudSecurityScanResult() + result.Findings = []schemas.VerifiedFinding{f} + + var doc map[string]any + if err := json.Unmarshal([]byte(GenerateSummaryJSON(result)), &doc); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + findings, _ := doc["findings"].([]any) + first, _ := findings[0].(map[string]any) + if first["has_attack_path"] != false || first["has_drift"] != false { + t.Errorf("want false/false, got %v/%v", first["has_attack_path"], first["has_drift"]) + } +} + +// TestSummaryJSONEmptyContainers pins that empty lists render as [] rather than +// null, which is what a nil Go slice would give. +func TestSummaryJSONEmptyContainers(t *testing.T) { + got := GenerateSummaryJSON(schemas.NewCloudSecurityScanResult()) + for _, want := range []string{ + `"providers_detected": []`, + `"findings": []`, + `"attack_paths": []`, + `"compliance_frameworks_checked": []`, + `"by_severity": {}`, + `"cost_breakdown": {}`, + } { + if !strings.Contains(got, want) { + t.Errorf("missing %s in:\n%s", want, got) + } + } +} + +// TestRenderJSONReturnsTheParsedDocument pins render_json. +func TestRenderJSONReturnsTheParsedDocument(t *testing.T) { + result := schemas.NewCloudSecurityScanResult() + result.Repository = "org/repo" + result.Tier = 3 + + got, err := RenderJSON(result) + if err != nil { + t.Fatalf("RenderJSON: %v", err) + } + if got["repository"] != "org/repo" { + t.Errorf("repository = %v", got["repository"]) + } + if got["tier"] != float64(3) { + t.Errorf("tier = %v (encoding/json decodes every number as float64)", got["tier"]) + } + if _, ok := got["findings"]; !ok { + t.Error("render_json returns the FULL document, not the summary") + } +} + +// TestSummaryAndFullJSONUseThePythonDictOrder pins the two dict-typed result +// fields whose INSERTION order Python fixes and a Go map cannot carry. +// +// orchestrator.py:165 seeds `severity_counts = {s.value: 0 for s in Severity}`, +// and Severity is declared critical, high, medium, low, info (scoring.py:6-11), +// so json.dumps writes them in that order — never the alphabetical critical, +// high, info, low, medium. orchestrator.py:54,67 seeds cost_breakdown from +// _PHASE_ORDER, so it reads recon, hunt, chain, prove, remediate. +func TestSummaryAndFullJSONUseThePythonDictOrder(t *testing.T) { + result := schemas.NewCloudSecurityScanResult() + result.Repository = "/repo" + result.BySeverity = map[string]int{"info": 0, "low": 1, "medium": 2, "high": 3, "critical": 4} + result.CostBreakdown = map[string]float64{ + "remediate": 0.5, "prove": 0.4, "chain": 0.3, "hunt": 0.2, "recon": 0.1, + } + + for name, body := range map[string]string{ + "summary": GenerateSummaryJSON(result), + "full": GenerateJSON(result, true), + } { + severities := dictKeyOrder(t, body, "by_severity") + if got := strings.Join(severities, ","); got != "critical,high,medium,low,info" { + t.Errorf("%s json by_severity order = %s, want critical,high,medium,low,info", name, got) + } + phases := dictKeyOrder(t, body, "cost_breakdown") + if got := strings.Join(phases, ","); got != "recon,hunt,chain,prove,remediate" { + t.Errorf("%s json cost_breakdown order = %s, want recon,hunt,chain,prove,remediate", name, got) + } + } +} + +// dictKeyOrder reads the key order of the object that follows `"": {` +// in an indented JSON document. +func dictKeyOrder(t *testing.T, body, field string) []string { + t.Helper() + idx := strings.Index(body, `"`+field+`": {`) + if idx < 0 { + t.Fatalf("no %q object in the document:\n%s", field, body) + } + rest := body[idx+len(`"`+field+`": {`):] + end := strings.Index(rest, "}") + if end < 0 { + t.Fatalf("unterminated %q object", field) + } + var keys []string + for _, line := range strings.Split(rest[:end], "\n") { + line = strings.TrimSpace(line) + key, _, ok := strings.Cut(line, ":") + if !ok { + continue + } + keys = append(keys, strings.Trim(key, `"`)) + } + return keys +} diff --git a/go/internal/output/pydantic.go b/go/internal/output/pydantic.go new file mode 100644 index 0000000..40c3e1d --- /dev/null +++ b/go/internal/output/pydantic.go @@ -0,0 +1,469 @@ +package output + +import ( + "encoding/json" + "math" + "reflect" + "sort" + "strconv" + "strings" + "time" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// This file ports the ONE thing json_output.py needs that pyfmt.Dumps cannot +// give it: pydantic v2's `BaseModel.model_dump_json()` spelling. +// +// src/cloudsecurity_af/output/json_output.py:15 +// full_json = result.model_dump_json() +// if not pretty: return full_json +// return json.dumps(json.loads(full_json), indent=2) +// +// So `generate_json` has TWO serializers in it, and they disagree: +// +// pydantic model_dump_json json.dumps +// separators "," ":" ", " ": " (or indent) +// non-ASCII raw UTF-8 \uXXXX (ensure_ascii) +// DEL (0x7f) raw  +// datetime "…T03:04:05.123456Z" (already a string) +// 1e-05 0.00001 1e-05 +// 1e-07 1e-7 1e-07 +// NaN / ±Inf null NaN / Infinity +// +// The pretty path is therefore NOT "dump the model with pyfmt": it is +// "dump the model with pydantic, parse it back, dump THAT with json.dumps". +// Both paths are driven from one intermediate tree here (pyTree), so the two +// spellings can never drift out of agreement about which value is at which key: +// +// tree := pyTree(result) +// compact := pydanticDumps(tree) // == result.model_dump_json() +// pretty := pyfmt.Dumps(tree, 2) // == json.dumps(json.loads(compact), indent=2) +// +// The round-trip is lossless because every float in the tree is rendered with +// shortest-round-trip digits by BOTH spellings, so json.loads recovers the +// identical float64. + +// timestampType is schemas.Timestamp, the one Go type whose JSON spelling +// differs between the two serializers (isoformat "+00:00" vs pydantic "Z"). +var timestampType = reflect.TypeOf(schemas.Timestamp{}) + +// jsonNumberType is json.Number, which appears in a `map[string]any` only when +// the caller decoded with json.Decoder.UseNumber. +var jsonNumberType = reflect.TypeOf(json.Number("")) + +// pyTree converts a Go value into the value tree `json.loads(model_dump_json())` +// produces on the Python side. +// +// The mapping mirrors pyfmt.Dumps' own walk (declaration-ordered structs, json +// tags, sorted map keys, nil slices/maps as null) so the two renderers agree, +// with three pydantic-specific rules layered on top: +// +// - schemas.Timestamp becomes the pydantic ISO string ("…Z" for UTC), because +// that is what model_dump_json writes and therefore what json.loads sees. +// Its own MarshalJSON emits the FastAPI/jsonable_encoder spelling +// ("+00:00"), which is the reasoner-boundary format, not this one. +// - A non-finite float becomes nil. pydantic's default +// `ser_json_inf_nan="null"` writes `null` for NaN and ±Inf where +// json.dumps would write the bare NaN/Infinity tokens, and since the pretty +// path re-reads pydantic's output it sees `null` too. +// - Integers stay integers (int64/uint64), so neither renderer turns 1 into +// 1.0. +// +// Known divergence — `metadata: dict[str, object]`: Go decodes an untyped JSON +// number into float64, so an INTEGER that arrived inside CloudSecurityScanResult. +// Metadata over the control-plane boundary renders as "1.0" where Python renders +// "1" (afx.Bind uses plain encoding/json, deliberately, see afx/bind.go). Every +// typed field is unaffected. Decode with json.Decoder.UseNumber to get exact +// parity; json.Number is passed through untouched here for that reason. +func pyTree(v any) any { return pyTreeValue(reflect.ValueOf(v)) } + +func pyTreeValue(rv reflect.Value) any { + for { + if !rv.IsValid() { + return nil + } + if k := rv.Kind(); k == reflect.Pointer || k == reflect.Interface { + if rv.IsNil() { + return nil + } + rv = rv.Elem() + continue + } + break + } + + switch rv.Type() { + case timestampType: + ts, _ := rv.Interface().(schemas.Timestamp) + return pydanticISO(ts) + case jsonNumberType: + return rv.Interface() + } + + switch rv.Kind() { + case reflect.Bool: + return rv.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return rv.Int() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return rv.Uint() + case reflect.Float32, reflect.Float64: + f := rv.Float() + if math.IsNaN(f) || math.IsInf(f, 0) { + // Python parity: pydantic's ser_json_inf_nan default is "null". + return nil + } + return f + case reflect.String: + return rv.String() + case reflect.Slice, reflect.Array: + if rv.Kind() == reflect.Slice && rv.IsNil() { + return nil + } + out := make([]any, rv.Len()) + for i := range out { + out[i] = pyTreeValue(rv.Index(i)) + } + return out + case reflect.Map: + if rv.IsNil() { + return nil + } + keys := rv.MapKeys() + names := make([]string, 0, len(keys)) + byName := make(map[string]reflect.Value, len(keys)) + for _, k := range keys { + name := pyfmt.JSONMapKey(k) + names = append(names, name) + byName[name] = rv.MapIndex(k) + } + // Python parity gap: a Python dict keeps insertion order and both + // serializers honor it. A Go map has none, so keys are SORTED — the + // same documented deviation pyfmt.Dumps makes. `metadata` is the only + // dict-typed field this still reaches: by_severity and cost_breakdown + // have a KNOWN insertion order and are rendered by + // pyTreeOrderedMap from pyTreeStruct. + sort.Strings(names) + out := make(pyfmt.Ordered, 0, len(names)) + for _, name := range names { + out = append(out, pyfmt.KV{K: name, V: pyTreeValue(byName[name])}) + } + return out + case reflect.Struct: + return pyTreeStruct(rv) + } + return nil +} + +// orderedDictFields are the model fields whose dict INSERTION order Python +// fixes and a Go map cannot carry, mapped to that order. +// +// `by_severity` is seeded `{s.value: 0 for s in Severity}` and `cost_breakdown` +// is seeded from `_PHASE_ORDER`, both in orchestrator.py, and neither ever +// gains a key on the live path — so their order is deterministic and knowable, +// unlike `metadata`, which stays sorted. +func orderedDictFields(name string) []string { + switch name { + case "by_severity": + return schemas.BySeverityOrder() + case "cost_breakdown": + return schemas.CostBreakdownOrder + } + return nil +} + +// pyTreeOrderedMap renders a map field with the given key order first, then any +// remaining keys sorted (a defensive tail Python cannot reach, kept +// deterministic). +func pyTreeOrderedMap(rv reflect.Value, order []string) any { + if rv.Kind() == reflect.Pointer || rv.Kind() == reflect.Interface { + if rv.IsNil() { + return nil + } + rv = rv.Elem() + } + if rv.Kind() != reflect.Map { + return pyTreeValue(rv) + } + if rv.IsNil() { + return nil + } + byName := make(map[string]reflect.Value, rv.Len()) + rest := make([]string, 0, rv.Len()) + for _, k := range rv.MapKeys() { + name := pyfmt.JSONMapKey(k) + byName[name] = rv.MapIndex(k) + } + out := make(pyfmt.Ordered, 0, len(byName)) + seen := make(map[string]bool, len(order)) + for _, name := range order { + seen[name] = true + if v, present := byName[name]; present { + out = append(out, pyfmt.KV{K: name, V: pyTreeValue(v)}) + } + } + for name := range byName { + if !seen[name] { + rest = append(rest, name) + } + } + sort.Strings(rest) + for _, name := range rest { + out = append(out, pyfmt.KV{K: name, V: pyTreeValue(byName[name])}) + } + return out +} + +// pyTreeStruct walks exported fields in DECLARATION order — which is pydantic's +// field order, and therefore model_dump()'s insertion order — honoring the json +// tag name, `json:"-"`, omitempty and encoding/json's flattening of an untagged +// anonymous struct field. +func pyTreeStruct(rv reflect.Value) pyfmt.Ordered { + rt := rv.Type() + out := make(pyfmt.Ordered, 0, rt.NumField()) + for i := 0; i < rt.NumField(); i++ { + sf := rt.Field(i) + if !sf.IsExported() { + continue + } + name, opts, _ := strings.Cut(sf.Tag.Get("json"), ",") + if name == "-" && opts == "" { + continue + } + fv := rv.Field(i) + if name == "" && sf.Anonymous { + inner := fv + for inner.Kind() == reflect.Pointer && !inner.IsNil() { + inner = inner.Elem() + } + if inner.Kind() == reflect.Struct && inner.Type() != timestampType { + out = append(out, pyTreeStruct(inner)...) + continue + } + } + if name == "" { + name = sf.Name + } + if strings.Contains(","+opts+",", ",omitempty,") && pyfmt.IsEmptyValue(fv) { + continue + } + if order := orderedDictFields(name); order != nil { + out = append(out, pyfmt.KV{K: name, V: pyTreeOrderedMap(fv, order)}) + continue + } + out = append(out, pyfmt.KV{K: name, V: pyTreeValue(fv)}) + } + return out +} + +// pydanticDumps renders a pyTree value exactly as pydantic v2's +// `model_dump_json()` does: no whitespace at all, `,` and `:` separators, raw +// UTF-8 (no ensure_ascii), and pydanticFloat numbers. +func pydanticDumps(v any) string { + var b strings.Builder + writePydantic(&b, v) + return b.String() +} + +func writePydantic(b *strings.Builder, v any) { + switch x := v.(type) { + case nil: + b.WriteString("null") + case bool: + if x { + b.WriteString("true") + } else { + b.WriteString("false") + } + case string: + writePydanticString(b, x) + case json.Number: + writePydanticNumber(b, string(x)) + case int64: + b.WriteString(strconv.FormatInt(x, 10)) + case uint64: + b.WriteString(strconv.FormatUint(x, 10)) + case int: + b.WriteString(strconv.Itoa(x)) + case float64: + b.WriteString(pydanticFloat(x)) + case []any: + b.WriteByte('[') + for i, item := range x { + if i > 0 { + b.WriteByte(',') + } + writePydantic(b, item) + } + b.WriteByte(']') + case pyfmt.Ordered: + b.WriteByte('{') + for i, pair := range x { + if i > 0 { + b.WriteByte(',') + } + writePydanticString(b, pair.K) + b.WriteByte(':') + writePydantic(b, pair.V) + } + b.WriteByte('}') + default: + // pyTree only ever emits the cases above; anything else is a bug + // upstream and rendering null keeps the document parseable. + b.WriteString("null") + } +} + +// writePydanticNumber renders a json.Number the way pydantic would render the +// value it stands for: an integral literal verbatim, anything else as a float. +func writePydanticNumber(b *strings.Builder, lit string) { + if lit == "" { + b.WriteString("null") + return + } + if !strings.ContainsAny(lit, ".eE") { + b.WriteString(lit) + return + } + f, err := strconv.ParseFloat(lit, 64) + if err != nil { + b.WriteString(lit) + return + } + b.WriteString(pydanticFloat(f)) +} + +// writePydanticString renders s the way pydantic-core's Rust JSON writer does: +// only `"`, `\` and the C0 control characters are escaped. Verified against the +// venv interpreter: +// +// DEL (0x7f) -> raw +// U+2028 / U+2029 -> raw +// < > & / -> raw (Go's encoding/json escapes the first three) +// non-ASCII -> raw UTF-8 (json.dumps would emit \uXXXX) +func writePydanticString(b *strings.Builder, s string) { + b.WriteByte('"') + for _, r := range s { + switch r { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\b': + b.WriteString(`\b`) + case '\f': + b.WriteString(`\f`) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + if r < 0x20 { + b.WriteString(`\u00`) + b.WriteByte(lowerHex[(r>>4)&0xf]) + b.WriteByte(lowerHex[r&0xf]) + continue + } + b.WriteRune(r) + } + } + b.WriteByte('"') +} + +const lowerHex = "0123456789abcdef" + +// pydanticFloat renders a float the way pydantic v2 writes it in JSON. +// +// It agrees with Python's repr() — and therefore with pyfmt.FormatFloat — on +// the DIGITS (both use the shortest round-tripping representation) and differs +// only in how it chooses and spells the notation. Verified across +// {1.0, 1.5, 9.87} × 10^[-12,19] against the venv interpreter: +// +// repr / json.dumps pydantic +// 1e-5 1e-05 0.00001 <- fixed one decade lower +// 9.87e-5 9.87e-05 0.0000987 +// 1e-6 1e-06 1e-6 <- exponent not zero-padded +// 1e-9 1e-09 1e-9 +// 1e-320 1e-320 1e-320 +// 1e15 1000000000000000.0 1000000000000000.0 +// 1e16 1e+16 1e+16 +// +// i.e. fixed notation for decpt >= -4 (repr: decpt > -4) and, in scientific +// notation, a signed exponent with NO minimum width (repr pads to two digits). +// `decpt` is CPython's: the value is 0. * 10**decpt. +func pydanticFloat(f float64) string { + if math.IsNaN(f) || math.IsInf(f, 0) { + // Python parity: pydantic's ser_json_inf_nan default is "null". + // pyTree already replaces these with nil, so this is belt-and-braces. + return "null" + } + sign := "" + if math.Signbit(f) { + sign = "-" + } + digits, decpt := pyfmt.ShortestDigits(math.Abs(f)) + if decpt < -4 || decpt > 16 { + return sign + pydanticSci(digits, decpt) + } + return sign + pydanticFixed(digits, decpt) +} + +// pydanticSci renders [.]e±X — signed exponent, no zero padding. +func pydanticSci(digits string, decpt int) string { + var b strings.Builder + b.WriteByte(digits[0]) + if len(digits) > 1 { + b.WriteByte('.') + b.WriteString(digits[1:]) + } + exp := decpt - 1 + b.WriteByte('e') + if exp < 0 { + b.WriteByte('-') + exp = -exp + } else { + b.WriteByte('+') + } + b.WriteString(strconv.Itoa(exp)) + return b.String() +} + +// pydanticFixed renders the digits with the decimal point at decpt, always +// keeping at least one digit on each side ("0.00001", "100.0", "0.0"). +func pydanticFixed(digits string, decpt int) string { + switch { + case decpt <= 0: + return "0." + strings.Repeat("0", -decpt) + digits + case decpt >= len(digits): + return digits + strings.Repeat("0", decpt-len(digits)) + ".0" + default: + return digits[:decpt] + "." + digits[decpt:] + } +} + +// pydanticISO renders a schemas.Timestamp the way pydantic v2 serialises a +// `datetime` field into JSON: like datetime.isoformat() except that a zero UTC +// offset is spelled "Z" instead of "+00:00". Verified against the venv: +// +// datetime(2026,1,2,3,4,5,123456,tzinfo=UTC) -> "2026-01-02T03:04:05.123456Z" +// datetime(2026,1,2,3,4,5, tzinfo=UTC) -> "2026-01-02T03:04:05Z" +// datetime(2026,1,2,3,4,5,123456,tz=+05:30) -> "2026-01-02T03:04:05.123456+05:30" +// +// schemas.Timestamp.ISOFormat() is the OTHER spelling (always numeric offset) — +// the one output/report.py and output/sarif.py interpolate as +// `result.timestamp.isoformat()`. Do not conflate them. +func pydanticISO(ts schemas.Timestamp) string { + t := ts.Truncate(time.Microsecond) + layout := "2006-01-02T15:04:05" + if t.Nanosecond() != 0 { + layout += ".000000" + } + if _, offset := t.Zone(); offset == 0 { + return t.Format(layout) + "Z" + } + return t.Format(layout + "-07:00") +} diff --git a/go/internal/output/pydantic_test.go b/go/internal/output/pydantic_test.go new file mode 100644 index 0000000..a0e3e75 --- /dev/null +++ b/go/internal/output/pydantic_test.go @@ -0,0 +1,332 @@ +package output + +import ( + "encoding/json" + "math" + "strings" + "testing" + "time" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// Every expectation in this file is CPython + pydantic 2.13.4 ground truth from +// ~/.agentfield/packages/cloudsecurity-af/venv/bin/python; the generator +// expression is quoted next to each table. + +// TestPydanticFloat pins pydantic's float spelling, which agrees with repr() +// on the DIGITS and differs on the notation threshold and the exponent width. +// +// class F(BaseModel): v: float +// F(v=x).model_dump_json() +func TestPydanticFloat(t *testing.T) { + cases := []struct { + in float64 + want string + // pyRepr is what pyfmt.FormatFloat (json.dumps / repr) produces, quoted + // where it DIFFERS so the divergence is visible in the source. + pyRepr string + }{ + {0.0, "0.0", "0.0"}, + {math.Copysign(0, -1), "-0.0", "-0.0"}, + {1.0, "1.0", "1.0"}, + {0.5, "0.5", "0.5"}, + {0.1, "0.1", "0.1"}, + {3.141592653589793, "3.141592653589793", "3.141592653589793"}, + + // The fixed/scientific threshold sits one decade lower than repr's. + {1e-4, "0.0001", "0.0001"}, + {1e-5, "0.00001", "1e-05"}, + {9.87e-5, "0.0000987", "9.87e-05"}, + {1.5e-5, "0.000015", "1.5e-05"}, + {1.5000000000000002e-05, "0.000015000000000000002", "1.5000000000000002e-05"}, + + // Below that, scientific — with an UNPADDED exponent. + {1e-6, "1e-6", "1e-06"}, + {1e-7, "1e-7", "1e-07"}, + {1e-9, "1e-9", "1e-09"}, + {1e-10, "1e-10", "1e-10"}, + {5e-324, "5e-324", "5e-324"}, + + // The upper threshold is repr's: fixed through 1e15, scientific from 1e16. + {1e15, "1000000000000000.0", "1000000000000000.0"}, + {1e16, "1e+16", "1e+16"}, + {1.5e16, "1.5e+16", "1.5e+16"}, + {-1e16, "-1e+16", "-1e+16"}, + {1.7976931348623157e308, "1.7976931348623157e+308", "1.7976931348623157e+308"}, + + {-7.25, "-7.25", "-7.25"}, + } + for _, tc := range cases { + t.Run(tc.want, func(t *testing.T) { + if got := pydanticFloat(tc.in); got != tc.want { + t.Errorf("pydanticFloat(%v) = %q, want %q", tc.in, got, tc.want) + } + if got := pyfmt.FormatFloat(tc.in); got != tc.pyRepr { + t.Errorf("pyfmt.FormatFloat(%v) = %q, want %q (repr spelling)", tc.in, got, tc.pyRepr) + } + // Whatever the spelling, both must round-trip to the same float — + // that is what lets generate_json dump with one and re-read with + // the other. + var back float64 + if err := json.Unmarshal([]byte(tc.want), &back); err != nil { + t.Fatalf("pydantic spelling %q is not valid JSON: %v", tc.want, err) + } + if back != tc.in && !(math.Signbit(back) == math.Signbit(tc.in) && back == tc.in) { + t.Errorf("round trip of %q gave %v, want %v", tc.want, back, tc.in) + } + }) + } +} + +// TestPydanticNonFiniteIsNull pins pydantic's ser_json_inf_nan="null" default, +// which is the one place its numbers are not just repr under another name. +// +// F(v=float("nan")).model_dump_json() == '{"v":null}' +func TestPydanticNonFiniteIsNull(t *testing.T) { + for _, v := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} { + if got := pydanticFloat(v); got != "null" { + t.Errorf("pydanticFloat(%v) = %q, want null", v, got) + } + // pyTree drops them to nil BEFORE either renderer sees them, so the + // pretty path (json.dumps, which would emit the bare NaN token) agrees. + if got := pyTree(v); got != nil { + t.Errorf("pyTree(%v) = %v, want nil", v, got) + } + } +} + +// TestPydanticString pins pydantic-core's escaping: only `"`, `\` and the C0 +// controls. Everything else — DEL, U+2028/9, <>&/, all non-ASCII — is raw. +// +// class S(BaseModel): v: str +// S(v=s).model_dump_json() +func TestPydanticString(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"quote", "a\"b", `"a\"b"`}, + {"backslash", `a\b`, `"a\\b"`}, + {"short escapes", "\b\f\n\r\t", `"\b\f\n\r\t"`}, + {"other control", "\x00\x01\x1f", "\"\\u0000\\u0001\\u001f\""}, + {"DEL is raw", "\x7f", "\"\x7f\""}, + {"html chars are raw", " & /", `" & /"`}, + {"non-ascii is raw", "héllo — 世界 🚀", `"héllo — 世界 🚀"`}, + {"line separators are raw", "\u2028\u2029", "\"\u2028\u2029\""}, + {"empty", "", `""`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var b strings.Builder + writePydanticString(&b, tc.in) + if got := b.String(); got != tc.want { + t.Fatalf("writePydanticString(%q) = %q, want %q", tc.in, got, tc.want) + } + // The two spellings must genuinely DIFFER where Python's do: + // json.dumps escapes every non-ASCII rune, pydantic escapes none. + if tc.name == "non-ascii is raw" { + py := pyfmt.DumpsCompact(tc.in) + if strings.Contains(py, "é") || !strings.Contains(py, `\u00e9`) { + t.Errorf("pyfmt.DumpsCompact = %s; it must ensure_ascii-escape where pydantic does not", py) + } + } + }) + } +} + +// TestPydanticISO pins the datetime spelling model_dump_json uses, and that it +// is NOT schemas.Timestamp.ISOFormat(). +// +// class T(BaseModel): ts: datetime +// T(ts=d).model_dump_json() +func TestPydanticISO(t *testing.T) { + cases := []struct { + name string + in time.Time + want string + iso string + }{ + { + "utc with microseconds", + time.Date(2026, 1, 2, 3, 4, 5, 123456000, time.UTC), + "2026-01-02T03:04:05.123456Z", + "2026-01-02T03:04:05.123456+00:00", + }, + { + "utc whole second", + time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), + "2026-01-02T03:04:05Z", + "2026-01-02T03:04:05+00:00", + }, + { + "utc one microsecond", + time.Date(2026, 1, 2, 3, 4, 5, 1000, time.UTC), + "2026-01-02T03:04:05.000001Z", + "2026-01-02T03:04:05.000001+00:00", + }, + { + "offset zone", + time.Date(2026, 1, 2, 3, 4, 5, 123456000, time.FixedZone("+0530", 5*3600+30*60)), + "2026-01-02T03:04:05.123456+05:30", + "2026-01-02T03:04:05.123456+05:30", + }, + } + 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 got := ts.ISOFormat(); got != tc.iso { + t.Errorf("ISOFormat = %q, want %q", got, tc.iso) + } + }) + } +} + +// TestPyTreeStructOrderAndTags pins the struct walk: declaration order (which +// is pydantic field order), json tag names, `json:"-"` and embedded flattening. +func TestPyTreeStructOrderAndTags(t *testing.T) { + type inner struct { + Deep string `json:"deep"` + } + type Embedded struct { + First int `json:"first"` + } + type sample struct { + Embedded + Zed string `json:"zed"` + Alpha int `json:"alpha"` + Skipped string `json:"-"` + Nested inner `json:"nested"` + NoTag bool + } + + tree := pyTree(sample{ + Embedded: Embedded{First: 1}, + Zed: "z", + Alpha: 2, + Skipped: "never", + Nested: inner{Deep: "d"}, + NoTag: true, + }) + pairs, ok := tree.(pyfmt.Ordered) + if !ok { + t.Fatalf("pyTree(struct) = %T, want pyfmt.Ordered", tree) + } + var keys []string + for _, p := range pairs { + keys = append(keys, p.K) + } + want := "first,zed,alpha,nested,NoTag" + if strings.Join(keys, ",") != want { + t.Fatalf("keys = %v, want %s", keys, want) + } + if got := pydanticDumps(tree); got != `{"first":1,"zed":"z","alpha":2,"nested":{"deep":"d"},"NoTag":true}` { + t.Fatalf("pydanticDumps = %s", got) + } +} + +// TestPyTreeSortsMapKeys pins the documented ordering deviation: a Go map has +// no insertion order, so keys are sorted in BOTH spellings. +func TestPyTreeSortsMapKeys(t *testing.T) { + tree := pyTree(map[string]int{"zebra": 1, "Apple": 2, "apple": 3}) + if got, want := pydanticDumps(tree), `{"Apple":2,"apple":3,"zebra":1}`; got != want { + t.Fatalf("pydanticDumps = %s, want %s", got, want) + } + if got, want := pyfmt.Dumps(tree, 0), `{"Apple": 2, "apple": 3, "zebra": 1}`; got != want { + t.Fatalf("pyfmt.Dumps = %s, want %s", got, want) + } +} + +// TestPyTreeNilsAndPointers pins that nil pointers, nil slices and nil maps all +// render as null (encoding/json semantics, which pyfmt.Dumps shares) and that a +// populated pointer is transparent. +func TestPyTreeNilsAndPointers(t *testing.T) { + type sample struct { + Ptr *string `json:"ptr"` + Slice []int `json:"slice"` + Map map[string]string `json:"map"` + } + if got, want := pydanticDumps(pyTree(sample{})), `{"ptr":null,"slice":null,"map":null}`; got != want { + t.Fatalf("zero value = %s, want %s", got, want) + } + s := "v" + filled := sample{Ptr: &s, Slice: []int{}, Map: map[string]string{}} + if got, want := pydanticDumps(pyTree(filled)), `{"ptr":"v","slice":[],"map":{}}`; got != want { + t.Fatalf("filled = %s, want %s", got, want) + } +} + +// TestPyTreeKeepsIntegersIntegral guards the one lossy path: a typed int stays +// an int in both spellings, and a json.Number survives untouched — which is how +// a caller that decodes `metadata` with UseNumber gets exact parity. +func TestPyTreeKeepsIntegersIntegral(t *testing.T) { + type sample struct { + N int `json:"n"` + U uint8 `json:"u"` + } + if got, want := pydanticDumps(pyTree(sample{N: 7, U: 3})), `{"n":7,"u":3}`; got != want { + t.Fatalf("got %s, want %s", got, want) + } + + dec := json.NewDecoder(strings.NewReader(`{"i":7,"f":1.5,"e":0.00001}`)) + dec.UseNumber() + var doc any + if err := dec.Decode(&doc); err != nil { + t.Fatalf("decode: %v", err) + } + if got, want := pydanticDumps(pyTree(doc)), `{"e":0.00001,"f":1.5,"i":7}`; got != want { + t.Fatalf("json.Number path = %s, want %s", got, want) + } + // The documented divergence: without UseNumber, an untyped integer becomes + // a float64 and renders as "1.0" where Python renders "1". + var lossy any + if err := json.Unmarshal([]byte(`{"i":7}`), &lossy); err != nil { + t.Fatalf("decode: %v", err) + } + if got, want := pydanticDumps(pyTree(lossy)), `{"i":7.0}`; got != want { + t.Fatalf("plain-decode path = %s, want %s (documented divergence)", got, want) + } +} + +// TestPydanticDumpsHasNoWhitespace pins the separators: pydantic writes "," and +// ":" with no spaces, where json.dumps writes ", " and ": ". +func TestPydanticDumpsHasNoWhitespace(t *testing.T) { + tree := pyfmt.Ordered{{K: "a", V: int64(1)}, {K: "b", V: []any{int64(1), int64(2)}}} + if got, want := pydanticDumps(tree), `{"a":1,"b":[1,2]}`; got != want { + t.Fatalf("pydanticDumps = %s, want %s", got, want) + } + if got, want := pyfmt.DumpsCompact(tree), `{"a": 1, "b": [1, 2]}`; got != want { + t.Fatalf("pyfmt.DumpsCompact = %s, want %s", got, want) + } +} + +// TestPyTreeSkipsUnexportedEmbeddedType documents the one gap pyTree shares +// with pyfmt.Dumps and afx.ToMap: an embedded field whose TYPE is unexported is +// SKIPPED rather than flattened. encoding/json promotes its exported fields, but +// reflect refuses to read through an unexported field. No struct in the port has +// one; the test exists so a future one fails loudly here rather than silently +// losing keys from an artifact. +func TestPyTreeSkipsUnexportedEmbeddedType(t *testing.T) { + type hidden struct { + Inner string `json:"inner"` + } + type sample struct { + hidden + Kept string `json:"kept"` + } + + got := pydanticDumps(pyTree(sample{hidden: hidden{Inner: "i"}, Kept: "k"})) + if got != `{"kept":"k"}` { + t.Fatalf("pyTree = %s, want {\"kept\":\"k\"} (the documented gap)", got) + } + // encoding/json disagrees, which is exactly what the doc comment says. + std, _ := json.Marshal(sample{hidden: hidden{Inner: "i"}, Kept: "k"}) + if string(std) != `{"inner":"i","kept":"k"}` { + t.Fatalf("encoding/json = %s; the documented gap description is stale", std) + } +} diff --git a/go/internal/output/report.go b/go/internal/output/report.go new file mode 100644 index 0000000..721bb84 --- /dev/null +++ b/go/internal/output/report.go @@ -0,0 +1,241 @@ +package output + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// This file ports src/cloudsecurity_af/output/report.py — the Markdown report. +// +// Python builds a []string of lines and returns "\n".join(lines), so the +// document has NO trailing newline. Every f-string below is transcribed +// verbatim, including the em dashes ("—", U+2014) in the attack-path step and +// blast-radius lines. +// +// Float spellings: Python's `:.1f` / `:.2f` / `:.4f` and Go's `%.1f` / `%.2f` / +// `%.4f` both round half-to-even against the exact binary value, so they agree +// digit for digit. (They differ only for ±Inf and NaN — "inf"/"nan" in Python, +// "+Inf"/"NaN" in Go — which no scan can produce for these fields.) + +// GenerateReport ports generate_report. +func GenerateReport(result schemas.CloudSecurityScanResult) string { + lines := []string{ + "# CloudSecurity AF Infrastructure Security 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 Paths", "") + if len(result.AttackPaths) > 0 { + for _, path := range result.AttackPaths { + lines = append(lines, renderAttackPath(path)...) + } + } else { + lines = append(lines, "No multi-resource attack paths identified.", "") + } + + if result.DriftResources > 0 || result.ShadowITResources > 0 { + lines = append(lines, + "## Drift Summary", + "", + fmt.Sprintf("- Drifted resources: **%d**", result.DriftResources), + fmt.Sprintf("- Shadow IT (cloud-only) resources: **%d**", result.ShadowITResources), + "", + ) + } + + if len(result.ComplianceFrameworksChecked) > 0 { + lines = append(lines, + "## Compliance", + "", + "- Frameworks checked: "+strings.Join(result.ComplianceFrameworksChecked, ", "), + "", + ) + } + + lines = append(lines, + "## Performance & Cost", + "", + fmt.Sprintf("- Duration: %.1fs", result.DurationSeconds), + fmt.Sprintf("- Agent invocations: %d", result.AgentInvocations), + fmt.Sprintf("- Cost: $%.4f", result.CostUSD), + "- Cost breakdown:", + ) + if len(result.CostBreakdown) > 0 { + // Python parity: `for phase, cost in result.cost_breakdown.items()` + // walks the dict in INSERTION order, and orchestrator.py seeds it from + // _PHASE_ORDER — so the report always reads recon, hunt, chain, prove, + // remediate, never the alphabetical chain, hunt, prove, recon, + // remediate a Go map's sorted keys produce. + for _, phase := range orderedPhaseKeys(result.CostBreakdown) { + lines = append(lines, fmt.Sprintf(" - %s: $%.4f", phase, result.CostBreakdown[phase])) + } + } else { + lines = append(lines, " - n/a") + } + lines = append(lines, "") + + return strings.Join(lines, "\n") +} + +// RenderReport ports render_report, the alias generate_report is exported under. +func RenderReport(result schemas.CloudSecurityScanResult) string { + return GenerateReport(result) +} + +// --------------------------------------------------------------------------- +// Section renderers +// --------------------------------------------------------------------------- + +// renderSummary ports _render_summary. +func renderSummary(result schemas.CloudSecurityScanResult) []string { + // Python parity: `f"- Branch: \`{branch}\`" if result.branch else "- Branch: n/a"` + // — `branch` is `str | None`, so BOTH None and "" take the n/a arm. + branch := "- Branch: n/a" + if result.Branch != nil && *result.Branch != "" { + branch = "- Branch: `" + *result.Branch + "`" + } + + // Python parity: `'static' if tier == 1 else 'live' if tier == 2 else 'deep'` + // — every tier that is neither 1 nor 2 (including 0 and 4) reads "deep". + tierName := "deep" + switch result.Tier { + case 1: + tierName = "static" + case 2: + tierName = "live" + } + + // Python parity: `', '.join(providers) or 'none detected'` — an empty list + // joins to "", which is falsy. + providers := strings.Join(result.ProvidersDetected, ", ") + if providers == "" { + providers = "none detected" + } + + return []string{ + "## Summary", + "", + "- Repository: `" + result.Repository + "`", + "- Commit: `" + result.CommitSHA + "`", + branch, + "- Timestamp: `" + result.Timestamp.ISOFormat() + "`", + "- Depth profile: `" + result.DepthProfile + "`", + fmt.Sprintf("- Tier: **%d** (%s)", result.Tier, tierName), + "- Providers: " + providers, + fmt.Sprintf("- Resources scanned: **%d**", result.TotalResourcesScanned), + 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. Every optional line is guarded by +// Python's truthiness of the corresponding field: an empty string, an empty +// list and None are all skipped, while a model instance is always truthy. +func renderFinding(finding schemas.VerifiedFinding) []string { + lines := []string{ + "### " + finding.Title, + "", + "- ID: `" + finding.ID + "`", + "- Verdict: `" + string(finding.Verdict) + "` | Severity: `" + string(finding.Severity) + "`", + fmt.Sprintf("- Risk score: **%.2f/10**", finding.RiskScore), + "- Category: `" + finding.Category + "` | Hunter: `" + finding.HunterStrategy + "`", + "- Location: `" + finding.IaCFile + ":" + strconv.Itoa(finding.IaCLine) + "`", + } + if finding.Description != "" { + lines = append(lines, "- Description: "+finding.Description) + } + if finding.AttackPath != nil { + lines = append(lines, "- Attack path: **"+finding.AttackPath.Title+"**") + } + if finding.Drift != nil { + lines = append(lines, "- Drift detected: `"+finding.Drift.ResourceID+"` ("+finding.Drift.Significance+")") + } + if len(finding.ComplianceMappings) > 0 { + lines = append(lines, "- Compliance: "+strings.Join(finding.ComplianceMappings, ", ")) + } + if finding.Remediation != nil { + lines = append(lines, "- Remediation: "+finding.Remediation.Description) + if finding.Remediation.BreakingChange { + lines = append(lines, " - **WARNING: Breaking change**") + } + // Python parity: `if finding.remediation.downtime_estimate:` — a `str | + // None` field, so an EMPTY string is skipped too. + if d := finding.Remediation.DowntimeEstimate; d != nil && *d != "" { + lines = append(lines, " - Downtime: "+*d) + } + } + if finding.ConfigSnippet != "" { + lines = append(lines, "", "```hcl", finding.ConfigSnippet, "```") + } + return append(lines, "") +} + +// renderAttackPath ports _render_attack_path. +func renderAttackPath(path schemas.AttackPath) []string { + quoted := make([]string, 0, len(path.FindingsInvolved)) + for _, fid := range path.FindingsInvolved { + quoted = append(quoted, "`"+fid+"`") + } + + lines := []string{ + "### " + path.Title, + "", + "- ID: `" + path.ID + "`", + "- Combined severity: `" + string(path.CombinedSeverity) + "`", + "- Entry: `" + path.EntryPoint + "` → Target: `" + path.Target + "`", + "- Findings involved: " + strings.Join(quoted, ", "), + "- Steps:", + } + for _, step := range path.Steps { + // Python parity: the two adjacent f-strings concatenate into one line. + lines = append(lines, fmt.Sprintf(" %d. `%s` (%s) — %s via `%s`", + step.StepNumber, step.ResourceID, step.ResourceType, step.Action, step.PermissionUsed)) + } + if len(path.BlastRadius.DataStoresReachable) > 0 { + lines = append(lines, "- Blast radius — data stores: "+ + strings.Join(path.BlastRadius.DataStoresReachable, ", ")) + } + if len(path.BlastRadius.ComputeReachable) > 0 { + lines = append(lines, "- Blast radius — compute: "+ + strings.Join(path.BlastRadius.ComputeReachable, ", ")) + } + return append(lines, "") +} + +// orderedPhaseKeys returns a cost_breakdown map's keys in Python's INSERTION +// order (schemas.CostBreakdownOrder), followed by any key outside that set in +// code-point order — a tail the live path cannot produce, kept deterministic. +func orderedPhaseKeys(m map[string]float64) []string { + out := make([]string, 0, len(m)) + seen := make(map[string]bool, len(schemas.CostBreakdownOrder)) + for _, phase := range schemas.CostBreakdownOrder { + seen[phase] = true + if _, present := m[phase]; present { + out = append(out, phase) + } + } + rest := make([]string, 0, len(m)) + for k := range m { + if !seen[k] { + rest = append(rest, k) + } + } + sort.Strings(rest) + return append(out, rest...) +} diff --git a/go/internal/output/report_test.go b/go/internal/output/report_test.go new file mode 100644 index 0000000..0677cb2 --- /dev/null +++ b/go/internal/output/report_test.go @@ -0,0 +1,403 @@ +package output + +import ( + "math" + "strings" + "testing" + "time" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// Behaviour tests for output/report.py, derived from the Python source's +// observable behaviour. The byte-level agreement is golden_test.go's job; this +// file pins the truthiness guards and the numeric formats one at a time. + +// reportOf renders a result and splits it into lines for assertion. +func reportOf(result schemas.CloudSecurityScanResult) []string { + return strings.Split(GenerateReport(result), "\n") +} + +func containsLine(lines []string, want string) bool { + for _, line := range lines { + if line == want { + return true + } + } + return false +} + +func lineWithPrefix(t *testing.T, lines []string, prefix string) string { + t.Helper() + for _, line := range lines { + if strings.HasPrefix(line, prefix) { + return line + } + } + t.Fatalf("no line starting with %q in:\n%s", prefix, strings.Join(lines, "\n")) + return "" +} + +// TestReportHasNoTrailingNewline pins that Python returns "\n".join(lines) — +// the last line is the empty string the cost section appends, so the document +// ends with exactly one newline and no more. +func TestReportHasNoTrailingNewline(t *testing.T) { + got := GenerateReport(schemas.NewCloudSecurityScanResult()) + if strings.HasSuffix(got, "\n\n") { + t.Error("report must not end with a blank line plus a newline") + } + if !strings.HasSuffix(got, "\n") { + t.Error("report's last joined element is \"\", so it must end with a newline") + } + if !strings.HasPrefix(got, "# CloudSecurity AF Infrastructure Security Report\n\n## Summary\n") { + t.Errorf("unexpected header:\n%s", got[:80]) + } +} + +// TestReportBranchFalsyArms pins `f"- Branch: ..." if result.branch else +// "- Branch: n/a"`: `branch` is `str | None`, so BOTH None and "" take the n/a +// arm — an empty string is falsy in Python but not nil in Go. +func TestReportBranchFalsyArms(t *testing.T) { + result := schemas.NewCloudSecurityScanResult() + + if got := lineWithPrefix(t, reportOf(result), "- Branch:"); got != "- Branch: n/a" { + t.Errorf("nil branch -> %q", got) + } + + empty := "" + result.Branch = &empty + if got := lineWithPrefix(t, reportOf(result), "- Branch:"); got != "- Branch: n/a" { + t.Errorf("empty branch -> %q, want the n/a arm (Python truthiness)", got) + } + + main := "main" + result.Branch = &main + if got := lineWithPrefix(t, reportOf(result), "- Branch:"); got != "- Branch: `main`" { + t.Errorf("populated branch -> %q", got) + } +} + +// TestReportTierNaming pins `'static' if tier == 1 else 'live' if tier == 2 +// else 'deep'` — every other tier, including 0 and 9, reads "deep". +func TestReportTierNaming(t *testing.T) { + for tier, want := range map[int]string{ + 0: "- Tier: **0** (deep)", + 1: "- Tier: **1** (static)", + 2: "- Tier: **2** (live)", + 3: "- Tier: **3** (deep)", + 9: "- Tier: **9** (deep)", + } { + result := schemas.NewCloudSecurityScanResult() + result.Tier = tier + if got := lineWithPrefix(t, reportOf(result), "- Tier:"); got != want { + t.Errorf("tier %d -> %q, want %q", tier, got, want) + } + } +} + +// TestReportProvidersFallback pins `', '.join(providers) or 'none detected'`. +func TestReportProvidersFallback(t *testing.T) { + result := schemas.NewCloudSecurityScanResult() + if got := lineWithPrefix(t, reportOf(result), "- Providers:"); got != "- Providers: none detected" { + t.Errorf("empty providers -> %q", got) + } + result.ProvidersDetected = []string{"aws", "gcp"} + if got := lineWithPrefix(t, reportOf(result), "- Providers:"); got != "- Providers: aws, gcp" { + t.Errorf("populated providers -> %q", got) + } +} + +// TestReportEmptySections pins the two "nothing to report" arms and that the +// conditional Drift/Compliance sections are absent when their guards are false. +func TestReportEmptySections(t *testing.T) { + lines := reportOf(schemas.NewCloudSecurityScanResult()) + for _, want := range []string{"No findings.", "No multi-resource attack paths identified.", " - n/a"} { + if !containsLine(lines, want) { + t.Errorf("missing %q", want) + } + } + for _, unwanted := range []string{"## Drift Summary", "## Compliance"} { + if containsLine(lines, unwanted) { + t.Errorf("%q must be omitted when its guard is false", unwanted) + } + } +} + +// TestReportDriftSectionGuard pins `if drift_resources > 0 or +// shadow_it_resources > 0` — EITHER counter alone brings the section in, and +// both numbers are always printed. +func TestReportDriftSectionGuard(t *testing.T) { + cases := []struct{ drift, shadow int }{{1, 0}, {0, 1}, {2, 3}} + for _, tc := range cases { + result := schemas.NewCloudSecurityScanResult() + result.DriftResources = tc.drift + result.ShadowITResources = tc.shadow + lines := reportOf(result) + if !containsLine(lines, "## Drift Summary") { + t.Fatalf("drift=%d shadow=%d: section missing", tc.drift, tc.shadow) + } + if !containsLine(lines, "- Drifted resources: **"+itoaTest(tc.drift)+"**") { + t.Errorf("drift=%d: count line missing", tc.drift) + } + if !containsLine(lines, "- Shadow IT (cloud-only) resources: **"+itoaTest(tc.shadow)+"**") { + t.Errorf("shadow=%d: count line missing", tc.shadow) + } + } +} + +func itoaTest(n int) string { + if n == 0 { + return "0" + } + digits := "" + for n > 0 { + digits = string(rune('0'+n%10)) + digits + n /= 10 + } + return digits +} + +// TestReportNumberFormats pins the four format specs against CPython ground +// truth (verified with the venv: f"{v:.1f}" / f"{v:.2f}" / f"{v:.4f}"). +// Python and Go both round half-to-even on the exact binary value, so a tie +// like 0.25 -> "0.2" must come out the same on both sides. +func TestReportNumberFormats(t *testing.T) { + result := schemas.NewCloudSecurityScanResult() + result.NoiseReductionPct = 0.25 // f"{0.25:.1f}" == "0.2" + result.DurationSeconds = 0.35 // f"{0.35:.1f}" == "0.3" + result.CostUSD = 0.00005 // f"{0.00005:.4f}" == "0.0001" + result.AgentInvocations = 7 + // Go parity note: the untyped constant -0.0 is POSITIVE zero, so negative + // zero has to be minted with Copysign — the fixture gets it from JSON. + result.CostBreakdown = map[string]float64{"prove": 1e16, "recon": math.Copysign(0, -1)} + + lines := reportOf(result) + for _, want := range []string{ + "- Noise reduction: **0.2%**", + "- Duration: 0.3s", + "- Agent invocations: 7", + "- Cost: $0.0001", + " - prove: $10000000000000000.0000", + " - recon: $-0.0000", + } { + if !containsLine(lines, want) { + t.Errorf("missing %q in:\n%s", want, strings.Join(lines, "\n")) + } + } +} + +// TestReportCostBreakdownUsesThePipelineOrder pins the Markdown report's phase +// order against Python's. +// +// orchestrator.py seeds `self.cost_breakdown = {phase: 0.0 for phase in +// self._PHASE_ORDER}` with `_PHASE_ORDER = ("recon","hunt","chain","prove", +// "remediate")` and only ever mutates existing keys, so +// `for phase, cost in result.cost_breakdown.items()` (report.py:67-69) always +// reads recon, hunt, chain, prove, remediate. Sorting a Go map instead printed +// chain, hunt, prove, recon, remediate. +func TestReportCostBreakdownUsesThePipelineOrder(t *testing.T) { + result := schemas.NewCloudSecurityScanResult() + // Built in a deliberately scrambled Go map — the order must come from + // _PHASE_ORDER, not from the literal and not from sorting. + result.CostBreakdown = map[string]float64{ + "prove": 0.4, "recon": 0.1, "remediate": 0.5, "chain": 0.3, "hunt": 0.2, + } + if got := strings.Join(costPhases(result), ","); got != "recon,hunt,chain,prove,remediate" { + t.Fatalf("cost breakdown order = %s, want recon,hunt,chain,prove,remediate", got) + } + + // A subset keeps its relative pipeline order. + result.CostBreakdown = map[string]float64{"prove": 0.4, "recon": 0.1} + if got := strings.Join(costPhases(result), ","); got != "recon,prove" { + t.Fatalf("cost breakdown order = %s, want recon,prove", got) + } + + // A key outside _PHASE_ORDER is unreachable on the live path; it lands + // after the known phases, in code-point order, so the report stays + // deterministic. + result.CostBreakdown = map[string]float64{"zzz": 0.1, "prove": 0.4, "aaa": 0.2} + if got := strings.Join(costPhases(result), ","); got != "prove,aaa,zzz" { + t.Fatalf("cost breakdown order = %s, want prove,aaa,zzz", got) + } +} + +// costPhases reads the phase names out of the report's cost-breakdown bullets. +func costPhases(result schemas.CloudSecurityScanResult) []string { + var seen []string + for _, line := range reportOf(result) { + if strings.HasPrefix(line, " - ") && strings.Contains(line, "$") { + seen = append(seen, strings.TrimPrefix(strings.Split(line, ":")[0], " - ")) + } + } + return seen +} + +// TestReportFindingOptionalLines pins every truthiness guard in +// _render_finding, one at a time. +func TestReportFindingOptionalLines(t *testing.T) { + base := schemas.NewVerifiedFinding() + base.ID = "f1" + base.Title = "Open bucket" + base.Verdict = schemas.VerdictConfirmed + base.Severity = scoring.SeverityHigh + base.Category = "public_exposure" + base.HunterStrategy = "data" + base.IaCFile = "s3.tf" + base.IaCLine = 4 + // Verified against the venv: f"{8.005:.2f}" == "8.01" (8.005 is just ABOVE + // the tie in binary), and Go's %.2f agrees. f"{2.675:.2f}" == "2.67" and + // f"{0.125:.2f}" == "0.12" likewise match, so the two round identically. + base.RiskScore = 8.005 + + result := schemas.NewCloudSecurityScanResult() + result.Findings = []schemas.VerifiedFinding{base} + lines := reportOf(result) + + for _, want := range []string{ + "### Open bucket", + "- ID: `f1`", + "- Verdict: `confirmed` | Severity: `high`", + "- Risk score: **8.01/10**", + "- Category: `public_exposure` | Hunter: `data`", + "- Location: `s3.tf:4`", + } { + if !containsLine(lines, want) { + t.Errorf("missing %q", want) + } + } + for _, unwanted := range []string{"- Description: ", "- Attack path: ", "- Drift detected: ", "- Compliance: ", "- Remediation: ", "```hcl"} { + for _, line := range lines { + if strings.HasPrefix(line, unwanted) { + t.Errorf("bare finding must not emit %q (got %q)", unwanted, line) + } + } + } + + // Now switch every optional on. + path := schemas.NewAttackPath() + path.Title = "ALB to bucket" + drift := schemas.DriftedResource{ResourceID: "aws_s3_bucket.b", Significance: "critical"} + downtime := "minutes" + full := base + full.Description = "acl is public-read" + full.AttackPath = &path + full.Drift = &drift + full.ComplianceMappings = []string{"CIS-AWS-2.1.1", "SOC2"} + full.Remediation = &schemas.RemediationSuggestion{ + Description: "Set acl = private", + BreakingChange: true, + DowntimeEstimate: &downtime, + } + full.ConfigSnippet = "acl = \"public-read\"" + + result.Findings = []schemas.VerifiedFinding{full} + lines = reportOf(result) + for _, want := range []string{ + "- Description: acl is public-read", + "- Attack path: **ALB to bucket**", + "- Drift detected: `aws_s3_bucket.b` (critical)", + "- Compliance: CIS-AWS-2.1.1, SOC2", + "- Remediation: Set acl = private", + " - **WARNING: Breaking change**", + " - Downtime: minutes", + "```hcl", + "acl = \"public-read\"", + "```", + } { + if !containsLine(lines, want) { + t.Errorf("missing %q in:\n%s", want, strings.Join(lines, "\n")) + } + } +} + +// TestReportEmptyDowntimeIsSuppressed pins that `downtime_estimate` is a +// `str | None` guarded by truthiness: an EMPTY string is skipped, exactly like +// None. +func TestReportEmptyDowntimeIsSuppressed(t *testing.T) { + empty := "" + f := schemas.NewVerifiedFinding() + f.Title = "T" + f.Verdict = schemas.VerdictLikely + f.Severity = scoring.SeverityLow + f.Remediation = &schemas.RemediationSuggestion{Description: "d", DowntimeEstimate: &empty} + + result := schemas.NewCloudSecurityScanResult() + result.Findings = []schemas.VerifiedFinding{f} + for _, line := range reportOf(result) { + if strings.HasPrefix(line, " - Downtime:") { + t.Fatalf("an empty downtime_estimate must be suppressed, got %q", line) + } + } +} + +// TestReportAttackPathRendering pins _render_attack_path, including the two +// blast-radius guards and the em-dashed step line built from two adjacent +// f-strings. +func TestReportAttackPathRendering(t *testing.T) { + path := schemas.NewAttackPath() + path.ID = "p1" + path.Title = "ALB to bucket" + path.CombinedSeverity = scoring.SeverityCritical + path.EntryPoint = "aws_lb.public" + path.Target = "aws_s3_bucket.pii" + path.FindingsInvolved = []string{"f1", "f2"} + path.Steps = []schemas.AttackStep{ + {StepNumber: 1, ResourceID: "aws_lb.public", ResourceType: "aws_lb", Action: "Reach it", PermissionUsed: "0.0.0.0/0"}, + } + + result := schemas.NewCloudSecurityScanResult() + result.AttackPaths = []schemas.AttackPath{path} + lines := reportOf(result) + + for _, want := range []string{ + "### ALB to bucket", + "- ID: `p1`", + "- Combined severity: `critical`", + "- Entry: `aws_lb.public` → Target: `aws_s3_bucket.pii`", + "- Findings involved: `f1`, `f2`", + "- Steps:", + " 1. `aws_lb.public` (aws_lb) — Reach it via `0.0.0.0/0`", + } { + if !containsLine(lines, want) { + t.Errorf("missing %q in:\n%s", want, strings.Join(lines, "\n")) + } + } + for _, unwanted := range []string{"- Blast radius — data stores:", "- Blast radius — compute:"} { + for _, line := range lines { + if strings.HasPrefix(line, unwanted) { + t.Errorf("empty blast radius must not emit %q", unwanted) + } + } + } + + path.BlastRadius.DataStoresReachable = []string{"s3://a", "s3://b"} + path.BlastRadius.ComputeReachable = []string{"ecs/api"} + result.AttackPaths = []schemas.AttackPath{path} + lines = reportOf(result) + if !containsLine(lines, "- Blast radius — data stores: s3://a, s3://b") { + t.Error("missing the data-stores blast radius line") + } + if !containsLine(lines, "- Blast radius — compute: ecs/api") { + t.Error("missing the compute blast radius line") + } +} + +// TestReportTimestampUsesIsoformat pins that the summary interpolates +// `result.timestamp.isoformat()`, i.e. the "+00:00" spelling. +func TestReportTimestampUsesIsoformat(t *testing.T) { + result := schemas.NewCloudSecurityScanResult() + result.Timestamp = schemas.NewTimestamp(time.Date(2026, 5, 6, 7, 8, 9, 123456000, time.UTC)) + want := "- Timestamp: `2026-05-06T07:08:09.123456+00:00`" + if got := lineWithPrefix(t, reportOf(result), "- Timestamp:"); got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +// TestRenderReportIsAnAlias pins render_report == generate_report. +func TestRenderReportIsAnAlias(t *testing.T) { + result := schemas.NewCloudSecurityScanResult() + if RenderReport(result) != GenerateReport(result) { + t.Fatal("render_report must return exactly what generate_report returns") + } +} diff --git a/go/internal/output/sarif.go b/go/internal/output/sarif.go new file mode 100644 index 0000000..3effa1f --- /dev/null +++ b/go/internal/output/sarif.go @@ -0,0 +1,404 @@ +// Package output ports src/cloudsecurity_af/output: the three artifact +// generators a CloudSecurity scan can emit — the SARIF 2.1.0 document, the +// full/summary JSON, and the Markdown report. +// +// Only generate_sarif is wired into the live pipeline +// (orchestrator.py:211 `result.sarif = generate_sarif(result)`); the rest are +// ported for 1:1 completeness, since the Python module exports them. +// +// Every generator's bytes are a contract with a third party — a SARIF uploader, +// a diffing reviewer, an API client — so golden_test.go compares each one +// byte-for-byte against the Python original over a shared fixture. That is why +// nothing here uses encoding/json: JSON goes through pyfmt.Dumps (CPython's +// json.dumps) or pydanticDumps (pydantic's model_dump_json), and every float +// through pyfmt/pydantic float formatting. +package output + +import ( + "fmt" + "sort" + "strings" + "unicode" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// obj is a Python dict literal: an ordered key/value sequence, rendered in +// insertion order by pyfmt.Dumps. Key order is part of every artifact this +// package writes, so no generator may build a Go map for a JSON object whose +// Python spelling is a literal. +type obj = pyfmt.Ordered + +// pythonPackageVersion mirrors `src/cloudsecurity_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 CloudSecurity scan result, serialised with +// `json.dumps(sarif, indent=2)`. +// +// Findings whose verdict is "not_exploitable" are dropped entirely — from the +// results AND from the rules — which is what makes the SARIF artifact the +// "signal only" view of a scan. +// +// The key order below is the Python dict literal's order and is part of the +// artifact, hence obj rather than a map. +func GenerateSarif(result schemas.CloudSecurityScanResult) string { + included := make([]schemas.VerifiedFinding, 0, len(result.Findings)) + for _, finding := range result.Findings { + if finding.Verdict != schemas.VerdictNotExploitable { + included = append(included, finding) + } + } + + results := make([]any, 0, len(included)) + for _, finding := range included { + results = append(results, buildResult(finding)) + } + + sarif := obj{ + {K: "$schema", V: "https://json.schemastore.org/sarif-2.1.0.json"}, + {K: "version", V: "2.1.0"}, + {K: "runs", V: []any{ + obj{ + {K: "tool", V: buildToolSection(included)}, + {K: "results", V: results}, + {K: "automationDetails", V: obj{ + {K: "id", V: fmt.Sprintf("cloudsecurity-af/scan/%s/%s", + result.Repository, result.Timestamp.ISOFormat())}, + }}, + }, + }}, + } + return pyfmt.Dumps(sarif, 2) +} + +// RenderSarif ports render_sarif, the alias generate_sarif is exported under. +func RenderSarif(result schemas.CloudSecurityScanResult) string { + return GenerateSarif(result) +} + +// --------------------------------------------------------------------------- +// Tool / Rules +// --------------------------------------------------------------------------- + +// buildToolSection ports _build_tool_section: one rule per distinct rule id, in +// sorted rule-id order (Python's `sorted(rules_by_id.items())`). +// +// Python parity: the rule id falls back to +// "cloudsecurity//" whenever sarif_rule_id is empty, +// and the SAME expression is recomputed in _build_result — so a finding always +// lands under the rule it declares. +func buildToolSection(findings []schemas.VerifiedFinding) obj { + rulesByID := map[string][]schemas.VerifiedFinding{} + for _, finding := range findings { + id := sarifRuleID(finding) + rulesByID[id] = append(rulesByID[id], finding) + } + ruleIDs := make([]string, 0, len(rulesByID)) + for ruleID := range rulesByID { + ruleIDs = append(ruleIDs, ruleID) + } + // sorted() on (key, value) tuples compares the keys first, and the keys are + // unique, so this is a plain key sort. Go's byte order over valid UTF-8 is + // Python's code-point order. + sort.Strings(ruleIDs) + + rules := make([]any, 0, len(ruleIDs)) + for _, ruleID := range ruleIDs { + rules = append(rules, buildRule(ruleID, rulesByID[ruleID])) + } + + return obj{ + {K: "driver", V: obj{ + {K: "name", V: "CloudSecurity AF"}, + {K: "semanticVersion", V: pythonPackageVersion}, + {K: "informationUri", V: "https://github.com/Agent-Field/cloudsecurity-af"}, + {K: "rules", V: rules}, + }}, + } +} + +// buildRule ports _build_rule. The FIRST finding carrying the rule id supplies +// the human-readable text; level, security-severity, precision and tags are +// aggregated over every finding that shares the id. +func buildRule(ruleID string, findings []schemas.VerifiedFinding) obj { + representative := findings[0] + + // max(f.sarif_security_severity for f in findings) + maxScore := findings[0].SARIFSecuritySeverity + for _, finding := range findings[1:] { + if finding.SARIFSecuritySeverity > maxScore { + maxScore = finding.SARIFSecuritySeverity + } + } + + // Python parity: `representative.description or representative.title` — + // an empty description falls back to the title. + full := representative.Description + if full == "" { + full = representative.Title + } + + return obj{ + {K: "id", V: ruleID}, + {K: "name", V: ruleName(ruleID)}, + // Python parity: f"{representative.title}" is just the title. + {K: "shortDescription", V: obj{{K: "text", V: representative.Title}}}, + {K: "fullDescription", V: obj{{K: "text", V: full}}}, + {K: "defaultConfiguration", V: obj{{K: "level", V: maxLevel(findings)}}}, + {K: "properties", V: obj{ + {K: "precision", V: maxPrecision(findings)}, + {K: "security-severity", V: formatSecuritySeverity(maxScore)}, + {K: "tags", V: aggregateRuleTags(findings)}, + }}, + } +} + +// --------------------------------------------------------------------------- +// Results +// --------------------------------------------------------------------------- + +// buildResult ports _build_result: one SARIF result per included finding. +func buildResult(finding schemas.VerifiedFinding) obj { + properties := obj{ + {K: "security-severity", V: formatSecuritySeverity(finding.SARIFSecuritySeverity)}, + {K: "cloudsecurity/verdict", V: string(finding.Verdict)}, + {K: "cloudsecurity/risk_score", V: finding.RiskScore}, + {K: "cloudsecurity/hunter_strategy", V: finding.HunterStrategy}, + {K: "cloudsecurity/category", V: finding.Category}, + {K: "cloudsecurity/compliance", V: finding.ComplianceMappings}, + {K: "tags", V: resultTags(finding)}, + } + if finding.AttackPath != nil { + // Python parity: this key is ASSIGNED after the literal is built, so it + // lands LAST in the properties object — after "tags", not next to the + // other cloudsecurity/* keys. + properties = append(properties, pyfmt.KV{ + K: "cloudsecurity/attack_path", V: finding.AttackPath.Title, + }) + } + + return obj{ + {K: "ruleId", V: sarifRuleID(finding)}, + {K: "level", V: severityToLevelOf(string(finding.Severity))}, + {K: "message", V: obj{{K: "text", V: messageText(finding)}}}, + {K: "locations", V: []any{obj{{K: "physicalLocation", V: physicalLocation(finding)}}}}, + {K: "partialFingerprints", V: obj{ + {K: "primaryLocationLineHash", V: finding.Fingerprint}, + }}, + {K: "properties", V: properties}, + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// sarifRuleID ports the `finding.sarif_rule_id or f"cloudsecurity/{...}/{...}"` +// expression that _build_tool_section and _build_result each spell out. +func sarifRuleID(finding schemas.VerifiedFinding) string { + if finding.SARIFRuleID != "" { + return finding.SARIFRuleID + } + return "cloudsecurity/" + finding.HunterStrategy + "/" + finding.Category +} + +// messageText ports _message_text: "[VERDICT] title: description", with the +// title standing in for an empty description. +func messageText(finding schemas.VerifiedFinding) string { + desc := finding.Description + if desc == "" { + desc = finding.Title + } + return "[" + strings.ToUpper(string(finding.Verdict)) + "] " + finding.Title + ": " + desc +} + +// physicalLocation ports _physical_location. The snippet sub-object appears +// only for a finding that carries one, and iac_line is floored at 1 because +// SARIF has no line 0. +func physicalLocation(finding schemas.VerifiedFinding) obj { + startLine := finding.IaCLine + if startLine < 1 { + startLine = 1 + } + region := obj{{K: "startLine", V: startLine}} + if finding.ConfigSnippet != "" { + region = append(region, pyfmt.KV{ + K: "snippet", V: obj{{K: "text", V: finding.ConfigSnippet}}, + }) + } + + uri := finding.IaCFile + if uri == "" { + uri = "unknown" + } + return obj{ + {K: "artifactLocation", V: obj{ + {K: "uri", V: uri}, + {K: "uriBaseId", V: "%SRCROOT%"}, + }}, + {K: "region", V: region}, + } +} + +// severityToLevelOf ports _severity_to_level: an unknown severity is "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-max-wins semantics. +func maxPrecision(findings []schemas.VerifiedFinding) string { + best := precisionOf(findings[0]) + for _, finding := range findings[1:] { + p := precisionOf(finding) + if precisionRank[p] > precisionRank[best] { + best = p + } + } + return best +} + +// precisionOf ports `_VERDICT_TO_PRECISION.get(f.verdict.value, "medium")`. +func precisionOf(finding schemas.VerifiedFinding) string { + if p, ok := verdictToPrecision[string(finding.Verdict)]; ok { + return p + } + return "medium" +} + +// formatSecuritySeverity ports _format_security_severity: the score clamped to +// [0, 10] and rendered with one decimal. Go's %.1f and Python's :.1f both round +// half-to-even on the exact binary value, so the two 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) +} + +// aggregateRuleTags ports _aggregate_rule_tags: the sorted union of every +// finding's base tags. +func aggregateRuleTags(findings []schemas.VerifiedFinding) []string { + seen := map[string]struct{}{} + for _, finding := range findings { + for _, tag := range baseTags(finding) { + seen[tag] = struct{}{} + } + } + return sortedKeys(seen) +} + +// resultTags ports _result_tags: one finding's base tags, deduplicated and +// sorted. +func resultTags(finding schemas.VerifiedFinding) []string { + seen := map[string]struct{}{} + for _, tag := range baseTags(finding) { + seen[tag] = struct{}{} + } + return sortedKeys(seen) +} + +// baseTags ports _base_tags. The order here does not survive (both callers +// sort), but it is kept identical to Python so a future caller that does not +// sort behaves the same. +func baseTags(finding schemas.VerifiedFinding) []string { + tags := []string{"security", "infrastructure", finding.Category, finding.HunterStrategy} + for _, mapping := range finding.ComplianceMappings { + tags = append(tags, "compliance:"+mapping) + } + return tags +} + +// sortedKeys ports `sorted(set(...))`: a deterministic, code-point-ordered list. +// It always returns a non-nil slice so an empty tag set renders as [] rather +// than null. +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 +} + +// ruleName ports _rule_name: the last "/"-separated segment of the rule id, +// with "_" folded to "-", split on "-", each non-empty chunk capitalized and +// concatenated — "cloudsecurity/iam/public_bucket" becomes "PublicBucket" — +// falling back to "CloudSecurityRule" when that yields nothing. +func ruleName(ruleID string) string { + segments := strings.Split(ruleID, "/") + rawName := strings.ReplaceAll(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 "CloudSecurityRule" + } + return b.String() +} + +// pyCapitalize ports Python's str.capitalize(): the first character is +// upper-cased and EVERY other character is lower-cased ("iamROLE" -> "Iamrole"). +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) +} diff --git a/go/internal/output/sarif_test.go b/go/internal/output/sarif_test.go new file mode 100644 index 0000000..b42e358 --- /dev/null +++ b/go/internal/output/sarif_test.go @@ -0,0 +1,423 @@ +package output + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// Behaviour tests for output/sarif.py. src/cloudsecurity_af has no Python test +// file for the output package, so every assertion here is derived from the +// Python SOURCE's observable behaviour (the validation contract), not from the +// Go implementation. The byte-level agreement with Python is golden_test.go's +// job; this file pins the branches a fixture cannot reach and states them in +// terms a reader can check against sarif.py line by line. + +// finding is a terse VerifiedFinding builder for the tables below. +func finding(mut func(*schemas.VerifiedFinding)) schemas.VerifiedFinding { + f := schemas.VerifiedFinding{ + Title: "T", + Verdict: schemas.VerdictConfirmed, + Severity: scoring.SeverityMedium, + Category: "cat", + ComplianceMappings: []string{}, + Proof: schemas.NewProof(), + HunterStrategy: "hunter", + } + if mut != nil { + mut(&f) + } + return f +} + +// resultOf wraps findings in a scan result with a fixed timestamp. +func resultOf(findings ...schemas.VerifiedFinding) schemas.CloudSecurityScanResult { + r := schemas.NewCloudSecurityScanResult() + r.Repository = "repo" + r.Findings = findings + return r +} + +// parseSarif decodes GenerateSarif's output so a test can assert structure +// without hand-matching 6KB of text. +func parseSarif(t *testing.T, result schemas.CloudSecurityScanResult) map[string]any { + t.Helper() + var doc map[string]any + if err := json.Unmarshal([]byte(GenerateSarif(result)), &doc); err != nil { + t.Fatalf("GenerateSarif produced invalid JSON: %v", err) + } + return doc +} + +func runOf(t *testing.T, doc map[string]any) map[string]any { + t.Helper() + runs, ok := doc["runs"].([]any) + if !ok || len(runs) != 1 { + t.Fatalf("expected exactly one run, got %#v", doc["runs"]) + } + run, _ := runs[0].(map[string]any) + return run +} + +// TestSarifDropsNotExploitable pins the noise reduction: a not_exploitable +// finding appears in neither the results NOR the rules. +func TestSarifDropsNotExploitable(t *testing.T) { + kept := finding(func(f *schemas.VerifiedFinding) { + f.SARIFRuleID = "rule/kept" + }) + dropped := finding(func(f *schemas.VerifiedFinding) { + f.Verdict = schemas.VerdictNotExploitable + f.SARIFRuleID = "rule/dropped" + }) + + run := runOf(t, parseSarif(t, resultOf(kept, dropped))) + results, _ := run["results"].([]any) + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if got := GenerateSarif(resultOf(kept, dropped)); strings.Contains(got, "rule/dropped") { + t.Error("a not_exploitable finding must not contribute a rule") + } +} + +// TestSarifRuleIDFallback pins `finding.sarif_rule_id or +// f"cloudsecurity/{hunter_strategy}/{category}"`, and that the SAME expression +// is used for the rule and for the result (so they always agree). +func TestSarifRuleIDFallback(t *testing.T) { + f := finding(func(f *schemas.VerifiedFinding) { + f.SARIFRuleID = "" + f.HunterStrategy = "iam" + f.Category = "overprivilege" + }) + if got, want := sarifRuleID(f), "cloudsecurity/iam/overprivilege"; got != want { + t.Fatalf("sarifRuleID = %q, want %q", got, want) + } + + run := runOf(t, parseSarif(t, resultOf(f))) + results, _ := run["results"].([]any) + first, _ := results[0].(map[string]any) + if got := first["ruleId"]; got != "cloudsecurity/iam/overprivilege" { + t.Fatalf("result ruleId = %v", got) + } + tool, _ := run["tool"].(map[string]any) + driver, _ := tool["driver"].(map[string]any) + rules, _ := driver["rules"].([]any) + rule, _ := rules[0].(map[string]any) + if got := rule["id"]; got != "cloudsecurity/iam/overprivilege" { + t.Fatalf("rule id = %v", got) + } +} + +// TestSarifRulesAreSortedByID pins Python's `sorted(rules_by_id.items())`. +func TestSarifRulesAreSortedByID(t *testing.T) { + mk := func(id string) schemas.VerifiedFinding { + return finding(func(f *schemas.VerifiedFinding) { f.SARIFRuleID = id }) + } + run := runOf(t, parseSarif(t, resultOf(mk("z/last"), mk("a/first"), mk("m/middle")))) + tool, _ := run["tool"].(map[string]any) + driver, _ := tool["driver"].(map[string]any) + rules, _ := driver["rules"].([]any) + + var ids []string + for _, r := range rules { + rule, _ := r.(map[string]any) + ids = append(ids, rule["id"].(string)) + } + want := []string{"a/first", "m/middle", "z/last"} + if strings.Join(ids, ",") != strings.Join(want, ",") { + t.Fatalf("rule ids = %v, want %v", ids, want) + } +} + +// TestMaxLevelKeepsFirstMaximum pins Python's max(): on a tie it returns the +// FIRST element holding the maximum key, so two "error" severities resolve to +// the first one's level and a later equal-ranked entry cannot displace it. +func TestMaxLevelKeepsFirstMaximum(t *testing.T) { + critical := finding(func(f *schemas.VerifiedFinding) { f.Severity = scoring.SeverityCritical }) + high := finding(func(f *schemas.VerifiedFinding) { f.Severity = scoring.SeverityHigh }) + low := finding(func(f *schemas.VerifiedFinding) { f.Severity = scoring.SeverityLow }) + + if got := maxLevel([]schemas.VerifiedFinding{low, critical, high}); got != "error" { + t.Fatalf("maxLevel = %q, want error", got) + } + if got := maxLevel([]schemas.VerifiedFinding{low}); got != "note" { + t.Fatalf("maxLevel(single low) = %q, want note", got) + } +} + +// TestMaxPrecisionKeepsFirstMaximum is the same contract for verdicts. +func TestMaxPrecisionKeepsFirstMaximum(t *testing.T) { + confirmed := finding(func(f *schemas.VerifiedFinding) { f.Verdict = schemas.VerdictConfirmed }) + likely := finding(func(f *schemas.VerifiedFinding) { f.Verdict = schemas.VerdictLikely }) + inconclusive := finding(func(f *schemas.VerifiedFinding) { f.Verdict = schemas.VerdictInconclusive }) + + if got := maxPrecision([]schemas.VerifiedFinding{inconclusive, confirmed, likely}); got != "very-high" { + t.Fatalf("maxPrecision = %q, want very-high", got) + } + if got := maxPrecision([]schemas.VerifiedFinding{inconclusive}); got != "medium" { + t.Fatalf("maxPrecision(inconclusive) = %q, want medium", got) + } +} + +// TestSeverityAndPrecisionDefaults pins the `.get(..., default)` arms. A +// validated model cannot hold an out-of-enum value, but the port must behave +// the same if one ever arrives over the wire. +func TestSeverityAndPrecisionDefaults(t *testing.T) { + if got := severityToLevelOf("purple"); got != "warning" { + t.Fatalf("severityToLevelOf(unknown) = %q, want warning", got) + } + f := finding(func(f *schemas.VerifiedFinding) { f.Verdict = schemas.Verdict("mystery") }) + if got := precisionOf(f); got != "medium" { + t.Fatalf("precisionOf(unknown) = %q, want medium", got) + } +} + +// TestFormatSecuritySeverityClamps pins _format_security_severity: clamp to +// [0, 10], one decimal, half-to-even at the tie. +func TestFormatSecuritySeverityClamps(t *testing.T) { + cases := []struct { + in float64 + want string + }{ + {-3, "0.0"}, + {0, "0.0"}, + {7.25, "7.2"}, // 7.25 is exactly representable -> ties to even + {7.35, "7.3"}, // 7.35 is just below the tie in binary + {9.99, "10.0"}, // rounds up, still within the clamp + {10, "10.0"}, + {42, "10.0"}, + } + for _, tc := range cases { + if got := formatSecuritySeverity(tc.in); got != tc.want { + t.Errorf("formatSecuritySeverity(%v) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// TestRuleName pins _rule_name, including the "_" -> "-" fold, the +// capitalize()-lowercases-the-rest rule, and the "CloudSecurityRule" fallback. +func TestRuleName(t *testing.T) { + cases := []struct{ in, want string }{ + {"cloudsecurity/iam/public_bucket", "PublicBucket"}, + {"cloudsecurity/data/PUBLIC-exposure_v2", "PublicExposureV2"}, + {"simple", "Simple"}, + {"a/b/c--d", "CD"}, + {"trailing/", "CloudSecurityRule"}, + {"", "CloudSecurityRule"}, + {"a/---", "CloudSecurityRule"}, + {"pfx/iamROLE", "Iamrole"}, + } + for _, tc := range cases { + if got := ruleName(tc.in); got != tc.want { + t.Errorf("ruleName(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// TestMessageTextFallsBackToTitle pins _message_text: "[VERDICT] title: desc", +// with the title standing in for an empty description. +func TestMessageTextFallsBackToTitle(t *testing.T) { + f := finding(func(f *schemas.VerifiedFinding) { + f.Title = "Open bucket" + f.Description = "" + f.Verdict = schemas.VerdictLikely + }) + if got, want := messageText(f), "[LIKELY] Open bucket: Open bucket"; got != want { + t.Fatalf("messageText = %q, want %q", got, want) + } + f.Description = "acl is public-read" + if got, want := messageText(f), "[LIKELY] Open bucket: acl is public-read"; got != want { + t.Fatalf("messageText = %q, want %q", got, want) + } +} + +// TestPhysicalLocation pins the line floor, the "unknown" uri fallback and the +// conditional snippet sub-object. +func TestPhysicalLocation(t *testing.T) { + bare := finding(func(f *schemas.VerifiedFinding) { + f.IaCFile = "" + f.IaCLine = 0 + f.ConfigSnippet = "" + }) + loc := physicalLocation(bare) + artifact, ok := loc.Get("artifactLocation") + if !ok { + t.Fatal("no artifactLocation") + } + uri, _ := artifact.(obj).Get("uri") + if uri != "unknown" { + t.Fatalf("uri = %v, want unknown", uri) + } + region, _ := loc.Get("region") + startLine, _ := region.(obj).Get("startLine") + if startLine != 1 { + t.Fatalf("startLine = %v, want 1 (max(iac_line, 1))", startLine) + } + if _, present := region.(obj).Get("snippet"); present { + t.Error("an empty config_snippet must not add a snippet object") + } + + withSnippet := finding(func(f *schemas.VerifiedFinding) { + f.IaCFile = "main.tf" + f.IaCLine = 9 + f.ConfigSnippet = "acl = \"public-read\"" + }) + region2, _ := physicalLocation(withSnippet).Get("region") + snippet, present := region2.(obj).Get("snippet") + if !present { + t.Fatal("expected a snippet object") + } + text, _ := snippet.(obj).Get("text") + if text != "acl = \"public-read\"" { + t.Fatalf("snippet text = %v", text) + } +} + +// TestResultTagsAreDedupedAndSorted pins _result_tags / _base_tags: +// "security" and "infrastructure" always, plus category, hunter strategy and a +// "compliance:" per mapping — as a sorted set, so a category that equals +// the hunter strategy collapses to one tag. +func TestResultTagsAreDedupedAndSorted(t *testing.T) { + f := finding(func(f *schemas.VerifiedFinding) { + f.Category = "iam" + f.HunterStrategy = "iam" + f.ComplianceMappings = []string{"CIS-AWS-1.4", "CIS-AWS-1.4", "SOC2"} + }) + got := resultTags(f) + want := []string{"compliance:CIS-AWS-1.4", "compliance:SOC2", "iam", "infrastructure", "security"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("resultTags = %v, want %v", got, want) + } +} + +// TestAggregateRuleTagsUnionsEveryFinding pins _aggregate_rule_tags. +func TestAggregateRuleTagsUnionsEveryFinding(t *testing.T) { + a := finding(func(f *schemas.VerifiedFinding) { + f.Category = "net" + f.HunterStrategy = "network" + f.ComplianceMappings = []string{"CIS-AWS-5.2"} + }) + b := finding(func(f *schemas.VerifiedFinding) { + f.Category = "data" + f.HunterStrategy = "data" + }) + got := aggregateRuleTags([]schemas.VerifiedFinding{a, b}) + want := []string{"compliance:CIS-AWS-5.2", "data", "infrastructure", "net", "network", "security"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("aggregateRuleTags = %v, want %v", got, want) + } +} + +// TestEmptyTagSetRendersAsList guards against the nil-slice-is-null trap: a +// finding with no tags cannot exist (two are constant), but sortedKeys must +// never hand pyfmt.Dumps a nil. +func TestEmptyTagSetRendersAsList(t *testing.T) { + if got := sortedKeys(map[string]struct{}{}); got == nil { + t.Fatal("sortedKeys returned nil; an empty tag set must render as []") + } +} + +// TestRuleAggregatesMaxSecuritySeverity pins +// `max(f.sarif_security_severity for f in findings)` on the RULE, while each +// RESULT keeps its own score. +func TestRuleAggregatesMaxSecuritySeverity(t *testing.T) { + low := finding(func(f *schemas.VerifiedFinding) { + f.SARIFRuleID = "r" + f.SARIFSecuritySeverity = 2.5 + }) + high := finding(func(f *schemas.VerifiedFinding) { + f.SARIFRuleID = "r" + f.SARIFSecuritySeverity = 8.5 + }) + rule := buildRule("r", []schemas.VerifiedFinding{low, high}) + props, _ := rule.Get("properties") + sev, _ := props.(obj).Get("security-severity") + if sev != "8.5" { + t.Fatalf("rule security-severity = %v, want 8.5", sev) + } + + res := buildResult(low) + rprops, _ := res.Get("properties") + rsev, _ := rprops.(obj).Get("security-severity") + if rsev != "2.5" { + t.Fatalf("result security-severity = %v, want 2.5", rsev) + } +} + +// TestRuleFullDescriptionFallsBackToTitle pins +// `representative.description or representative.title`. +func TestRuleFullDescriptionFallsBackToTitle(t *testing.T) { + f := finding(func(f *schemas.VerifiedFinding) { + f.Title = "Only a title" + f.Description = "" + }) + rule := buildRule("r", []schemas.VerifiedFinding{f}) + full, _ := rule.Get("fullDescription") + text, _ := full.(obj).Get("text") + if text != "Only a title" { + t.Fatalf("fullDescription.text = %v, want the title", text) + } + short, _ := rule.Get("shortDescription") + stext, _ := short.(obj).Get("text") + if stext != "Only a title" { + t.Fatalf("shortDescription.text = %v", stext) + } +} + +// TestAttackPathPropertyIsAppendedLast pins a subtle Python parity detail: +// _build_result assigns result["properties"]["cloudsecurity/attack_path"] AFTER +// the dict literal is built, so the key lands after "tags" — not next to the +// other cloudsecurity/* keys. The key order is observable in the artifact. +func TestAttackPathPropertyIsAppendedLast(t *testing.T) { + path := schemas.NewAttackPath() + path.Title = "Public ALB to bucket" + f := finding(func(f *schemas.VerifiedFinding) { f.AttackPath = &path }) + + props, _ := buildResult(f).Get("properties") + pairs := props.(obj) + last := pairs[len(pairs)-1] + if last.K != "cloudsecurity/attack_path" { + var keys []string + for _, p := range pairs { + keys = append(keys, p.K) + } + t.Fatalf("last properties key = %q, want cloudsecurity/attack_path (order: %v)", last.K, keys) + } + if last.V != "Public ALB to bucket" { + t.Fatalf("attack_path value = %v", last.V) + } + + // Without an attack path the key is absent entirely. + plain, _ := buildResult(finding(nil)).Get("properties") + if _, present := plain.(obj).Get("cloudsecurity/attack_path"); present { + t.Error("a finding with no attack path must not carry the key") + } +} + +// TestAutomationDetailsUsesIsoformat pins that the run id interpolates +// `result.timestamp.isoformat()` — the "+00:00" spelling, NOT pydantic's "Z". +func TestAutomationDetailsUsesIsoformat(t *testing.T) { + result := resultOf() + result.Repository = "org/repo" + run := runOf(t, parseSarif(t, result)) + details, _ := run["automationDetails"].(map[string]any) + id, _ := details["id"].(string) + want := "cloudsecurity-af/scan/org/repo/" + result.Timestamp.ISOFormat() + if id != want { + t.Fatalf("automationDetails.id = %q, want %q", id, want) + } + if strings.HasSuffix(id, "Z") { + t.Error("isoformat() never emits the Z spelling") + } +} + +// TestRenderSarifIsAnAlias pins render_sarif == generate_sarif. +func TestRenderSarifIsAnAlias(t *testing.T) { + result := resultOf(finding(nil)) + if RenderSarif(result) != GenerateSarif(result) { + t.Fatal("render_sarif must return exactly what generate_sarif returns") + } +} diff --git a/go/internal/output/testdata/golden/scan_result.full.json b/go/internal/output/testdata/golden/scan_result.full.json new file mode 100644 index 0000000..c10312d --- /dev/null +++ b/go/internal/output/testdata/golden/scan_result.full.json @@ -0,0 +1,392 @@ +{ + "repository": "https://github.com/Agent-Field/vulnerable-infra", + "commit_sha": "0f1e2d3c4b5a69788796a5b4c3d2e1f000112233", + "branch": "main", + "timestamp": "2026-05-06T07:08:09.123456Z", + "depth_profile": "standard", + "tier": 2, + "providers_detected": [ + "aws", + "gcp" + ], + "findings": [ + { + "id": "finding-iam-1", + "title": "Wildcard IAM policy on the task role", + "verdict": "confirmed", + "severity": "critical", + "category": "overprivilege", + "resources": [ + { + "resource_id": "aws_iam_role_policy.app", + "resource_type": "aws_iam_role_policy", + "attribute": "policy.Statement[0].Action", + "current_value": "\"*\"", + "recommended_value": "[\"s3:GetObject\"]" + } + ], + "attack_path": { + "id": "path-1", + "title": "Public ALB to customer PII bucket", + "description": "An internet-facing load balancer reaches a role that can read the PII bucket.", + "steps": [ + { + "step_number": 1, + "resource_id": "aws_lb.public", + "resource_type": "aws_lb", + "action": "Reach the listener from the internet", + "permission_used": "ingress 0.0.0.0/0:443", + "description": "The security group allows the world." + }, + { + "step_number": 2, + "resource_id": "aws_iam_role.app", + "resource_type": "aws_iam_role", + "action": "Assume the task role", + "permission_used": "sts:AssumeRole", + "description": "" + }, + { + "step_number": 3, + "resource_id": "aws_s3_bucket.pii", + "resource_type": "aws_s3_bucket", + "action": "Read every object", + "permission_used": "s3:GetObject", + "description": "" + } + ], + "entry_point": "aws_lb.public", + "target": "aws_s3_bucket.pii", + "findings_involved": [ + "finding-net-1", + "finding-iam-1" + ], + "combined_severity": "critical", + "blast_radius": { + "data_stores_reachable": [ + "aws_s3_bucket.pii", + "aws_rds_cluster.main" + ], + "compute_reachable": [ + "aws_ecs_service.api" + ], + "estimated_data_volume": "~400 GB", + "services_affected": [ + "s3", + "rds", + "ecs" + ] + } + }, + "drift": null, + "proof": { + "method": "static_analysis", + "evidence": [ + "policy document grants Action:* on Resource:*" + ], + "scripts_executed": [ + "grep -rn 'Action' iam.tf" + ], + "verification_tier": "static" + }, + "compliance_mappings": [ + "CIS-AWS-1.16", + "SOC2-CC6.1" + ], + "risk_score": 9.5, + "remediation": { + "finding_id": "finding-iam-1", + "description": "Scope the policy to the two objects the service actually reads.", + "diffs": [ + { + "file_path": "iam.tf", + "original_lines": " Action = \"*\"", + "patched_lines": " Action = [\"s3:GetObject\"]", + "start_line": 41, + "end_line": 41 + } + ], + "breaking_change": true, + "downtime_estimate": "seconds", + "effort": "moderate", + "alternative_approaches": [ + "Attach a permissions boundary instead." + ] + }, + "sarif_rule_id": "cloudsecurity/iam/overprivilege", + "sarif_security_severity": 9.5, + "iac_file": "iam.tf", + "iac_line": 41, + "config_snippet": "resource \"aws_iam_role_policy\" \"app\" {\n policy = jsonencode({ Action = \"*\" })\n}", + "description": "The task role can perform any action on any resource.", + "fingerprint": "fp-iam-1", + "hunter_strategy": "iam", + "drop_reason": null + }, + { + "id": "finding-net-1", + "title": "Security group open to the internet", + "verdict": "likely", + "severity": "high", + "category": "public_exposure", + "resources": [], + "attack_path": { + "id": "path-1", + "title": "Public ALB to customer PII bucket", + "description": "An internet-facing load balancer reaches a role that can read the PII bucket.", + "steps": [ + { + "step_number": 1, + "resource_id": "aws_lb.public", + "resource_type": "aws_lb", + "action": "Reach the listener from the internet", + "permission_used": "ingress 0.0.0.0/0:443", + "description": "The security group allows the world." + }, + { + "step_number": 2, + "resource_id": "aws_iam_role.app", + "resource_type": "aws_iam_role", + "action": "Assume the task role", + "permission_used": "sts:AssumeRole", + "description": "" + }, + { + "step_number": 3, + "resource_id": "aws_s3_bucket.pii", + "resource_type": "aws_s3_bucket", + "action": "Read every object", + "permission_used": "s3:GetObject", + "description": "" + } + ], + "entry_point": "aws_lb.public", + "target": "aws_s3_bucket.pii", + "findings_involved": [ + "finding-net-1", + "finding-iam-1" + ], + "combined_severity": "critical", + "blast_radius": { + "data_stores_reachable": [ + "aws_s3_bucket.pii", + "aws_rds_cluster.main" + ], + "compute_reachable": [ + "aws_ecs_service.api" + ], + "estimated_data_volume": "~400 GB", + "services_affected": [ + "s3", + "rds", + "ecs" + ] + } + }, + "drift": { + "resource_id": "aws_s3_bucket.pii", + "resource_type": "aws_s3_bucket", + "iac_config": { + "acl": "private", + "versioning": true + }, + "live_config": { + "acl": "public-read", + "versioning": false + }, + "diffs": [ + { + "attribute": "acl", + "iac_value": "private", + "live_value": "public-read", + "security_impact": "Bucket is world-readable in the account." + } + ], + "security_relevant": true, + "significance": "critical" + }, + "proof": { + "method": "drift_comparison", + "evidence": [], + "scripts_executed": [], + "verification_tier": "live" + }, + "compliance_mappings": [ + "CIS-AWS-5.2" + ], + "risk_score": 7.25, + "remediation": { + "finding_id": "finding-net-1", + "description": "Restrict ingress to the corporate CIDR.", + "diffs": [], + "breaking_change": false, + "downtime_estimate": null, + "effort": "trivial", + "alternative_approaches": [] + }, + "sarif_rule_id": "cloudsecurity/network/public_exposure", + "sarif_security_severity": 7.2, + "iac_file": "network.tf", + "iac_line": 12, + "config_snippet": "", + "description": "0.0.0.0/0 on port 443.", + "fingerprint": "fp-net-1", + "hunter_strategy": "network", + "drop_reason": null + }, + { + "id": "finding-net-2", + "title": "Load balancer logs disabled", + "verdict": "inconclusive", + "severity": "low", + "category": "public_exposure", + "resources": [], + "attack_path": null, + "drift": null, + "proof": { + "method": "static_analysis", + "evidence": [], + "scripts_executed": [], + "verification_tier": "static" + }, + "compliance_mappings": [], + "risk_score": 2.0, + "remediation": null, + "sarif_rule_id": "cloudsecurity/network/public_exposure", + "sarif_security_severity": 3.0, + "iac_file": "network.tf", + "iac_line": 88, + "config_snippet": "", + "description": "", + "fingerprint": "fp-net-2", + "hunter_strategy": "network", + "drop_reason": null + }, + { + "id": "finding-dropped", + "title": "Unused KMS key", + "verdict": "not_exploitable", + "severity": "medium", + "category": "encryption", + "resources": [], + "attack_path": null, + "drift": null, + "proof": { + "method": "static_analysis", + "evidence": [], + "scripts_executed": [], + "verification_tier": "static" + }, + "compliance_mappings": [], + "risk_score": 0.0, + "remediation": null, + "sarif_rule_id": "cloudsecurity/data/encryption", + "sarif_security_severity": 4.0, + "iac_file": "kms.tf", + "iac_line": 3, + "config_snippet": "", + "description": "The key has no grants.", + "fingerprint": "fp-dropped", + "hunter_strategy": "data", + "drop_reason": "not_exploitable" + } + ], + "attack_paths": [ + { + "id": "path-1", + "title": "Public ALB to customer PII bucket", + "description": "An internet-facing load balancer reaches a role that can read the PII bucket.", + "steps": [ + { + "step_number": 1, + "resource_id": "aws_lb.public", + "resource_type": "aws_lb", + "action": "Reach the listener from the internet", + "permission_used": "ingress 0.0.0.0/0:443", + "description": "The security group allows the world." + }, + { + "step_number": 2, + "resource_id": "aws_iam_role.app", + "resource_type": "aws_iam_role", + "action": "Assume the task role", + "permission_used": "sts:AssumeRole", + "description": "" + }, + { + "step_number": 3, + "resource_id": "aws_s3_bucket.pii", + "resource_type": "aws_s3_bucket", + "action": "Read every object", + "permission_used": "s3:GetObject", + "description": "" + } + ], + "entry_point": "aws_lb.public", + "target": "aws_s3_bucket.pii", + "findings_involved": [ + "finding-net-1", + "finding-iam-1" + ], + "combined_severity": "critical", + "blast_radius": { + "data_stores_reachable": [ + "aws_s3_bucket.pii", + "aws_rds_cluster.main" + ], + "compute_reachable": [ + "aws_ecs_service.api" + ], + "estimated_data_volume": "~400 GB", + "services_affected": [ + "s3", + "rds", + "ecs" + ] + } + } + ], + "total_resources_scanned": 137, + "total_raw_findings": 19, + "confirmed": 1, + "likely": 1, + "inconclusive": 1, + "not_exploitable": 1, + "noise_reduction_pct": 78.94736842105263, + "by_severity": { + "critical": 1, + "high": 1, + "medium": 1, + "low": 1, + "info": 0 + }, + "drift_resources": 3, + "shadow_it_resources": 1, + "compliance_frameworks_checked": [ + "CIS-AWS", + "SOC2" + ], + "compliance_gaps": [ + "CIS-AWS-2.1.1 has no evidence" + ], + "strategies_used": [ + "iam", + "network", + "data" + ], + "duration_seconds": 412.6499999999999, + "agent_invocations": 23, + "cost_usd": 1.23456789, + "cost_breakdown": { + "recon": 0.13456789, + "hunt": 0.5, + "chain": 0.2, + "prove": 0.4 + }, + "metadata": { + "harness": "aforge", + "live_verified": true, + "model": "minimax/minimax-m2.5" + }, + "sarif": "" +} \ No newline at end of file diff --git a/go/internal/output/testdata/golden/scan_result.full_compact.json b/go/internal/output/testdata/golden/scan_result.full_compact.json new file mode 100644 index 0000000..100139c --- /dev/null +++ b/go/internal/output/testdata/golden/scan_result.full_compact.json @@ -0,0 +1 @@ +{"repository":"https://github.com/Agent-Field/vulnerable-infra","commit_sha":"0f1e2d3c4b5a69788796a5b4c3d2e1f000112233","branch":"main","timestamp":"2026-05-06T07:08:09.123456Z","depth_profile":"standard","tier":2,"providers_detected":["aws","gcp"],"findings":[{"id":"finding-iam-1","title":"Wildcard IAM policy on the task role","verdict":"confirmed","severity":"critical","category":"overprivilege","resources":[{"resource_id":"aws_iam_role_policy.app","resource_type":"aws_iam_role_policy","attribute":"policy.Statement[0].Action","current_value":"\"*\"","recommended_value":"[\"s3:GetObject\"]"}],"attack_path":{"id":"path-1","title":"Public ALB to customer PII bucket","description":"An internet-facing load balancer reaches a role that can read the PII bucket.","steps":[{"step_number":1,"resource_id":"aws_lb.public","resource_type":"aws_lb","action":"Reach the listener from the internet","permission_used":"ingress 0.0.0.0/0:443","description":"The security group allows the world."},{"step_number":2,"resource_id":"aws_iam_role.app","resource_type":"aws_iam_role","action":"Assume the task role","permission_used":"sts:AssumeRole","description":""},{"step_number":3,"resource_id":"aws_s3_bucket.pii","resource_type":"aws_s3_bucket","action":"Read every object","permission_used":"s3:GetObject","description":""}],"entry_point":"aws_lb.public","target":"aws_s3_bucket.pii","findings_involved":["finding-net-1","finding-iam-1"],"combined_severity":"critical","blast_radius":{"data_stores_reachable":["aws_s3_bucket.pii","aws_rds_cluster.main"],"compute_reachable":["aws_ecs_service.api"],"estimated_data_volume":"~400 GB","services_affected":["s3","rds","ecs"]}},"drift":null,"proof":{"method":"static_analysis","evidence":["policy document grants Action:* on Resource:*"],"scripts_executed":["grep -rn 'Action' iam.tf"],"verification_tier":"static"},"compliance_mappings":["CIS-AWS-1.16","SOC2-CC6.1"],"risk_score":9.5,"remediation":{"finding_id":"finding-iam-1","description":"Scope the policy to the two objects the service actually reads.","diffs":[{"file_path":"iam.tf","original_lines":" Action = \"*\"","patched_lines":" Action = [\"s3:GetObject\"]","start_line":41,"end_line":41}],"breaking_change":true,"downtime_estimate":"seconds","effort":"moderate","alternative_approaches":["Attach a permissions boundary instead."]},"sarif_rule_id":"cloudsecurity/iam/overprivilege","sarif_security_severity":9.5,"iac_file":"iam.tf","iac_line":41,"config_snippet":"resource \"aws_iam_role_policy\" \"app\" {\n policy = jsonencode({ Action = \"*\" })\n}","description":"The task role can perform any action on any resource.","fingerprint":"fp-iam-1","hunter_strategy":"iam","drop_reason":null},{"id":"finding-net-1","title":"Security group open to the internet","verdict":"likely","severity":"high","category":"public_exposure","resources":[],"attack_path":{"id":"path-1","title":"Public ALB to customer PII bucket","description":"An internet-facing load balancer reaches a role that can read the PII bucket.","steps":[{"step_number":1,"resource_id":"aws_lb.public","resource_type":"aws_lb","action":"Reach the listener from the internet","permission_used":"ingress 0.0.0.0/0:443","description":"The security group allows the world."},{"step_number":2,"resource_id":"aws_iam_role.app","resource_type":"aws_iam_role","action":"Assume the task role","permission_used":"sts:AssumeRole","description":""},{"step_number":3,"resource_id":"aws_s3_bucket.pii","resource_type":"aws_s3_bucket","action":"Read every object","permission_used":"s3:GetObject","description":""}],"entry_point":"aws_lb.public","target":"aws_s3_bucket.pii","findings_involved":["finding-net-1","finding-iam-1"],"combined_severity":"critical","blast_radius":{"data_stores_reachable":["aws_s3_bucket.pii","aws_rds_cluster.main"],"compute_reachable":["aws_ecs_service.api"],"estimated_data_volume":"~400 GB","services_affected":["s3","rds","ecs"]}},"drift":{"resource_id":"aws_s3_bucket.pii","resource_type":"aws_s3_bucket","iac_config":{"acl":"private","versioning":true},"live_config":{"acl":"public-read","versioning":false},"diffs":[{"attribute":"acl","iac_value":"private","live_value":"public-read","security_impact":"Bucket is world-readable in the account."}],"security_relevant":true,"significance":"critical"},"proof":{"method":"drift_comparison","evidence":[],"scripts_executed":[],"verification_tier":"live"},"compliance_mappings":["CIS-AWS-5.2"],"risk_score":7.25,"remediation":{"finding_id":"finding-net-1","description":"Restrict ingress to the corporate CIDR.","diffs":[],"breaking_change":false,"downtime_estimate":null,"effort":"trivial","alternative_approaches":[]},"sarif_rule_id":"cloudsecurity/network/public_exposure","sarif_security_severity":7.2,"iac_file":"network.tf","iac_line":12,"config_snippet":"","description":"0.0.0.0/0 on port 443.","fingerprint":"fp-net-1","hunter_strategy":"network","drop_reason":null},{"id":"finding-net-2","title":"Load balancer logs disabled","verdict":"inconclusive","severity":"low","category":"public_exposure","resources":[],"attack_path":null,"drift":null,"proof":{"method":"static_analysis","evidence":[],"scripts_executed":[],"verification_tier":"static"},"compliance_mappings":[],"risk_score":2.0,"remediation":null,"sarif_rule_id":"cloudsecurity/network/public_exposure","sarif_security_severity":3.0,"iac_file":"network.tf","iac_line":88,"config_snippet":"","description":"","fingerprint":"fp-net-2","hunter_strategy":"network","drop_reason":null},{"id":"finding-dropped","title":"Unused KMS key","verdict":"not_exploitable","severity":"medium","category":"encryption","resources":[],"attack_path":null,"drift":null,"proof":{"method":"static_analysis","evidence":[],"scripts_executed":[],"verification_tier":"static"},"compliance_mappings":[],"risk_score":0.0,"remediation":null,"sarif_rule_id":"cloudsecurity/data/encryption","sarif_security_severity":4.0,"iac_file":"kms.tf","iac_line":3,"config_snippet":"","description":"The key has no grants.","fingerprint":"fp-dropped","hunter_strategy":"data","drop_reason":"not_exploitable"}],"attack_paths":[{"id":"path-1","title":"Public ALB to customer PII bucket","description":"An internet-facing load balancer reaches a role that can read the PII bucket.","steps":[{"step_number":1,"resource_id":"aws_lb.public","resource_type":"aws_lb","action":"Reach the listener from the internet","permission_used":"ingress 0.0.0.0/0:443","description":"The security group allows the world."},{"step_number":2,"resource_id":"aws_iam_role.app","resource_type":"aws_iam_role","action":"Assume the task role","permission_used":"sts:AssumeRole","description":""},{"step_number":3,"resource_id":"aws_s3_bucket.pii","resource_type":"aws_s3_bucket","action":"Read every object","permission_used":"s3:GetObject","description":""}],"entry_point":"aws_lb.public","target":"aws_s3_bucket.pii","findings_involved":["finding-net-1","finding-iam-1"],"combined_severity":"critical","blast_radius":{"data_stores_reachable":["aws_s3_bucket.pii","aws_rds_cluster.main"],"compute_reachable":["aws_ecs_service.api"],"estimated_data_volume":"~400 GB","services_affected":["s3","rds","ecs"]}}],"total_resources_scanned":137,"total_raw_findings":19,"confirmed":1,"likely":1,"inconclusive":1,"not_exploitable":1,"noise_reduction_pct":78.94736842105263,"by_severity":{"critical":1,"high":1,"medium":1,"low":1,"info":0},"drift_resources":3,"shadow_it_resources":1,"compliance_frameworks_checked":["CIS-AWS","SOC2"],"compliance_gaps":["CIS-AWS-2.1.1 has no evidence"],"strategies_used":["iam","network","data"],"duration_seconds":412.6499999999999,"agent_invocations":23,"cost_usd":1.23456789,"cost_breakdown":{"recon":0.13456789,"hunt":0.5,"chain":0.2,"prove":0.4},"metadata":{"harness":"aforge","live_verified":true,"model":"minimax/minimax-m2.5"},"sarif":""} \ No newline at end of file diff --git a/go/internal/output/testdata/golden/scan_result.report.md b/go/internal/output/testdata/golden/scan_result.report.md new file mode 100644 index 0000000..3897582 --- /dev/null +++ b/go/internal/output/testdata/golden/scan_result.report.md @@ -0,0 +1,101 @@ +# CloudSecurity AF Infrastructure Security Report + +## Summary + +- Repository: `https://github.com/Agent-Field/vulnerable-infra` +- Commit: `0f1e2d3c4b5a69788796a5b4c3d2e1f000112233` +- Branch: `main` +- Timestamp: `2026-05-06T07:08:09.123456+00:00` +- Depth profile: `standard` +- Tier: **2** (live) +- Providers: aws, gcp +- Resources scanned: **137** +- Findings: **4** (confirmed: 1, likely: 1, inconclusive: 1, not exploitable: 1) +- Noise reduction: **78.9%** + +## Findings + +### Wildcard IAM policy on the task role + +- ID: `finding-iam-1` +- Verdict: `confirmed` | Severity: `critical` +- Risk score: **9.50/10** +- Category: `overprivilege` | Hunter: `iam` +- Location: `iam.tf:41` +- Description: The task role can perform any action on any resource. +- Attack path: **Public ALB to customer PII bucket** +- Compliance: CIS-AWS-1.16, SOC2-CC6.1 +- Remediation: Scope the policy to the two objects the service actually reads. + - **WARNING: Breaking change** + - Downtime: seconds + +```hcl +resource "aws_iam_role_policy" "app" { + policy = jsonencode({ Action = "*" }) +} +``` + +### Security group open to the internet + +- ID: `finding-net-1` +- Verdict: `likely` | Severity: `high` +- Risk score: **7.25/10** +- Category: `public_exposure` | Hunter: `network` +- Location: `network.tf:12` +- Description: 0.0.0.0/0 on port 443. +- Attack path: **Public ALB to customer PII bucket** +- Drift detected: `aws_s3_bucket.pii` (critical) +- Compliance: CIS-AWS-5.2 +- Remediation: Restrict ingress to the corporate CIDR. + +### Load balancer logs disabled + +- ID: `finding-net-2` +- Verdict: `inconclusive` | Severity: `low` +- Risk score: **2.00/10** +- Category: `public_exposure` | Hunter: `network` +- Location: `network.tf:88` + +### Unused KMS key + +- ID: `finding-dropped` +- Verdict: `not_exploitable` | Severity: `medium` +- Risk score: **0.00/10** +- Category: `encryption` | Hunter: `data` +- Location: `kms.tf:3` +- Description: The key has no grants. + +## Attack Paths + +### Public ALB to customer PII bucket + +- ID: `path-1` +- Combined severity: `critical` +- Entry: `aws_lb.public` → Target: `aws_s3_bucket.pii` +- Findings involved: `finding-net-1`, `finding-iam-1` +- Steps: + 1. `aws_lb.public` (aws_lb) — Reach the listener from the internet via `ingress 0.0.0.0/0:443` + 2. `aws_iam_role.app` (aws_iam_role) — Assume the task role via `sts:AssumeRole` + 3. `aws_s3_bucket.pii` (aws_s3_bucket) — Read every object via `s3:GetObject` +- Blast radius — data stores: aws_s3_bucket.pii, aws_rds_cluster.main +- Blast radius — compute: aws_ecs_service.api + +## Drift Summary + +- Drifted resources: **3** +- Shadow IT (cloud-only) resources: **1** + +## Compliance + +- Frameworks checked: CIS-AWS, SOC2 + +## Performance & Cost + +- Duration: 412.6s +- Agent invocations: 23 +- Cost: $1.2346 +- Cost breakdown: + - recon: $0.1346 + - hunt: $0.5000 + - chain: $0.2000 + - prove: $0.4000 diff --git a/go/internal/output/testdata/golden/scan_result.sarif.json b/go/internal/output/testdata/golden/scan_result.sarif.json new file mode 100644 index 0000000..4ffdb6d --- /dev/null +++ b/go/internal/output/testdata/golden/scan_result.sarif.json @@ -0,0 +1,195 @@ +{ + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "CloudSecurity AF", + "semanticVersion": "0.1.0", + "informationUri": "https://github.com/Agent-Field/cloudsecurity-af", + "rules": [ + { + "id": "cloudsecurity/iam/overprivilege", + "name": "Overprivilege", + "shortDescription": { + "text": "Wildcard IAM policy on the task role" + }, + "fullDescription": { + "text": "The task role can perform any action on any resource." + }, + "defaultConfiguration": { + "level": "error" + }, + "properties": { + "precision": "very-high", + "security-severity": "9.5", + "tags": [ + "compliance:CIS-AWS-1.16", + "compliance:SOC2-CC6.1", + "iam", + "infrastructure", + "overprivilege", + "security" + ] + } + }, + { + "id": "cloudsecurity/network/public_exposure", + "name": "PublicExposure", + "shortDescription": { + "text": "Security group open to the internet" + }, + "fullDescription": { + "text": "0.0.0.0/0 on port 443." + }, + "defaultConfiguration": { + "level": "error" + }, + "properties": { + "precision": "high", + "security-severity": "7.2", + "tags": [ + "compliance:CIS-AWS-5.2", + "infrastructure", + "network", + "public_exposure", + "security" + ] + } + } + ] + } + }, + "results": [ + { + "ruleId": "cloudsecurity/iam/overprivilege", + "level": "error", + "message": { + "text": "[CONFIRMED] Wildcard IAM policy on the task role: The task role can perform any action on any resource." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "iam.tf", + "uriBaseId": "%SRCROOT%" + }, + "region": { + "startLine": 41, + "snippet": { + "text": "resource \"aws_iam_role_policy\" \"app\" {\n policy = jsonencode({ Action = \"*\" })\n}" + } + } + } + } + ], + "partialFingerprints": { + "primaryLocationLineHash": "fp-iam-1" + }, + "properties": { + "security-severity": "9.5", + "cloudsecurity/verdict": "confirmed", + "cloudsecurity/risk_score": 9.5, + "cloudsecurity/hunter_strategy": "iam", + "cloudsecurity/category": "overprivilege", + "cloudsecurity/compliance": [ + "CIS-AWS-1.16", + "SOC2-CC6.1" + ], + "tags": [ + "compliance:CIS-AWS-1.16", + "compliance:SOC2-CC6.1", + "iam", + "infrastructure", + "overprivilege", + "security" + ], + "cloudsecurity/attack_path": "Public ALB to customer PII bucket" + } + }, + { + "ruleId": "cloudsecurity/network/public_exposure", + "level": "error", + "message": { + "text": "[LIKELY] Security group open to the internet: 0.0.0.0/0 on port 443." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "network.tf", + "uriBaseId": "%SRCROOT%" + }, + "region": { + "startLine": 12 + } + } + } + ], + "partialFingerprints": { + "primaryLocationLineHash": "fp-net-1" + }, + "properties": { + "security-severity": "7.2", + "cloudsecurity/verdict": "likely", + "cloudsecurity/risk_score": 7.25, + "cloudsecurity/hunter_strategy": "network", + "cloudsecurity/category": "public_exposure", + "cloudsecurity/compliance": [ + "CIS-AWS-5.2" + ], + "tags": [ + "compliance:CIS-AWS-5.2", + "infrastructure", + "network", + "public_exposure", + "security" + ], + "cloudsecurity/attack_path": "Public ALB to customer PII bucket" + } + }, + { + "ruleId": "cloudsecurity/network/public_exposure", + "level": "note", + "message": { + "text": "[INCONCLUSIVE] Load balancer logs disabled: Load balancer logs disabled" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "network.tf", + "uriBaseId": "%SRCROOT%" + }, + "region": { + "startLine": 88 + } + } + } + ], + "partialFingerprints": { + "primaryLocationLineHash": "fp-net-2" + }, + "properties": { + "security-severity": "3.0", + "cloudsecurity/verdict": "inconclusive", + "cloudsecurity/risk_score": 2.0, + "cloudsecurity/hunter_strategy": "network", + "cloudsecurity/category": "public_exposure", + "cloudsecurity/compliance": [], + "tags": [ + "infrastructure", + "network", + "public_exposure", + "security" + ] + } + } + ], + "automationDetails": { + "id": "cloudsecurity-af/scan/https://github.com/Agent-Field/vulnerable-infra/2026-05-06T07:08:09.123456+00:00" + } + } + ] +} \ No newline at end of file diff --git a/go/internal/output/testdata/golden/scan_result.summary.json b/go/internal/output/testdata/golden/scan_result.summary.json new file mode 100644 index 0000000..58c9e00 --- /dev/null +++ b/go/internal/output/testdata/golden/scan_result.summary.json @@ -0,0 +1,114 @@ +{ + "repository": "https://github.com/Agent-Field/vulnerable-infra", + "commit_sha": "0f1e2d3c4b5a69788796a5b4c3d2e1f000112233", + "timestamp": "2026-05-06T07:08:09.123456+00:00", + "depth_profile": "standard", + "tier": 2, + "providers_detected": [ + "aws", + "gcp" + ], + "summary": { + "total_resources_scanned": 137, + "total_findings": 4, + "confirmed": 1, + "likely": 1, + "inconclusive": 1, + "not_exploitable": 1, + "noise_reduction_pct": 78.94736842105263, + "by_severity": { + "critical": 1, + "high": 1, + "medium": 1, + "low": 1, + "info": 0 + } + }, + "findings": [ + { + "id": "finding-iam-1", + "title": "Wildcard IAM policy on the task role", + "severity": "critical", + "verdict": "confirmed", + "risk_score": 9.5, + "category": "overprivilege", + "iac_file": "iam.tf", + "iac_line": 41, + "hunter_strategy": "iam", + "has_attack_path": true, + "has_drift": false + }, + { + "id": "finding-net-1", + "title": "Security group open to the internet", + "severity": "high", + "verdict": "likely", + "risk_score": 7.25, + "category": "public_exposure", + "iac_file": "network.tf", + "iac_line": 12, + "hunter_strategy": "network", + "has_attack_path": true, + "has_drift": true + }, + { + "id": "finding-net-2", + "title": "Load balancer logs disabled", + "severity": "low", + "verdict": "inconclusive", + "risk_score": 2.0, + "category": "public_exposure", + "iac_file": "network.tf", + "iac_line": 88, + "hunter_strategy": "network", + "has_attack_path": false, + "has_drift": false + }, + { + "id": "finding-dropped", + "title": "Unused KMS key", + "severity": "medium", + "verdict": "not_exploitable", + "risk_score": 0.0, + "category": "encryption", + "iac_file": "kms.tf", + "iac_line": 3, + "hunter_strategy": "data", + "has_attack_path": false, + "has_drift": false + } + ], + "attack_paths": [ + { + "id": "path-1", + "title": "Public ALB to customer PII bucket", + "entry_point": "aws_lb.public", + "target": "aws_s3_bucket.pii", + "combined_severity": "critical", + "steps_count": 3, + "findings_involved": [ + "finding-net-1", + "finding-iam-1" + ] + } + ], + "drift": { + "drifted_resources": 3, + "shadow_it_resources": 1 + }, + "compliance_frameworks_checked": [ + "CIS-AWS", + "SOC2" + ], + "performance": { + "duration_seconds": 412.6499999999999, + "cost_usd": 1.23456789, + "cost_breakdown": { + "recon": 0.13456789, + "hunt": 0.5, + "chain": 0.2, + "prove": 0.4 + }, + "agent_invocations": 23 + } +} \ No newline at end of file diff --git a/go/internal/output/testdata/golden/scan_result_edge.full.json b/go/internal/output/testdata/golden/scan_result_edge.full.json new file mode 100644 index 0000000..545355e --- /dev/null +++ b/go/internal/output/testdata/golden/scan_result_edge.full.json @@ -0,0 +1,161 @@ +{ + "repository": "repo/with spaces & ", + "commit_sha": "", + "branch": "", + "timestamp": "2026-05-06T07:08:09.500000+05:30", + "depth_profile": "thorough", + "tier": 7, + "providers_detected": [ + "azure" + ], + "findings": [ + { + "id": "finding-fallback", + "title": "Unnamed rule", + "verdict": "confirmed", + "severity": "info", + "category": "", + "resources": [], + "attack_path": null, + "drift": null, + "proof": { + "method": "static_analysis", + "evidence": [], + "scripts_executed": [], + "verification_tier": "static" + }, + "compliance_mappings": [], + "risk_score": 1e-05, + "remediation": null, + "sarif_rule_id": "", + "sarif_security_severity": -3.0, + "iac_file": "", + "iac_line": 0, + "config_snippet": "", + "description": "", + "fingerprint": "fp-fallback", + "hunter_strategy": "", + "drop_reason": null + }, + { + "id": "finding-fallback-2", + "title": "Unnamed rule, second sighting", + "verdict": "not_exploitable", + "severity": "critical", + "category": "", + "resources": [], + "attack_path": null, + "drift": null, + "proof": { + "method": "static_analysis", + "evidence": [], + "scripts_executed": [], + "verification_tier": "static" + }, + "compliance_mappings": [], + "risk_score": -0.0, + "remediation": null, + "sarif_rule_id": "", + "sarif_security_severity": 99.0, + "iac_file": "", + "iac_line": 0, + "config_snippet": "", + "description": "", + "fingerprint": "fp-fallback-2", + "hunter_strategy": "", + "drop_reason": null + }, + { + "id": "finding-\u00e9scaped", + "title": "S3 bucket \"public\" \u2014 \u4e16\u754c \ud83d\ude80", + "verdict": "likely", + "severity": "medium", + "category": "public_exposure", + "resources": [], + "attack_path": null, + "drift": null, + "proof": { + "method": "static_analysis", + "evidence": [ + " & friends" + ], + "scripts_executed": [], + "verification_tier": "static" + }, + "compliance_mappings": [ + "CIS-AWS-2.1.1", + "\u00a75.2" + ], + "risk_score": 2.675, + "remediation": { + "finding_id": "finding-\u00e9scaped", + "description": "Set `acl = \"private\"`.", + "diffs": [], + "breaking_change": true, + "downtime_estimate": "", + "effort": "trivial", + "alternative_approaches": [] + }, + "sarif_rule_id": "cloudsecurity/data/PUBLIC-exposure_v2", + "sarif_security_severity": 10.0, + "iac_file": "s3\\buckets.tf", + "iac_line": 7, + "config_snippet": "resource \"aws_s3_bucket\" \"b\" {\n\tacl = \"public-read\"\n}", + "description": "Bucket ACL is public-read.\tSee .", + "fingerprint": "fp-\u00e9scaped", + "hunter_strategy": "data", + "drop_reason": null + } + ], + "attack_paths": [ + { + "id": "path-bare", + "title": "Path with \"quotes\" & ", + "description": "", + "steps": [], + "entry_point": "", + "target": "", + "findings_involved": [], + "combined_severity": "info", + "blast_radius": { + "data_stores_reachable": [], + "compute_reachable": [], + "estimated_data_volume": null, + "services_affected": [] + } + } + ], + "total_resources_scanned": 0, + "total_raw_findings": 0, + "confirmed": 2, + "likely": 1, + "inconclusive": 0, + "not_exploitable": 1, + "noise_reduction_pct": 0.05, + "by_severity": { + "critical": 1, + "medium": 1, + "info": 1 + }, + "drift_resources": 0, + "shadow_it_resources": 2, + "compliance_frameworks_checked": [ + "CIS-AWS" + ], + "compliance_gaps": [], + "strategies_used": [], + "duration_seconds": 0.05, + "agent_invocations": 0, + "cost_usd": 5e-05, + "cost_breakdown": { + "prove": 0.12345, + "zzz": -0.0, + "\u00e9phase": 1e+16 + }, + "metadata": { + "note": "tab\there", + "ratio": 0.5, + "unicode": "\u2014" + }, + "sarif": "" +} \ No newline at end of file diff --git a/go/internal/output/testdata/golden/scan_result_edge.full_compact.json b/go/internal/output/testdata/golden/scan_result_edge.full_compact.json new file mode 100644 index 0000000..9415a72 --- /dev/null +++ b/go/internal/output/testdata/golden/scan_result_edge.full_compact.json @@ -0,0 +1 @@ +{"repository":"repo/with spaces & ","commit_sha":"","branch":"","timestamp":"2026-05-06T07:08:09.500000+05:30","depth_profile":"thorough","tier":7,"providers_detected":["azure"],"findings":[{"id":"finding-fallback","title":"Unnamed rule","verdict":"confirmed","severity":"info","category":"","resources":[],"attack_path":null,"drift":null,"proof":{"method":"static_analysis","evidence":[],"scripts_executed":[],"verification_tier":"static"},"compliance_mappings":[],"risk_score":0.00001,"remediation":null,"sarif_rule_id":"","sarif_security_severity":-3.0,"iac_file":"","iac_line":0,"config_snippet":"","description":"","fingerprint":"fp-fallback","hunter_strategy":"","drop_reason":null},{"id":"finding-fallback-2","title":"Unnamed rule, second sighting","verdict":"not_exploitable","severity":"critical","category":"","resources":[],"attack_path":null,"drift":null,"proof":{"method":"static_analysis","evidence":[],"scripts_executed":[],"verification_tier":"static"},"compliance_mappings":[],"risk_score":-0.0,"remediation":null,"sarif_rule_id":"","sarif_security_severity":99.0,"iac_file":"","iac_line":0,"config_snippet":"","description":"","fingerprint":"fp-fallback-2","hunter_strategy":"","drop_reason":null},{"id":"finding-éscaped","title":"S3 bucket \"public\" — 世界 🚀","verdict":"likely","severity":"medium","category":"public_exposure","resources":[],"attack_path":null,"drift":null,"proof":{"method":"static_analysis","evidence":[" & friends"],"scripts_executed":[],"verification_tier":"static"},"compliance_mappings":["CIS-AWS-2.1.1","§5.2"],"risk_score":2.675,"remediation":{"finding_id":"finding-éscaped","description":"Set `acl = \"private\"`.","diffs":[],"breaking_change":true,"downtime_estimate":"","effort":"trivial","alternative_approaches":[]},"sarif_rule_id":"cloudsecurity/data/PUBLIC-exposure_v2","sarif_security_severity":10.0,"iac_file":"s3\\buckets.tf","iac_line":7,"config_snippet":"resource \"aws_s3_bucket\" \"b\" {\n\tacl = \"public-read\"\n}","description":"Bucket ACL is public-read.\tSee .","fingerprint":"fp-éscaped","hunter_strategy":"data","drop_reason":null}],"attack_paths":[{"id":"path-bare","title":"Path with \"quotes\" & ","description":"","steps":[],"entry_point":"","target":"","findings_involved":[],"combined_severity":"info","blast_radius":{"data_stores_reachable":[],"compute_reachable":[],"estimated_data_volume":null,"services_affected":[]}}],"total_resources_scanned":0,"total_raw_findings":0,"confirmed":2,"likely":1,"inconclusive":0,"not_exploitable":1,"noise_reduction_pct":0.05,"by_severity":{"critical":1,"medium":1,"info":1},"drift_resources":0,"shadow_it_resources":2,"compliance_frameworks_checked":["CIS-AWS"],"compliance_gaps":[],"strategies_used":[],"duration_seconds":0.05,"agent_invocations":0,"cost_usd":0.00005,"cost_breakdown":{"prove":0.12345,"zzz":-0.0,"éphase":1e+16},"metadata":{"note":"tab\there","ratio":0.5,"unicode":"—"},"sarif":""} \ No newline at end of file diff --git a/go/internal/output/testdata/golden/scan_result_edge.report.md b/go/internal/output/testdata/golden/scan_result_edge.report.md new file mode 100644 index 0000000..d319e9d --- /dev/null +++ b/go/internal/output/testdata/golden/scan_result_edge.report.md @@ -0,0 +1,79 @@ +# CloudSecurity AF Infrastructure Security Report + +## Summary + +- Repository: `repo/with spaces & ` +- Commit: `` +- Branch: n/a +- Timestamp: `2026-05-06T07:08:09.500000+05:30` +- Depth profile: `thorough` +- Tier: **7** (deep) +- Providers: azure +- Resources scanned: **0** +- Findings: **3** (confirmed: 2, likely: 1, inconclusive: 0, not exploitable: 1) +- Noise reduction: **0.1%** + +## Findings + +### Unnamed rule + +- ID: `finding-fallback` +- Verdict: `confirmed` | Severity: `info` +- Risk score: **0.00/10** +- Category: `` | Hunter: `` +- Location: `:0` + +### Unnamed rule, second sighting + +- ID: `finding-fallback-2` +- Verdict: `not_exploitable` | Severity: `critical` +- Risk score: **-0.00/10** +- Category: `` | Hunter: `` +- Location: `:0` + +### S3 bucket "public" — 世界 🚀 + +- ID: `finding-éscaped` +- Verdict: `likely` | Severity: `medium` +- Risk score: **2.67/10** +- Category: `public_exposure` | Hunter: `data` +- Location: `s3\buckets.tf:7` +- Description: Bucket ACL is public-read. See . +- Compliance: CIS-AWS-2.1.1, §5.2 +- Remediation: Set `acl = "private"`. + - **WARNING: Breaking change** + +```hcl +resource "aws_s3_bucket" "b" { + acl = "public-read" +} +``` + +## Attack Paths + +### Path with "quotes" & + +- ID: `path-bare` +- Combined severity: `info` +- Entry: `` → Target: `` +- Findings involved: +- Steps: + +## Drift Summary + +- Drifted resources: **0** +- Shadow IT (cloud-only) resources: **2** + +## Compliance + +- Frameworks checked: CIS-AWS + +## Performance & Cost + +- Duration: 0.1s +- Agent invocations: 0 +- Cost: $0.0001 +- Cost breakdown: + - prove: $0.1235 + - zzz: $-0.0000 + - éphase: $10000000000000000.0000 diff --git a/go/internal/output/testdata/golden/scan_result_edge.sarif.json b/go/internal/output/testdata/golden/scan_result_edge.sarif.json new file mode 100644 index 0000000..20a8c19 --- /dev/null +++ b/go/internal/output/testdata/golden/scan_result_edge.sarif.json @@ -0,0 +1,150 @@ +{ + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "CloudSecurity AF", + "semanticVersion": "0.1.0", + "informationUri": "https://github.com/Agent-Field/cloudsecurity-af", + "rules": [ + { + "id": "cloudsecurity//", + "name": "CloudSecurityRule", + "shortDescription": { + "text": "Unnamed rule" + }, + "fullDescription": { + "text": "Unnamed rule" + }, + "defaultConfiguration": { + "level": "note" + }, + "properties": { + "precision": "very-high", + "security-severity": "0.0", + "tags": [ + "", + "infrastructure", + "security" + ] + } + }, + { + "id": "cloudsecurity/data/PUBLIC-exposure_v2", + "name": "PublicExposureV2", + "shortDescription": { + "text": "S3 bucket \"public\" \u2014 \u4e16\u754c \ud83d\ude80" + }, + "fullDescription": { + "text": "Bucket ACL is public-read.\tSee ." + }, + "defaultConfiguration": { + "level": "warning" + }, + "properties": { + "precision": "high", + "security-severity": "10.0", + "tags": [ + "compliance:CIS-AWS-2.1.1", + "compliance:\u00a75.2", + "data", + "infrastructure", + "public_exposure", + "security" + ] + } + } + ] + } + }, + "results": [ + { + "ruleId": "cloudsecurity//", + "level": "note", + "message": { + "text": "[CONFIRMED] Unnamed rule: Unnamed rule" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "unknown", + "uriBaseId": "%SRCROOT%" + }, + "region": { + "startLine": 1 + } + } + } + ], + "partialFingerprints": { + "primaryLocationLineHash": "fp-fallback" + }, + "properties": { + "security-severity": "0.0", + "cloudsecurity/verdict": "confirmed", + "cloudsecurity/risk_score": 1e-05, + "cloudsecurity/hunter_strategy": "", + "cloudsecurity/category": "", + "cloudsecurity/compliance": [], + "tags": [ + "", + "infrastructure", + "security" + ] + } + }, + { + "ruleId": "cloudsecurity/data/PUBLIC-exposure_v2", + "level": "warning", + "message": { + "text": "[LIKELY] S3 bucket \"public\" \u2014 \u4e16\u754c \ud83d\ude80: Bucket ACL is public-read.\tSee ." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "s3\\buckets.tf", + "uriBaseId": "%SRCROOT%" + }, + "region": { + "startLine": 7, + "snippet": { + "text": "resource \"aws_s3_bucket\" \"b\" {\n\tacl = \"public-read\"\n}" + } + } + } + } + ], + "partialFingerprints": { + "primaryLocationLineHash": "fp-\u00e9scaped" + }, + "properties": { + "security-severity": "10.0", + "cloudsecurity/verdict": "likely", + "cloudsecurity/risk_score": 2.675, + "cloudsecurity/hunter_strategy": "data", + "cloudsecurity/category": "public_exposure", + "cloudsecurity/compliance": [ + "CIS-AWS-2.1.1", + "\u00a75.2" + ], + "tags": [ + "compliance:CIS-AWS-2.1.1", + "compliance:\u00a75.2", + "data", + "infrastructure", + "public_exposure", + "security" + ] + } + } + ], + "automationDetails": { + "id": "cloudsecurity-af/scan/repo/with spaces & /2026-05-06T07:08:09.500000+05:30" + } + } + ] +} \ No newline at end of file diff --git a/go/internal/output/testdata/golden/scan_result_edge.summary.json b/go/internal/output/testdata/golden/scan_result_edge.summary.json new file mode 100644 index 0000000..19eec69 --- /dev/null +++ b/go/internal/output/testdata/golden/scan_result_edge.summary.json @@ -0,0 +1,93 @@ +{ + "repository": "repo/with spaces & ", + "commit_sha": "", + "timestamp": "2026-05-06T07:08:09.500000+05:30", + "depth_profile": "thorough", + "tier": 7, + "providers_detected": [ + "azure" + ], + "summary": { + "total_resources_scanned": 0, + "total_findings": 3, + "confirmed": 2, + "likely": 1, + "inconclusive": 0, + "not_exploitable": 1, + "noise_reduction_pct": 0.05, + "by_severity": { + "critical": 1, + "medium": 1, + "info": 1 + } + }, + "findings": [ + { + "id": "finding-fallback", + "title": "Unnamed rule", + "severity": "info", + "verdict": "confirmed", + "risk_score": 1e-05, + "category": "", + "iac_file": "", + "iac_line": 0, + "hunter_strategy": "", + "has_attack_path": false, + "has_drift": false + }, + { + "id": "finding-fallback-2", + "title": "Unnamed rule, second sighting", + "severity": "critical", + "verdict": "not_exploitable", + "risk_score": -0.0, + "category": "", + "iac_file": "", + "iac_line": 0, + "hunter_strategy": "", + "has_attack_path": false, + "has_drift": false + }, + { + "id": "finding-\u00e9scaped", + "title": "S3 bucket \"public\" \u2014 \u4e16\u754c \ud83d\ude80", + "severity": "medium", + "verdict": "likely", + "risk_score": 2.675, + "category": "public_exposure", + "iac_file": "s3\\buckets.tf", + "iac_line": 7, + "hunter_strategy": "data", + "has_attack_path": false, + "has_drift": false + } + ], + "attack_paths": [ + { + "id": "path-bare", + "title": "Path with \"quotes\" & ", + "entry_point": "", + "target": "", + "combined_severity": "info", + "steps_count": 0, + "findings_involved": [] + } + ], + "drift": { + "drifted_resources": 0, + "shadow_it_resources": 2 + }, + "compliance_frameworks_checked": [ + "CIS-AWS" + ], + "performance": { + "duration_seconds": 0.05, + "cost_usd": 5e-05, + "cost_breakdown": { + "prove": 0.12345, + "zzz": -0.0, + "\u00e9phase": 1e+16 + }, + "agent_invocations": 0 + } +} \ No newline at end of file diff --git a/go/internal/output/testdata/golden/scan_result_empty.full.json b/go/internal/output/testdata/golden/scan_result_empty.full.json new file mode 100644 index 0000000..7606488 --- /dev/null +++ b/go/internal/output/testdata/golden/scan_result_empty.full.json @@ -0,0 +1,30 @@ +{ + "repository": "", + "commit_sha": "", + "branch": null, + "timestamp": "2026-05-06T07:08:09Z", + "depth_profile": "quick", + "tier": 1, + "providers_detected": [], + "findings": [], + "attack_paths": [], + "total_resources_scanned": 0, + "total_raw_findings": 0, + "confirmed": 0, + "likely": 0, + "inconclusive": 0, + "not_exploitable": 0, + "noise_reduction_pct": 0.0, + "by_severity": {}, + "drift_resources": 0, + "shadow_it_resources": 0, + "compliance_frameworks_checked": [], + "compliance_gaps": [], + "strategies_used": [], + "duration_seconds": 0.0, + "agent_invocations": 0, + "cost_usd": 0.0, + "cost_breakdown": {}, + "metadata": {}, + "sarif": "" +} \ No newline at end of file diff --git a/go/internal/output/testdata/golden/scan_result_empty.full_compact.json b/go/internal/output/testdata/golden/scan_result_empty.full_compact.json new file mode 100644 index 0000000..97f3544 --- /dev/null +++ b/go/internal/output/testdata/golden/scan_result_empty.full_compact.json @@ -0,0 +1 @@ +{"repository":"","commit_sha":"","branch":null,"timestamp":"2026-05-06T07:08:09Z","depth_profile":"quick","tier":1,"providers_detected":[],"findings":[],"attack_paths":[],"total_resources_scanned":0,"total_raw_findings":0,"confirmed":0,"likely":0,"inconclusive":0,"not_exploitable":0,"noise_reduction_pct":0.0,"by_severity":{},"drift_resources":0,"shadow_it_resources":0,"compliance_frameworks_checked":[],"compliance_gaps":[],"strategies_used":[],"duration_seconds":0.0,"agent_invocations":0,"cost_usd":0.0,"cost_breakdown":{},"metadata":{},"sarif":""} \ No newline at end of file diff --git a/go/internal/output/testdata/golden/scan_result_empty.report.md b/go/internal/output/testdata/golden/scan_result_empty.report.md new file mode 100644 index 0000000..3b920f1 --- /dev/null +++ b/go/internal/output/testdata/golden/scan_result_empty.report.md @@ -0,0 +1,30 @@ +# CloudSecurity AF Infrastructure Security Report + +## Summary + +- Repository: `` +- Commit: `` +- Branch: n/a +- Timestamp: `2026-05-06T07:08:09+00:00` +- Depth profile: `quick` +- Tier: **1** (static) +- Providers: none detected +- Resources scanned: **0** +- Findings: **0** (confirmed: 0, likely: 0, inconclusive: 0, not exploitable: 0) +- Noise reduction: **0.0%** + +## Findings + +No findings. + +## Attack Paths + +No multi-resource attack paths identified. + +## Performance & Cost + +- Duration: 0.0s +- Agent invocations: 0 +- Cost: $0.0000 +- Cost breakdown: + - n/a diff --git a/go/internal/output/testdata/golden/scan_result_empty.sarif.json b/go/internal/output/testdata/golden/scan_result_empty.sarif.json new file mode 100644 index 0000000..7bad183 --- /dev/null +++ b/go/internal/output/testdata/golden/scan_result_empty.sarif.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "CloudSecurity AF", + "semanticVersion": "0.1.0", + "informationUri": "https://github.com/Agent-Field/cloudsecurity-af", + "rules": [] + } + }, + "results": [], + "automationDetails": { + "id": "cloudsecurity-af/scan//2026-05-06T07:08:09+00:00" + } + } + ] +} \ No newline at end of file diff --git a/go/internal/output/testdata/golden/scan_result_empty.summary.json b/go/internal/output/testdata/golden/scan_result_empty.summary.json new file mode 100644 index 0000000..943b4cf --- /dev/null +++ b/go/internal/output/testdata/golden/scan_result_empty.summary.json @@ -0,0 +1,31 @@ +{ + "repository": "", + "commit_sha": "", + "timestamp": "2026-05-06T07:08:09+00:00", + "depth_profile": "quick", + "tier": 1, + "providers_detected": [], + "summary": { + "total_resources_scanned": 0, + "total_findings": 0, + "confirmed": 0, + "likely": 0, + "inconclusive": 0, + "not_exploitable": 0, + "noise_reduction_pct": 0.0, + "by_severity": {} + }, + "findings": [], + "attack_paths": [], + "drift": { + "drifted_resources": 0, + "shadow_it_resources": 0 + }, + "compliance_frameworks_checked": [], + "performance": { + "duration_seconds": 0.0, + "cost_usd": 0.0, + "cost_breakdown": {}, + "agent_invocations": 0 + } +} \ No newline at end of file diff --git a/go/internal/output/testdata/scan_result.json b/go/internal/output/testdata/scan_result.json new file mode 100644 index 0000000..56bba50 --- /dev/null +++ b/go/internal/output/testdata/scan_result.json @@ -0,0 +1,392 @@ +{ + "repository": "https://github.com/Agent-Field/vulnerable-infra", + "commit_sha": "0f1e2d3c4b5a69788796a5b4c3d2e1f000112233", + "branch": "main", + "timestamp": "2026-05-06T07:08:09.123456Z", + "depth_profile": "standard", + "tier": 2, + "providers_detected": [ + "aws", + "gcp" + ], + "findings": [ + { + "id": "finding-iam-1", + "title": "Wildcard IAM policy on the task role", + "verdict": "confirmed", + "severity": "critical", + "category": "overprivilege", + "resources": [ + { + "resource_id": "aws_iam_role_policy.app", + "resource_type": "aws_iam_role_policy", + "attribute": "policy.Statement[0].Action", + "current_value": "\"*\"", + "recommended_value": "[\"s3:GetObject\"]" + } + ], + "attack_path": { + "id": "path-1", + "title": "Public ALB to customer PII bucket", + "description": "An internet-facing load balancer reaches a role that can read the PII bucket.", + "steps": [ + { + "step_number": 1, + "resource_id": "aws_lb.public", + "resource_type": "aws_lb", + "action": "Reach the listener from the internet", + "permission_used": "ingress 0.0.0.0/0:443", + "description": "The security group allows the world." + }, + { + "step_number": 2, + "resource_id": "aws_iam_role.app", + "resource_type": "aws_iam_role", + "action": "Assume the task role", + "permission_used": "sts:AssumeRole", + "description": "" + }, + { + "step_number": 3, + "resource_id": "aws_s3_bucket.pii", + "resource_type": "aws_s3_bucket", + "action": "Read every object", + "permission_used": "s3:GetObject", + "description": "" + } + ], + "entry_point": "aws_lb.public", + "target": "aws_s3_bucket.pii", + "findings_involved": [ + "finding-net-1", + "finding-iam-1" + ], + "combined_severity": "critical", + "blast_radius": { + "data_stores_reachable": [ + "aws_s3_bucket.pii", + "aws_rds_cluster.main" + ], + "compute_reachable": [ + "aws_ecs_service.api" + ], + "estimated_data_volume": "~400 GB", + "services_affected": [ + "s3", + "rds", + "ecs" + ] + } + }, + "drift": null, + "proof": { + "method": "static_analysis", + "evidence": [ + "policy document grants Action:* on Resource:*" + ], + "scripts_executed": [ + "grep -rn 'Action' iam.tf" + ], + "verification_tier": "static" + }, + "compliance_mappings": [ + "CIS-AWS-1.16", + "SOC2-CC6.1" + ], + "risk_score": 9.5, + "remediation": { + "finding_id": "finding-iam-1", + "description": "Scope the policy to the two objects the service actually reads.", + "diffs": [ + { + "file_path": "iam.tf", + "original_lines": " Action = \"*\"", + "patched_lines": " Action = [\"s3:GetObject\"]", + "start_line": 41, + "end_line": 41 + } + ], + "breaking_change": true, + "downtime_estimate": "seconds", + "effort": "moderate", + "alternative_approaches": [ + "Attach a permissions boundary instead." + ] + }, + "sarif_rule_id": "cloudsecurity/iam/overprivilege", + "sarif_security_severity": 9.5, + "iac_file": "iam.tf", + "iac_line": 41, + "config_snippet": "resource \"aws_iam_role_policy\" \"app\" {\n policy = jsonencode({ Action = \"*\" })\n}", + "description": "The task role can perform any action on any resource.", + "fingerprint": "fp-iam-1", + "hunter_strategy": "iam", + "drop_reason": null + }, + { + "id": "finding-net-1", + "title": "Security group open to the internet", + "verdict": "likely", + "severity": "high", + "category": "public_exposure", + "resources": [], + "attack_path": { + "id": "path-1", + "title": "Public ALB to customer PII bucket", + "description": "An internet-facing load balancer reaches a role that can read the PII bucket.", + "steps": [ + { + "step_number": 1, + "resource_id": "aws_lb.public", + "resource_type": "aws_lb", + "action": "Reach the listener from the internet", + "permission_used": "ingress 0.0.0.0/0:443", + "description": "The security group allows the world." + }, + { + "step_number": 2, + "resource_id": "aws_iam_role.app", + "resource_type": "aws_iam_role", + "action": "Assume the task role", + "permission_used": "sts:AssumeRole", + "description": "" + }, + { + "step_number": 3, + "resource_id": "aws_s3_bucket.pii", + "resource_type": "aws_s3_bucket", + "action": "Read every object", + "permission_used": "s3:GetObject", + "description": "" + } + ], + "entry_point": "aws_lb.public", + "target": "aws_s3_bucket.pii", + "findings_involved": [ + "finding-net-1", + "finding-iam-1" + ], + "combined_severity": "critical", + "blast_radius": { + "data_stores_reachable": [ + "aws_s3_bucket.pii", + "aws_rds_cluster.main" + ], + "compute_reachable": [ + "aws_ecs_service.api" + ], + "estimated_data_volume": "~400 GB", + "services_affected": [ + "s3", + "rds", + "ecs" + ] + } + }, + "drift": { + "resource_id": "aws_s3_bucket.pii", + "resource_type": "aws_s3_bucket", + "iac_config": { + "acl": "private", + "versioning": true + }, + "live_config": { + "acl": "public-read", + "versioning": false + }, + "diffs": [ + { + "attribute": "acl", + "iac_value": "private", + "live_value": "public-read", + "security_impact": "Bucket is world-readable in the account." + } + ], + "security_relevant": true, + "significance": "critical" + }, + "proof": { + "method": "drift_comparison", + "evidence": [], + "scripts_executed": [], + "verification_tier": "live" + }, + "compliance_mappings": [ + "CIS-AWS-5.2" + ], + "risk_score": 7.25, + "remediation": { + "finding_id": "finding-net-1", + "description": "Restrict ingress to the corporate CIDR.", + "diffs": [], + "breaking_change": false, + "downtime_estimate": null, + "effort": "trivial", + "alternative_approaches": [] + }, + "sarif_rule_id": "cloudsecurity/network/public_exposure", + "sarif_security_severity": 7.2, + "iac_file": "network.tf", + "iac_line": 12, + "config_snippet": "", + "description": "0.0.0.0/0 on port 443.", + "fingerprint": "fp-net-1", + "hunter_strategy": "network", + "drop_reason": null + }, + { + "id": "finding-net-2", + "title": "Load balancer logs disabled", + "verdict": "inconclusive", + "severity": "low", + "category": "public_exposure", + "resources": [], + "attack_path": null, + "drift": null, + "proof": { + "method": "static_analysis", + "evidence": [], + "scripts_executed": [], + "verification_tier": "static" + }, + "compliance_mappings": [], + "risk_score": 2.0, + "remediation": null, + "sarif_rule_id": "cloudsecurity/network/public_exposure", + "sarif_security_severity": 3.0, + "iac_file": "network.tf", + "iac_line": 88, + "config_snippet": "", + "description": "", + "fingerprint": "fp-net-2", + "hunter_strategy": "network", + "drop_reason": null + }, + { + "id": "finding-dropped", + "title": "Unused KMS key", + "verdict": "not_exploitable", + "severity": "medium", + "category": "encryption", + "resources": [], + "attack_path": null, + "drift": null, + "proof": { + "method": "static_analysis", + "evidence": [], + "scripts_executed": [], + "verification_tier": "static" + }, + "compliance_mappings": [], + "risk_score": 0.0, + "remediation": null, + "sarif_rule_id": "cloudsecurity/data/encryption", + "sarif_security_severity": 4.0, + "iac_file": "kms.tf", + "iac_line": 3, + "config_snippet": "", + "description": "The key has no grants.", + "fingerprint": "fp-dropped", + "hunter_strategy": "data", + "drop_reason": "not_exploitable" + } + ], + "attack_paths": [ + { + "id": "path-1", + "title": "Public ALB to customer PII bucket", + "description": "An internet-facing load balancer reaches a role that can read the PII bucket.", + "steps": [ + { + "step_number": 1, + "resource_id": "aws_lb.public", + "resource_type": "aws_lb", + "action": "Reach the listener from the internet", + "permission_used": "ingress 0.0.0.0/0:443", + "description": "The security group allows the world." + }, + { + "step_number": 2, + "resource_id": "aws_iam_role.app", + "resource_type": "aws_iam_role", + "action": "Assume the task role", + "permission_used": "sts:AssumeRole", + "description": "" + }, + { + "step_number": 3, + "resource_id": "aws_s3_bucket.pii", + "resource_type": "aws_s3_bucket", + "action": "Read every object", + "permission_used": "s3:GetObject", + "description": "" + } + ], + "entry_point": "aws_lb.public", + "target": "aws_s3_bucket.pii", + "findings_involved": [ + "finding-net-1", + "finding-iam-1" + ], + "combined_severity": "critical", + "blast_radius": { + "data_stores_reachable": [ + "aws_s3_bucket.pii", + "aws_rds_cluster.main" + ], + "compute_reachable": [ + "aws_ecs_service.api" + ], + "estimated_data_volume": "~400 GB", + "services_affected": [ + "s3", + "rds", + "ecs" + ] + } + } + ], + "total_resources_scanned": 137, + "total_raw_findings": 19, + "confirmed": 1, + "likely": 1, + "inconclusive": 1, + "not_exploitable": 1, + "noise_reduction_pct": 78.94736842105263, + "by_severity": { + "critical": 1, + "high": 1, + "medium": 1, + "low": 1, + "info": 0 + }, + "drift_resources": 3, + "shadow_it_resources": 1, + "compliance_frameworks_checked": [ + "CIS-AWS", + "SOC2" + ], + "compliance_gaps": [ + "CIS-AWS-2.1.1 has no evidence" + ], + "strategies_used": [ + "iam", + "network", + "data" + ], + "duration_seconds": 412.6499999999999, + "agent_invocations": 23, + "cost_usd": 1.23456789, + "cost_breakdown": { + "recon": 0.13456789, + "hunt": 0.5, + "chain": 0.2, + "prove": 0.4 + }, + "metadata": { + "harness": "aforge", + "live_verified": true, + "model": "minimax/minimax-m2.5" + }, + "sarif": "" +} diff --git a/go/internal/output/testdata/scan_result_edge.json b/go/internal/output/testdata/scan_result_edge.json new file mode 100644 index 0000000..52d90b8 --- /dev/null +++ b/go/internal/output/testdata/scan_result_edge.json @@ -0,0 +1,161 @@ +{ + "repository": "repo/with spaces & ", + "commit_sha": "", + "branch": "", + "timestamp": "2026-05-06T07:08:09.500000+05:30", + "depth_profile": "thorough", + "tier": 7, + "providers_detected": [ + "azure" + ], + "findings": [ + { + "id": "finding-fallback", + "title": "Unnamed rule", + "verdict": "confirmed", + "severity": "info", + "category": "", + "resources": [], + "attack_path": null, + "drift": null, + "proof": { + "method": "static_analysis", + "evidence": [], + "scripts_executed": [], + "verification_tier": "static" + }, + "compliance_mappings": [], + "risk_score": 1e-05, + "remediation": null, + "sarif_rule_id": "", + "sarif_security_severity": -3.0, + "iac_file": "", + "iac_line": 0, + "config_snippet": "", + "description": "", + "fingerprint": "fp-fallback", + "hunter_strategy": "", + "drop_reason": null + }, + { + "id": "finding-fallback-2", + "title": "Unnamed rule, second sighting", + "verdict": "not_exploitable", + "severity": "critical", + "category": "", + "resources": [], + "attack_path": null, + "drift": null, + "proof": { + "method": "static_analysis", + "evidence": [], + "scripts_executed": [], + "verification_tier": "static" + }, + "compliance_mappings": [], + "risk_score": -0.0, + "remediation": null, + "sarif_rule_id": "", + "sarif_security_severity": 99.0, + "iac_file": "", + "iac_line": 0, + "config_snippet": "", + "description": "", + "fingerprint": "fp-fallback-2", + "hunter_strategy": "", + "drop_reason": null + }, + { + "id": "finding-\u00e9scaped", + "title": "S3 bucket \"public\" \u2014 \u4e16\u754c \ud83d\ude80", + "verdict": "likely", + "severity": "medium", + "category": "public_exposure", + "resources": [], + "attack_path": null, + "drift": null, + "proof": { + "method": "static_analysis", + "evidence": [ + " & friends" + ], + "scripts_executed": [], + "verification_tier": "static" + }, + "compliance_mappings": [ + "CIS-AWS-2.1.1", + "\u00a75.2" + ], + "risk_score": 2.675, + "remediation": { + "finding_id": "finding-\u00e9scaped", + "description": "Set `acl = \"private\"`.", + "diffs": [], + "breaking_change": true, + "downtime_estimate": "", + "effort": "trivial", + "alternative_approaches": [] + }, + "sarif_rule_id": "cloudsecurity/data/PUBLIC-exposure_v2", + "sarif_security_severity": 10.0, + "iac_file": "s3\\buckets.tf", + "iac_line": 7, + "config_snippet": "resource \"aws_s3_bucket\" \"b\" {\n\tacl = \"public-read\"\n}", + "description": "Bucket ACL is public-read.\tSee .", + "fingerprint": "fp-\u00e9scaped", + "hunter_strategy": "data", + "drop_reason": null + } + ], + "attack_paths": [ + { + "id": "path-bare", + "title": "Path with \"quotes\" & ", + "description": "", + "steps": [], + "entry_point": "", + "target": "", + "findings_involved": [], + "combined_severity": "info", + "blast_radius": { + "data_stores_reachable": [], + "compute_reachable": [], + "estimated_data_volume": null, + "services_affected": [] + } + } + ], + "total_resources_scanned": 0, + "total_raw_findings": 0, + "confirmed": 2, + "likely": 1, + "inconclusive": 0, + "not_exploitable": 1, + "noise_reduction_pct": 0.05, + "by_severity": { + "critical": 1, + "medium": 1, + "info": 1 + }, + "drift_resources": 0, + "shadow_it_resources": 2, + "compliance_frameworks_checked": [ + "CIS-AWS" + ], + "compliance_gaps": [], + "strategies_used": [], + "duration_seconds": 0.05, + "agent_invocations": 0, + "cost_usd": 5e-05, + "cost_breakdown": { + "prove": 0.12345, + "zzz": -0.0, + "\u00e9phase": 1e+16 + }, + "metadata": { + "note": "tab\there", + "ratio": 0.5, + "unicode": "\u2014" + }, + "sarif": "" +} diff --git a/go/internal/output/testdata/scan_result_empty.json b/go/internal/output/testdata/scan_result_empty.json new file mode 100644 index 0000000..5acc8bc --- /dev/null +++ b/go/internal/output/testdata/scan_result_empty.json @@ -0,0 +1,30 @@ +{ + "repository": "", + "commit_sha": "", + "branch": null, + "timestamp": "2026-05-06T07:08:09Z", + "depth_profile": "quick", + "tier": 1, + "providers_detected": [], + "findings": [], + "attack_paths": [], + "total_resources_scanned": 0, + "total_raw_findings": 0, + "confirmed": 0, + "likely": 0, + "inconclusive": 0, + "not_exploitable": 0, + "noise_reduction_pct": 0.0, + "by_severity": {}, + "drift_resources": 0, + "shadow_it_resources": 0, + "compliance_frameworks_checked": [], + "compliance_gaps": [], + "strategies_used": [], + "duration_seconds": 0.0, + "agent_invocations": 0, + "cost_usd": 0.0, + "cost_breakdown": {}, + "metadata": {}, + "sarif": "" +} From 83209a4e170fc463fb153dff74de81678c8d8ac7 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 20 Aug 2026 02:11:58 -0400 Subject: [PATCH 3/5] feat(go): port the scan orchestrator and the phase reasoners internal/phases carries recon/hunt/chain/prove/remediation_phase with the exact Python call targets, kwargs, depth tables, semaphore bounds and fallback shapes, so the control-plane DAG is identical to the Python node's. internal/orch ports ScanOrchestrator: the five sequential phase calls, checkpoints, budget bookkeeping and generate_output (benchmark severity floors, risk scores, drift/shadow-IT counts, SARIF). Co-Authored-By: Claude Fable 5 --- go/internal/orch/budget.go | 213 ++++++++ go/internal/orch/budget_test.go | 297 ++++++++++++ go/internal/orch/checkpoint.go | 101 ++++ go/internal/orch/checkpoint_test.go | 179 +++++++ go/internal/orch/doc.go | 36 ++ go/internal/orch/helpers_test.go | 251 ++++++++++ go/internal/orch/orchestrator.go | 472 ++++++++++++++++++ go/internal/orch/orchestrator_test.go | 592 +++++++++++++++++++++++ go/internal/orch/output.go | 203 ++++++++ go/internal/orch/output_test.go | 425 ++++++++++++++++ go/internal/phases/chain.go | 84 ++++ go/internal/phases/chain_test.go | 153 ++++++ go/internal/phases/doc.go | 62 +++ go/internal/phases/helpers_test.go | 161 ++++++ go/internal/phases/hunt.go | 208 ++++++++ go/internal/phases/hunt_test.go | 305 ++++++++++++ go/internal/phases/inputs.go | 226 +++++++++ go/internal/phases/inputs_test.go | 87 ++++ go/internal/phases/payload_order_test.go | 231 +++++++++ go/internal/phases/phases.go | 154 ++++++ go/internal/phases/prove.go | 246 ++++++++++ go/internal/phases/prove_test.go | 482 ++++++++++++++++++ go/internal/phases/recon.go | 213 ++++++++ go/internal/phases/recon_test.go | 365 ++++++++++++++ go/internal/phases/remediate.go | 142 ++++++ go/internal/phases/remediate_test.go | 219 +++++++++ 26 files changed, 6107 insertions(+) create mode 100644 go/internal/orch/budget.go create mode 100644 go/internal/orch/budget_test.go create mode 100644 go/internal/orch/checkpoint.go create mode 100644 go/internal/orch/checkpoint_test.go create mode 100644 go/internal/orch/doc.go create mode 100644 go/internal/orch/helpers_test.go create mode 100644 go/internal/orch/orchestrator.go create mode 100644 go/internal/orch/orchestrator_test.go create mode 100644 go/internal/orch/output.go create mode 100644 go/internal/orch/output_test.go create mode 100644 go/internal/phases/chain.go create mode 100644 go/internal/phases/chain_test.go create mode 100644 go/internal/phases/doc.go create mode 100644 go/internal/phases/helpers_test.go create mode 100644 go/internal/phases/hunt.go create mode 100644 go/internal/phases/hunt_test.go create mode 100644 go/internal/phases/inputs.go create mode 100644 go/internal/phases/inputs_test.go create mode 100644 go/internal/phases/payload_order_test.go create mode 100644 go/internal/phases/phases.go create mode 100644 go/internal/phases/prove.go create mode 100644 go/internal/phases/prove_test.go create mode 100644 go/internal/phases/recon.go create mode 100644 go/internal/phases/recon_test.go create mode 100644 go/internal/phases/remediate.go create mode 100644 go/internal/phases/remediate_test.go diff --git a/go/internal/orch/budget.go b/go/internal/orch/budget.go new file mode 100644 index 0000000..c8c17b9 --- /dev/null +++ b/go/internal/orch/budget.go @@ -0,0 +1,213 @@ +package orch + +import ( + "context" + + "github.com/Agent-Field/agentfield/sdk/go/harness" + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// BudgetExhausted ports `class BudgetExhausted(RuntimeError)`. Its message is +// f"{phase} budget exhausted", verbatim. +type BudgetExhausted struct { + Phase string +} + +func (e *BudgetExhausted) Error() string { return e.Phase + " budget exhausted" } + +// defaultPhaseBudgetWeight is the `weights.get(phase, 0.1)` fallback in +// _phase_budget_limit — reachable only for a phase name outside _PHASE_ORDER. +const defaultPhaseBudgetWeight = 0.1 + +// BudgetOrTimeoutExhausted ports _budget_or_timeout_exhausted. +// +// Python: +// +// if self.max_duration_seconds is not None: +// if time.monotonic() - self.started_at > self.max_duration_seconds: +// self.budget_exhausted = True; return True +// if self.max_cost_usd is not None and self.total_cost_usd >= self.max_cost_usd: +// self.budget_exhausted = True; return True +// phase_limit = self._phase_budget_limit(phase) +// if phase_limit is not None and self.cost_breakdown.get(phase, 0.0) >= phase_limit: +// self.budget_exhausted = True; return True +// return False +// +// Python parity: the duration test is STRICTLY greater while both cost tests are +// greater-or-equal, and the method has the side effect of latching +// budget_exhausted. +func (o *ScanOrchestrator) BudgetOrTimeoutExhausted(phase string) bool { + if o.MaxDurationSeconds != nil { + if o.elapsedSeconds() > float64(*o.MaxDurationSeconds) { + o.BudgetExhausted = true + return true + } + } + if o.MaxCostUSD != nil && o.TotalCostUSD >= *o.MaxCostUSD { + o.BudgetExhausted = true + return true + } + if limit := o.PhaseBudgetLimit(phase); limit != nil { + if o.CostBreakdown[phase] >= *limit { + o.BudgetExhausted = true + return true + } + } + return false +} + +// PhaseBudgetLimit ports _phase_budget_limit: nil (Python None) when there is no +// overall cost cap, otherwise max_cost_usd scaled by the phase's budget +// percentage. +func (o *ScanOrchestrator) PhaseBudgetLimit(phase string) *float64 { + if o.MaxCostUSD == nil { + return nil + } + weights := map[string]float64{ + "recon": o.BudgetConfig.ReconBudgetPct, + "hunt": o.BudgetConfig.HuntBudgetPct, + "chain": o.BudgetConfig.ChainBudgetPct, + "prove": o.BudgetConfig.ProveBudgetPct, + "remediate": o.BudgetConfig.RemediateBudgetPct, + } + weight, present := weights[phase] + if !present { + weight = defaultPhaseBudgetWeight + } + limit := *o.MaxCostUSD * weight + return &limit +} + +// RegisterCost ports _register_cost: +// +// if cost_usd is None or cost_usd < 0: return +// self.total_cost_usd += cost_usd +// self.cost_breakdown[phase] = self.cost_breakdown.get(phase, 0.0) + cost_usd +// +// Python parity: a nil cost (the harness reported none) and a NEGATIVE cost are +// both ignored, and a phase name outside _PHASE_ORDER creates a new +// cost_breakdown entry rather than erroring. +func (o *ScanOrchestrator) RegisterCost(phase string, costUSD *float64) { + if costUSD == nil || *costUSD < 0 { + return + } + o.TotalCostUSD += *costUSD + if o.CostBreakdown == nil { + o.CostBreakdown = map[string]float64{} + } + o.CostBreakdown[phase] += *costUSD +} + +// EmitProgress ports _emit_progress. +// +// Python builds a ScanProgress and then DOES NOTHING WITH IT — there is no +// app.note, no return, no side effect beyond the arithmetic. The port keeps the +// arithmetic (and returns the value so it is testable) but emits nothing, which +// is the observable behaviour that matters: the Python node produces no +// progress note, so neither may the Go node. +// +// 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 +// ScanProgress(..., agents_running=max(0, agents_total - agents_completed), +// estimated_remaining_seconds=max(0.0, estimated_total - elapsed), +// cost_so_far_usd=round(self.total_cost_usd, 4)) +func (o *ScanOrchestrator) EmitProgress(phase string, agentsTotal, agentsCompleted, findingsSoFar int) schemas.ScanProgress { + 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 + } + + agentsRunning := agentsTotal - agentsCompleted + if agentsRunning < 0 { + agentsRunning = 0 + } + remaining := estimatedTotal - elapsed + if remaining < 0.0 { + remaining = 0.0 + } + + progress := schemas.NewScanProgress() + progress.Phase = phase + progress.PhaseProgress = phaseProgress + progress.AgentsTotal = agentsTotal + progress.AgentsCompleted = agentsCompleted + progress.AgentsRunning = agentsRunning + progress.FindingsSoFar = findingsSoFar + progress.ElapsedSeconds = elapsed + progress.EstimatedRemainingSeconds = remaining + progress.CostSoFarUSD = pyfmt.Round(o.TotalCostUSD, 4) + return progress +} + +// PhaseHarnessProxy ports `class _PhaseHarnessProxy`: an app.harness facade that +// refuses to run once the phase's budget is spent and books the cost of every +// run it does allow. +// +// It is DEAD CODE in Python — run() never constructs one — and is ported for +// completeness because it is the only place the budget helpers are wired to +// anything. It satisfies appx.Harnesser so it can be dropped in wherever an +// agent function takes the harness seam. +type PhaseHarnessProxy struct { + orchestrator *ScanOrchestrator + phase string +} + +var _ appx.Harnesser = (*PhaseHarnessProxy)(nil) + +// NewPhaseHarnessProxy ports `_PhaseHarnessProxy(orchestrator, phase)`. +func NewPhaseHarnessProxy(orchestrator *ScanOrchestrator, phase string) *PhaseHarnessProxy { + return &PhaseHarnessProxy{orchestrator: orchestrator, phase: phase} +} + +// Harness ports _PhaseHarnessProxy.harness. +// +// Python: +// +// 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 +// +// Python parity: a harness that RAISES bumps neither the invocation counter nor +// the cost, because the await propagates before those two lines run. A harness +// that returns an ERROR result (IsError set, no Go error) does bump both, since +// Python's SDK returns that as a value too. +func (p *PhaseHarnessProxy) Harness( + ctx context.Context, + prompt string, + schema map[string]any, + dest any, + opts harness.Options, +) (*harness.Result, error) { + if p.orchestrator.BudgetOrTimeoutExhausted(p.phase) { + return nil, &BudgetExhausted{Phase: p.phase} + } + result, err := p.orchestrator.App.Harness(ctx, prompt, schema, dest, opts) + if err != nil { + return nil, err + } + p.orchestrator.AgentInvocations++ + var costUSD *float64 + if result != nil { + costUSD = result.CostUSD + } + p.orchestrator.RegisterCost(p.phase, costUSD) + return result, nil +} diff --git a/go/internal/orch/budget_test.go b/go/internal/orch/budget_test.go new file mode 100644 index 0000000..dec511d --- /dev/null +++ b/go/internal/orch/budget_test.go @@ -0,0 +1,297 @@ +package orch + +import ( + "context" + "errors" + "testing" + "time" + + sdkharness "github.com/Agent-Field/agentfield/sdk/go/harness" + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// TestBudgetExhaustedError pins the RuntimeError message +// f"{phase} budget exhausted". +func TestBudgetExhaustedError(t *testing.T) { + err := &BudgetExhausted{Phase: "hunt"} + if err.Error() != "hunt budget exhausted" { + t.Fatalf("Error() = %q", err.Error()) + } + var target *BudgetExhausted + if !errors.As(error(err), &target) { + t.Fatal("BudgetExhausted should be matchable with errors.As") + } +} + +// TestBudgetOrTimeoutExhausted covers each of the three arms plus the +// all-clear path, and the budget_exhausted latch. +func TestBudgetOrTimeoutExhausted(t *testing.T) { + t.Run("no caps means never exhausted", func(t *testing.T) { + o := newTestOrchestrator(t, &appx.Fake{}, scanInput(t, nil), fixedClock()) + if o.BudgetOrTimeoutExhausted("hunt") { + t.Fatal("exhausted with no caps") + } + if o.BudgetExhausted { + t.Fatal("latch set with no caps") + } + }) + + t.Run("duration cap is strictly greater", func(t *testing.T) { + input := scanInput(t, func(in *schemas.CloudSecurityInput) { in.MaxDurationSeconds = intPtr(1) }) + // Each nowFn call advances 1s: StartedAt = t0, first check sees +1s + // (NOT > 1), second sees +2s (> 1). + o := newTestOrchestrator(t, &appx.Fake{}, input, steppingClock(time.Second)) + if o.BudgetOrTimeoutExhausted("hunt") { + t.Fatal("elapsed == cap must NOT be exhausted (Python uses >)") + } + if !o.BudgetOrTimeoutExhausted("hunt") { + t.Fatal("elapsed > cap must be exhausted") + } + if !o.BudgetExhausted { + t.Fatal("budget_exhausted latch not set") + } + }) + + t.Run("total cost cap is greater or equal", func(t *testing.T) { + input := scanInput(t, func(in *schemas.CloudSecurityInput) { in.MaxCostUSD = floatPtr(1.0) }) + o := newTestOrchestrator(t, &appx.Fake{}, input, fixedClock()) + o.TotalCostUSD = 0.99 + if o.BudgetOrTimeoutExhausted("hunt") { + t.Fatal("below the cap must not be exhausted") + } + o.TotalCostUSD = 1.0 + if !o.BudgetOrTimeoutExhausted("hunt") { + t.Fatal("cost == cap must be exhausted (Python uses >=)") + } + }) + + t.Run("per phase cap", func(t *testing.T) { + input := scanInput(t, func(in *schemas.CloudSecurityInput) { in.MaxCostUSD = floatPtr(1.0) }) + o := newTestOrchestrator(t, &appx.Fake{}, input, fixedClock()) + // hunt's share is 35% of 1.0. + o.CostBreakdown["hunt"] = 0.34 + if o.BudgetOrTimeoutExhausted("hunt") { + t.Fatal("below the phase cap must not be exhausted") + } + o.CostBreakdown["hunt"] = 0.35 + if !o.BudgetOrTimeoutExhausted("hunt") { + t.Fatal("phase cost == phase cap must be exhausted") + } + }) +} + +// TestPhaseBudgetLimit pins the five weights and the 0.1 fallback, plus the +// None-when-uncapped rule. +func TestPhaseBudgetLimit(t *testing.T) { + t.Run("nil without a cost cap", func(t *testing.T) { + o := newTestOrchestrator(t, &appx.Fake{}, scanInput(t, nil), fixedClock()) + if got := o.PhaseBudgetLimit("hunt"); got != nil { + t.Fatalf("PhaseBudgetLimit = %v, want nil", *got) + } + }) + + input := scanInput(t, func(in *schemas.CloudSecurityInput) { in.MaxCostUSD = floatPtr(10.0) }) + o := newTestOrchestrator(t, &appx.Fake{}, input, fixedClock()) + cases := map[string]float64{ + "recon": 1.0, + "hunt": 3.5, + "chain": 2.0, + "prove": 2.5, + "remediate": 1.0, + "unknown": 1.0, // weights.get(phase, 0.1) + } + for phase, want := range cases { + got := o.PhaseBudgetLimit(phase) + if got == nil { + t.Fatalf("PhaseBudgetLimit(%q) = nil", phase) + } + if diff := *got - want; diff > 1e-9 || diff < -1e-9 { + t.Errorf("PhaseBudgetLimit(%q) = %v, want %v", phase, *got, want) + } + } +} + +// TestRegisterCost covers `if cost_usd is None or cost_usd < 0: return`. +func TestRegisterCost(t *testing.T) { + o := newTestOrchestrator(t, &appx.Fake{}, scanInput(t, nil), fixedClock()) + + o.RegisterCost("hunt", nil) + o.RegisterCost("hunt", floatPtr(-1.0)) + if o.TotalCostUSD != 0 || o.CostBreakdown["hunt"] != 0 { + t.Fatalf("nil / negative costs must be ignored: %v %v", o.TotalCostUSD, o.CostBreakdown["hunt"]) + } + + o.RegisterCost("hunt", floatPtr(0.0)) + o.RegisterCost("hunt", floatPtr(0.25)) + o.RegisterCost("hunt", floatPtr(0.25)) + if o.TotalCostUSD != 0.5 || o.CostBreakdown["hunt"] != 0.5 { + t.Fatalf("total/hunt = %v/%v, want 0.5/0.5", o.TotalCostUSD, o.CostBreakdown["hunt"]) + } + + // A phase outside _PHASE_ORDER gets a fresh bucket (dict.get(phase, 0.0)). + o.RegisterCost("mystery", floatPtr(1.5)) + if o.CostBreakdown["mystery"] != 1.5 { + t.Fatalf("cost_breakdown[mystery] = %v", o.CostBreakdown["mystery"]) + } +} + +// TestEmitProgress covers the arithmetic and the fact that NOTHING is emitted. +func TestEmitProgress(t *testing.T) { + fake := &appx.Fake{} + o := newTestOrchestrator(t, fake, scanInput(t, nil), steppingClock(2*time.Second)) + o.TotalCostUSD = 0.123456 + + // First nowFn call was StartedAt; this one is +2s. + got := o.EmitProgress("hunt", 4, 1, 7) + if got.Phase != "hunt" || got.AgentsTotal != 4 || got.AgentsCompleted != 1 || got.FindingsSoFar != 7 { + t.Fatalf("progress = %#v", got) + } + if got.AgentsRunning != 3 { + t.Errorf("agents_running = %d, want 3", got.AgentsRunning) + } + if got.PhaseProgress != 0.25 { + t.Errorf("phase_progress = %v, want 0.25", got.PhaseProgress) + } + if got.ElapsedSeconds != 2.0 { + t.Errorf("elapsed_seconds = %v, want 2", got.ElapsedSeconds) + } + // estimated_total = 2 / 0.25 = 8 -> remaining = 6. + if got.EstimatedRemainingSeconds != 6.0 { + t.Errorf("estimated_remaining_seconds = %v, want 6", got.EstimatedRemainingSeconds) + } + if got.CostSoFarUSD != 0.1235 { + t.Errorf("cost_so_far_usd = %v, want 0.1235", got.CostSoFarUSD) + } + if len(fake.Notes) != 0 { + t.Fatalf("_emit_progress must not emit a note, got %v", fake.NoteMessages()) + } +} + +// TestEmitProgress_EdgeCases covers max(1, agents_total), the min(1.0, ...) +// clamp, the phase_progress == 0 branch and max(0, ...) on both derived fields. +func TestEmitProgress_EdgeCases(t *testing.T) { + t.Run("zero agents_total is clamped to one", func(t *testing.T) { + o := newTestOrchestrator(t, &appx.Fake{}, scanInput(t, nil), fixedClock()) + got := o.EmitProgress("recon", 0, 1, 0) + if got.PhaseProgress != 1.0 { + t.Fatalf("phase_progress = %v, want 1.0", got.PhaseProgress) + } + if got.AgentsRunning != 0 { + t.Fatalf("agents_running = %d, want 0", got.AgentsRunning) + } + }) + + t.Run("progress is capped at one", func(t *testing.T) { + o := newTestOrchestrator(t, &appx.Fake{}, scanInput(t, nil), fixedClock()) + got := o.EmitProgress("recon", 2, 5, 0) + if got.PhaseProgress != 1.0 { + t.Fatalf("phase_progress = %v, want 1.0", got.PhaseProgress) + } + if got.AgentsRunning != 0 { + t.Fatalf("agents_running = %d, want 0 (max(0, 2-5))", got.AgentsRunning) + } + }) + + t.Run("zero progress leaves nothing remaining", func(t *testing.T) { + o := newTestOrchestrator(t, &appx.Fake{}, scanInput(t, nil), steppingClock(3*time.Second)) + got := o.EmitProgress("recon", 4, 0, 0) + if got.PhaseProgress != 0 { + t.Fatalf("phase_progress = %v", got.PhaseProgress) + } + // estimated_total = elapsed -> remaining = 0. + if got.EstimatedRemainingSeconds != 0 { + t.Fatalf("estimated_remaining_seconds = %v, want 0", got.EstimatedRemainingSeconds) + } + }) +} + +// TestPhaseHarnessProxy covers the budget gate, the invocation counter and the +// cost bookkeeping. +func TestPhaseHarnessProxy(t *testing.T) { + t.Run("refuses once the budget is spent", func(t *testing.T) { + input := scanInput(t, func(in *schemas.CloudSecurityInput) { in.MaxCostUSD = floatPtr(1.0) }) + fake := &appx.Fake{} + o := newTestOrchestrator(t, fake, input, fixedClock()) + o.TotalCostUSD = 1.0 + + proxy := NewPhaseHarnessProxy(o, "hunt") + _, err := proxy.Harness(context.Background(), "p", nil, nil, sdkharness.Options{}) + var exhausted *BudgetExhausted + if !errors.As(err, &exhausted) || exhausted.Phase != "hunt" { + t.Fatalf("err = %v, want a BudgetExhausted for hunt", err) + } + if len(fake.Harnesses) != 0 { + t.Fatal("the harness must not run once the budget is spent") + } + if o.AgentInvocations != 0 { + t.Fatalf("agent_invocations = %d", o.AgentInvocations) + } + }) + + t.Run("counts invocations and registers cost", func(t *testing.T) { + cost := 0.4 + fake := &appx.Fake{HarnessFn: func(_ context.Context, _ string, _ map[string]any, _ any, _ sdkharness.Options) (*sdkharness.Result, error) { + return &sdkharness.Result{Result: "ok", CostUSD: &cost}, nil + }} + o := newTestOrchestrator(t, fake, scanInput(t, nil), fixedClock()) + proxy := NewPhaseHarnessProxy(o, "prove") + + for i := 0; i < 2; i++ { + if _, err := proxy.Harness(context.Background(), "p", nil, nil, sdkharness.Options{}); err != nil { + t.Fatalf("Harness: %v", err) + } + } + if o.AgentInvocations != 2 { + t.Errorf("agent_invocations = %d, want 2", o.AgentInvocations) + } + if o.TotalCostUSD != 0.8 { + t.Errorf("total_cost_usd = %v, want 0.8", o.TotalCostUSD) + } + if o.CostBreakdown["prove"] != 0.8 { + t.Errorf("cost_breakdown[prove] = %v, want 0.8", o.CostBreakdown["prove"]) + } + }) + + t.Run("a harness error bumps nothing", func(t *testing.T) { + fake := &appx.Fake{HarnessFn: func(_ context.Context, _ string, _ map[string]any, _ any, _ sdkharness.Options) (*sdkharness.Result, error) { + return nil, errors.New("harness blew up") + }} + o := newTestOrchestrator(t, fake, scanInput(t, nil), fixedClock()) + proxy := NewPhaseHarnessProxy(o, "recon") + if _, err := proxy.Harness(context.Background(), "p", nil, nil, sdkharness.Options{}); err == nil { + t.Fatal("expected the harness error to propagate") + } + if o.AgentInvocations != 0 || o.TotalCostUSD != 0 { + t.Fatalf("counters moved: %d / %v", o.AgentInvocations, o.TotalCostUSD) + } + }) + + t.Run("a nil cost is ignored but still counts as an invocation", func(t *testing.T) { + fake := &appx.Fake{HarnessFn: func(_ context.Context, _ string, _ map[string]any, _ any, _ sdkharness.Options) (*sdkharness.Result, error) { + return &sdkharness.Result{Result: "ok"}, nil + }} + o := newTestOrchestrator(t, fake, scanInput(t, nil), fixedClock()) + proxy := NewPhaseHarnessProxy(o, "chain") + if _, err := proxy.Harness(context.Background(), "p", nil, nil, sdkharness.Options{}); err != nil { + t.Fatalf("Harness: %v", err) + } + if o.AgentInvocations != 1 || o.TotalCostUSD != 0 { + t.Fatalf("counters = %d / %v", o.AgentInvocations, o.TotalCostUSD) + } + }) +} + +// TestPhaseHarnessProxy_SatisfiesTheHarnessSeam is a compile-time-ish guard that +// the proxy can stand in for the app wherever an agent takes appx.Harnesser. +func TestPhaseHarnessProxy_SatisfiesTheHarnessSeam(t *testing.T) { + o := newTestOrchestrator(t, &appx.Fake{}, scanInput(t, nil), fixedClock()) + proxy := NewPhaseHarnessProxy(o, "recon") + if proxy == nil { + t.Fatal("NewPhaseHarnessProxy returned nil") + } + // The assignment IS the assertion: it does not compile unless the proxy + // implements appx.Harnesser. + var seam appx.Harnesser = proxy + _ = seam +} diff --git a/go/internal/orch/checkpoint.go b/go/internal/orch/checkpoint.go new file mode 100644 index 0000000..3d1edee --- /dev/null +++ b/go/internal/orch/checkpoint.go @@ -0,0 +1,101 @@ +package orch + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/afx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// checkpointDirPerm / checkpointFilePerm mirror the CPython defaults: +// Path.mkdir() defaults to mode=0o777 and open(..., "w") to 0o666, both then +// masked by the process umask — which is what Go's os.MkdirAll / os.WriteFile +// perm arguments do too. +const ( + checkpointDirPerm os.FileMode = 0o777 + checkpointFilePerm os.FileMode = 0o666 +) + +// CheckpointPath is `self.checkpoint_dir / f"checkpoint-{phase}.json"`. +func (o *ScanOrchestrator) CheckpointPath(phase string) string { + return filepath.Join(o.CheckpointDir, "checkpoint-"+phase+".json") +} + +// WriteCheckpoint ports _write_checkpoint for a single pydantic model. +// +// Python: +// +// self.checkpoint_dir.mkdir(parents=True, exist_ok=True) +// path = self.checkpoint_dir / f"checkpoint-{phase}.json" +// data = 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 body's key order (phase, created_at, data) is observable in the file, so +// it is built as a pyfmt.Ordered rather than a Go map, and rendered with +// pyfmt.Dumps — CPython's json.dumps spelling, not encoding/json's (float +// repr, ensure_ascii, no <>& escaping, ", "/": " separators). +func (o *ScanOrchestrator) WriteCheckpoint(phase string, payload any) error { + return o.writeCheckpointBody(phase, payload) +} + +// WriteCheckpointList ports the `isinstance(payload, list)` branch of +// _write_checkpoint: `data = [item.model_dump() for item in payload]`. The only +// caller is the "prove" checkpoint, which stores []VerifiedFinding. +func (o *ScanOrchestrator) WriteCheckpointList(phase string, payload []schemas.VerifiedFinding) error { + return o.writeCheckpointBody(phase, payload) +} + +func (o *ScanOrchestrator) writeCheckpointBody(phase string, data any) error { + if err := os.MkdirAll(o.CheckpointDir, checkpointDirPerm); err != nil { + return fmt.Errorf("orch: create checkpoint dir: %w", err) + } + body := pyfmt.Ordered{ + {K: "phase", V: phase}, + {K: "created_at", V: o.nowUTC().ISOFormat()}, + {K: "data", V: data}, + } + 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 +} + +// ReadCheckpoint ports _read_checkpoint: +// +// payload = json.loads(path.read_text(encoding="utf-8")) +// return schema(**payload.get("data", {})) +// +// Nothing in cloudsecurity-af calls it — there is no resume-from-checkpoint +// path, unlike sec-af — but it is part of the class and is ported so the +// checkpoint format has a reader that a future resume path (and this package's +// tests) can use. +// +// Python parity: a checkpoint whose top level is not an object, or whose "data" +// is not an object, raises; so does this, via the JSON decode. +func ReadCheckpoint[T any](o *ScanOrchestrator, phase string) (T, error) { + var zero T + raw, err := os.ReadFile(o.CheckpointPath(phase)) + if err != nil { + return zero, fmt.Errorf("orch: read checkpoint: %w", err) + } + var body map[string]json.RawMessage + if err := json.Unmarshal(raw, &body); err != nil { + return zero, fmt.Errorf("orch: decode checkpoint: %w", err) + } + data, present := body["data"] + if !present { + // Python parity: payload.get("data", {}) -> schema(**{}). + return afx.Bind[T](map[string]any{}) + } + var asMap map[string]any + if err := json.Unmarshal(data, &asMap); err != nil { + return zero, fmt.Errorf("orch: decode checkpoint data: %w", err) + } + return afx.Bind[T](asMap) +} diff --git a/go/internal/orch/checkpoint_test.go b/go/internal/orch/checkpoint_test.go new file mode 100644 index 0000000..b58034e --- /dev/null +++ b/go/internal/orch/checkpoint_test.go @@ -0,0 +1,179 @@ +package orch + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// wantChainCheckpoint is the literal file CPython writes for +// +// _write_checkpoint("chain", ChainResult(attack_paths=[], total_paths_evaluated=3, +// viable_paths=1, chain_duration_seconds=1.5)) +// +// with created_at pinned — captured from this repo's venv interpreter. +const wantChainCheckpoint = `{ + "phase": "chain", + "created_at": "2026-01-02T03:04:05.123456+00:00", + "data": { + "attack_paths": [], + "total_paths_evaluated": 3, + "viable_paths": 1, + "chain_duration_seconds": 1.5 + } +}` + +// wantProveCheckpoint is the same for the list branch, one VerifiedFinding. +// Note it is a FULL model_dump: the null optional fields are present, unlike the +// exclude_none dumps the phases return. +const wantProveCheckpoint = `{ + "phase": "prove", + "created_at": "2026-01-02T03:04:05.123456+00:00", + "data": [ + { + "id": "z", + "title": "t", + "verdict": "confirmed", + "severity": "high", + "category": "public_access", + "resources": [], + "attack_path": null, + "drift": null, + "proof": { + "method": "static_analysis", + "evidence": [], + "scripts_executed": [], + "verification_tier": "static" + }, + "compliance_mappings": [], + "risk_score": 0.0, + "remediation": null, + "sarif_rule_id": "r", + "sarif_security_severity": 0.0, + "iac_file": "main.tf", + "iac_line": 2, + "config_snippet": "", + "description": "d", + "fingerprint": "fp", + "hunter_strategy": "iam", + "drop_reason": null + } + ] +}` + +// TestWriteCheckpoint_MatchesPythonBytes compares the Go checkpoint file with +// the exact bytes json.dumps(body, indent=2) produces in CPython. +func TestWriteCheckpoint_MatchesPythonBytes(t *testing.T) { + o := newTestOrchestrator(t, &appx.Fake{}, scanInput(t, nil), fixedClock()) + + chain := schemas.NewChainResult() + chain.TotalPathsEvaluated = 3 + chain.ViablePaths = 1 + chain.ChainDurationSeconds = 1.5 + if err := o.WriteCheckpoint("chain", chain); err != nil { + t.Fatalf("WriteCheckpoint: %v", err) + } + got, err := os.ReadFile(o.CheckpointPath("chain")) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(got) != wantChainCheckpoint { + t.Fatalf("chain checkpoint mismatch\n--- got ---\n%s\n--- want ---\n%s", got, wantChainCheckpoint) + } + + finding := verifiedFinding("z", schemas.VerdictConfirmed, scoring.SeverityHigh) + if err := o.WriteCheckpointList("prove", []schemas.VerifiedFinding{finding}); err != nil { + t.Fatalf("WriteCheckpointList: %v", err) + } + got, err = os.ReadFile(o.CheckpointPath("prove")) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(got) != wantProveCheckpoint { + t.Fatalf("prove checkpoint mismatch\n--- got ---\n%s\n--- want ---\n%s", got, wantProveCheckpoint) + } +} + +// TestWriteCheckpoint_CreatesTheDirectoryTree covers mkdir(parents=True). +func TestWriteCheckpoint_CreatesTheDirectoryTree(t *testing.T) { + o := newTestOrchestrator(t, &appx.Fake{}, scanInput(t, nil), fixedClock()) + o.CheckpointDir = filepath.Join(t.TempDir(), "deep", "nested", ".cloudsecurity") + if err := o.WriteCheckpoint("recon", schemas.NewReconResult()); err != nil { + t.Fatalf("WriteCheckpoint: %v", err) + } + if _, err := os.Stat(o.CheckpointPath("recon")); err != nil { + t.Fatalf("checkpoint missing: %v", err) + } +} + +// TestCheckpointPath pins the f"checkpoint-{phase}.json" name. +func TestCheckpointPath(t *testing.T) { + o := newTestOrchestrator(t, &appx.Fake{}, scanInput(t, nil), fixedClock()) + want := filepath.Join(o.CheckpointDir, "checkpoint-hunt.json") + if got := o.CheckpointPath("hunt"); got != want { + t.Fatalf("CheckpointPath = %q, want %q", got, want) + } +} + +// TestReadCheckpoint_RoundTrip covers _read_checkpoint's +// `schema(**payload.get("data", {}))`. +func TestReadCheckpoint_RoundTrip(t *testing.T) { + o := newTestOrchestrator(t, &appx.Fake{}, scanInput(t, nil), fixedClock()) + + hunt := schemas.NewHuntResult() + hunt.Findings = []schemas.RawFinding{rawFinding("f1", scoring.SeverityCritical, "fp1")} + hunt.TotalRaw = 9 + hunt.DeduplicatedCount = 1 + hunt.StrategiesRun = []string{"iam"} + hunt.HuntDurationSeconds = 2.25 + if err := o.WriteCheckpoint("hunt", hunt); err != nil { + t.Fatalf("WriteCheckpoint: %v", err) + } + + got, err := ReadCheckpoint[schemas.HuntResult](o, "hunt") + if err != nil { + t.Fatalf("ReadCheckpoint: %v", err) + } + if got.TotalRaw != 9 || got.DeduplicatedCount != 1 || got.HuntDurationSeconds != 2.25 { + t.Fatalf("round trip = %#v", got) + } + if len(got.Findings) != 1 || got.Findings[0].ID != "f1" || + got.Findings[0].EstimatedSeverity != scoring.SeverityCritical { + t.Fatalf("findings = %#v", got.Findings) + } + if !equalStrings(got.StrategiesRun, []string{"iam"}) { + t.Fatalf("strategies_run = %v", got.StrategiesRun) + } +} + +// TestReadCheckpoint_MissingDataKeySeedsDefaults covers the +// `payload.get("data", {})` fallback. +func TestReadCheckpoint_MissingDataKeySeedsDefaults(t *testing.T) { + o := newTestOrchestrator(t, &appx.Fake{}, scanInput(t, nil), fixedClock()) + if err := os.MkdirAll(o.CheckpointDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + body := []byte(`{"phase": "chain", "created_at": "2026-01-02T03:04:05+00:00"}`) + if err := os.WriteFile(o.CheckpointPath("chain"), body, 0o600); err != nil { + t.Fatalf("write: %v", err) + } + got, err := ReadCheckpoint[schemas.ChainResult](o, "chain") + if err != nil { + t.Fatalf("ReadCheckpoint: %v", err) + } + if got.AttackPaths == nil || len(got.AttackPaths) != 0 { + t.Fatalf("attack_paths = %#v, want the [] default", got.AttackPaths) + } +} + +// TestReadCheckpoint_MissingFileErrors. +func TestReadCheckpoint_MissingFileErrors(t *testing.T) { + o := newTestOrchestrator(t, &appx.Fake{}, scanInput(t, nil), fixedClock()) + if _, err := ReadCheckpoint[schemas.ChainResult](o, "nope"); err == nil { + t.Fatal("expected an error for a missing checkpoint") + } +} diff --git a/go/internal/orch/doc.go b/go/internal/orch/doc.go new file mode 100644 index 0000000..88c4127 --- /dev/null +++ b/go/internal/orch/doc.go @@ -0,0 +1,36 @@ +// Package orch ports src/cloudsecurity_af/orchestrator.py — the ScanOrchestrator +// that drives one CloudSecurity scan end to end. +// +// Run() is THE DAG driver: five sequential `app.call`s, one per phase, each with +// exactly the kwargs Python passes. Nothing here calls a phase function +// in-process; every phase is a control-plane child execution, which is what +// makes the workflow tree in the UI identical to the Python node's. +// +// scan / prove +// ├── recon_phase +// ├── hunt_phase +// ├── chain_phase +// ├── prove_phase +// └── remediation_phase +// +// Parity notes that apply package-wide: +// +// - Envelope handling uses afx.UnwrapStrict + afx.AsMap, the exact ports of +// the _unwrap/_as_dict pair orchestrator.py defines at module scope (a +// byte-identical copy of the pair in reasoners/phases.py). +// - NODE_ID is read inside Run(), exactly as Python's +// `node_id = os.getenv("NODE_ID", "cloudsecurity")` does. +// - time.monotonic() becomes a nowFn seam defaulting to time.Now, whose +// result carries Go's monotonic reading; datetime.now(UTC) becomes +// schemas.NewTimestamp(nowFn().UTC()). Both are injectable for tests and +// neither changes live behaviour. +// - app.py MUTATES repo_path and checkpoint_dir after constructing the +// orchestrator, so RepoPath and CheckpointDir are exported settable fields. +// Config.RepoPath deliberately keeps the CLOUDSECURITY_REPO_PATH/cwd value +// New() computed — Python never re-derives it either. +// - _emit_progress builds a ScanProgress and DROPS it. It is ported as a +// builder that returns the value and emits nothing, so the (unused) math is +// still covered by tests and a future `app.note` wiring has a home. +// - _PhaseHarnessProxy exists in Python but no code path reaches it; it is +// ported minimally, for the budget/cost bookkeeping it documents. +package orch diff --git a/go/internal/orch/helpers_test.go b/go/internal/orch/helpers_test.go new file mode 100644 index 0000000..ab01a56 --- /dev/null +++ b/go/internal/orch/helpers_test.go @@ -0,0 +1,251 @@ +package orch + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/afx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// fixedTime is the instant every deterministic test pins its clock to. It has a +// non-zero microsecond field so the Timestamp/isoformat fraction is exercised. +var fixedTime = time.Date(2026, 1, 2, 3, 4, 5, 123456000, time.UTC) + +// fixedClock never advances: every elapsed duration is exactly 0. +func fixedClock() func() time.Time { + return func() time.Time { return fixedTime } +} + +// steppingClock advances by step on every call after the first. +func steppingClock(step time.Duration) func() time.Time { + calls := 0 + return func() time.Time { + t := fixedTime.Add(time.Duration(calls) * step) + calls++ + return t + } +} + +// newTestOrchestrator builds an orchestrator rooted at a scratch repo directory +// with a frozen clock. It sets CLOUDSECURITY_REPO_PATH so New's own resolution +// is exercised rather than bypassed. +func newTestOrchestrator(t *testing.T, app appx.App, input schemas.CloudSecurityInput, nowFn func() time.Time) *ScanOrchestrator { + t.Helper() + repo := t.TempDir() + t.Setenv("CLOUDSECURITY_REPO_PATH", repo) + o, err := NewWithClock(app, input, nowFn) + if err != nil { + t.Fatalf("NewWithClock: %v", err) + } + return o +} + +// scanInput mirrors the Python probe's CloudSecurityInput construction. +func scanInput(t *testing.T, mutate func(*schemas.CloudSecurityInput)) schemas.CloudSecurityInput { + t.Helper() + in := schemas.NewCloudSecurityInput() + in.RepoURL = "https://example.com/r.git" + in.Depth = "quick" + in.SeverityThreshold = "low" + in.ComplianceFrameworks = []string{"cis_aws"} + if mutate != nil { + mutate(&in) + } + return in +} + +// verifiedFinding mirrors the probe's vf() helper. +func verifiedFinding(id string, verdict schemas.Verdict, severity scoring.Severity) schemas.VerifiedFinding { + v := schemas.NewVerifiedFinding() + v.ID = id + v.Title = "t" + v.Verdict = verdict + v.Severity = severity + v.Category = "public_access" + v.IaCFile = "main.tf" + v.IaCLine = 2 + v.ConfigSnippet = "" + v.Description = "d" + v.Fingerprint = "fp" + v.HunterStrategy = "iam" + v.SARIFRuleID = "r" + v.SARIFSecuritySeverity = 0.0 + return v +} + +func rawFinding(id string, severity scoring.Severity, fingerprint string) schemas.RawFinding { + f := schemas.NewRawFinding() + f.ID = id + f.HunterStrategy = "iam" + f.Title = "t" + f.Description = "d" + f.Category = "public_access" + f.EstimatedSeverity = severity + f.IaCFile = "main.tf" + f.IaCLine = 1 + f.Fingerprint = fingerprint + return f +} + +func jsonMap(t *testing.T, v any) map[string]any { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal %T: %v", v, err) + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("unmarshal %T: %v", v, err) + } + return out +} + +func keysOf(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sortStrings(out) + return out +} + +func sortStrings(s []string) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j] < s[j-1]; j-- { + s[j], s[j-1] = s[j-1], s[j] + } + } +} + +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 floatPtr(f float64) *float64 { return &f } +func intPtr(i int) *int { return &i } +func stringPtr(s string) *string { return &s } + +// --- bindVerifiedList: the required-field half of model_validate ------------- + +// VALIDATION CONTRACT — orchestrator.py:121/:131 +// `[VerifiedFinding.model_validate(v) for v in prove_dict["verified"]]`. +// +// Ground truth from the repo venv (VerifiedFinding declares title, verdict, +// severity and category with no defaults): +// +// model_validate({"id": "x", "iac_file": "main.tf"}) -> ValidationError, 4 errors +// model_validate({"title": "t", "severity": "high", "category": "c"}) -> ValidationError +// model_validate({... "verdict": "bogus" ...}) -> ValidationError +// model_validate(5) -> ValidationError +// +// and every one of those is a ValueError SUBCLASS, i.e. app.py's 400 branch. +// A bulk json.Unmarshal into []VerifiedFinding accepts all but the bad enum and +// yields verdict "" — uncounted in the verdict tallies, a bogus "" key in +// by_severity, and dropped by the default severity_threshold. +func TestBindVerifiedList_MissingRequiredFieldsRaise(t *testing.T) { + for name, element := range map[string]any{ + "no required field at all": map[string]any{"id": "x", "iac_file": "main.tf"}, + "verdict missing": map[string]any{"title": "t", "severity": "high", "category": "c"}, + } { + _, err := bindVerifiedList(map[string]any{"verified": []any{element}}) + if err == nil { + t.Fatalf("%s: expected a validation failure, got none", name) + } + var validation *afx.ValidationError + if !errors.As(err, &validation) { + t.Fatalf("%s: error %v (%T) is not ValueError-class, so app.py's 400 branch is unreachable", name, err, err) + } + var missing *afx.MissingFieldError + if !errors.As(err, &missing) || missing.Model != "VerifiedFinding" { + t.Fatalf("%s: expected a MissingFieldError for VerifiedFinding, got %v", name, err) + } + } +} + +// A bad enum value is also ValueError-class (it already was, via +// Verdict.UnmarshalJSON) — pinned so the routing change keeps it that way. +func TestBindVerifiedList_BadEnumIsValueErrorClass(t *testing.T) { + _, err := bindVerifiedList(map[string]any{"verified": []any{ + map[string]any{"title": "t", "verdict": "bogus", "severity": "high", "category": "c"}, + }}) + var validation *afx.ValidationError + if !errors.As(err, &validation) { + t.Fatalf("error %v (%T) is not ValueError-class", err, err) + } +} + +// A non-dict element is a ValidationError in pydantic too. +func TestBindVerifiedList_NonDictElementIsValueErrorClass(t *testing.T) { + _, err := bindVerifiedList(map[string]any{"verified": []any{5}}) + var validation *afx.ValidationError + if !errors.As(err, &validation) { + t.Fatalf("error %v (%T) is not ValueError-class", err, err) + } +} + +// A complete element still binds, an empty list still binds to an empty (not +// nil) slice, and a missing key is still the KeyError-equivalent — which is NOT +// ValueError-class, so Python answers it 500. +func TestBindVerifiedList_HappyPathAndMissingKey(t *testing.T) { + got, err := bindVerifiedList(map[string]any{"verified": []any{ + map[string]any{"title": "t", "verdict": "confirmed", "severity": "high", "category": "iam"}, + }}) + if err != nil { + t.Fatalf("bindVerifiedList: %v", err) + } + if len(got) != 1 || got[0].Verdict != schemas.VerdictConfirmed || got[0].Title != "t" { + t.Fatalf("bound %+v, want one confirmed finding titled t", got) + } + + empty, err := bindVerifiedList(map[string]any{"verified": []any{}}) + if err != nil || empty == nil || len(empty) != 0 { + t.Fatalf("empty list -> (%v, %v), want an empty non-nil slice", empty, err) + } + + _, err = bindVerifiedList(map[string]any{}) + if !errors.Is(err, errMissingVerifiedKey) { + t.Fatalf("missing key -> %v, want errMissingVerifiedKey", err) + } + var validation *afx.ValidationError + if errors.As(err, &validation) { + t.Fatal("a missing 'verified' key is a KeyError in Python, not a ValueError") + } +} + +// A phase whose execution stored a null result reaches the orchestrator as a +// NIL map with a nil error from the SDK. Python sees None and raises +// RuntimeError (500), so the orchestrator must not bind it as an empty dict — +// which for HuntResult/ChainResult (no required fields) would produce a +// fully-defaulted, zero-finding phase result and a 200 "clean" scan. +func TestCallDict_NilReplyIsTheNoneTypeRuntimeError(t *testing.T) { + app := &appx.Fake{ + CallFn: func(ctx context.Context, target string, input map[string]any) (map[string]any, error) { + return nil, nil // the SDK's "succeeded, stored result was null" + }, + } + _, err := callDict(context.Background(), app, "cloudsecurity.hunt_phase", "hunt_phase", map[string]any{}) + if err == nil { + t.Fatal("a null phase reply was accepted; Python raises RuntimeError") + } + if want := "hunt_phase returned non-dict payload: NoneType"; err.Error() != want { + t.Errorf("error = %q, want %q", err, want) + } + var validation *afx.ValidationError + if errors.As(err, &validation) { + t.Error("a RuntimeError-equivalent must not be ValueError-class (Python answers it 500, not 400)") + } +} diff --git a/go/internal/orch/orchestrator.go b/go/internal/orch/orchestrator.go new file mode 100644 index 0000000..3d18010 --- /dev/null +++ b/go/internal/orch/orchestrator.go @@ -0,0 +1,472 @@ +package orch + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/afx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/agents/util" + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/config" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// DefaultNodeID is the fallback in `os.getenv("NODE_ID", "cloudsecurity")`. +const DefaultNodeID = config.DefaultNodeID + +// CheckpointDirName is the `.cloudsecurity` directory `self.repo_path / +// ".cloudsecurity"` names. +const CheckpointDirName = ".cloudsecurity" + +// PhaseOrder ports ScanOrchestrator._PHASE_ORDER. It seeds cost_breakdown's +// keys, so its ORDER is observable in the scan result's cost_breakdown dict in +// Python (insertion-ordered). A Go map cannot carry that order, so the single +// declaration lives in schemas next to the field and every renderer +// (internal/output's report and JSON artifacts) walks it from there rather than +// sorting. +var PhaseOrder = schemas.CostBreakdownOrder + +// ScanOrchestrator ports orchestrator.py ScanOrchestrator. +// +// Field names follow the Python attributes one for one. Everything Python +// mutates from the outside (app.py rewrites repo_path and checkpoint_dir after +// construction) is exported. +type ScanOrchestrator struct { + // App is Python's `self.app = cast("Any", app)`. + App appx.App + // Input is Python's `self.input`. + Input schemas.CloudSecurityInput + + // StartedAt is `self.started_at = time.monotonic()`. + StartedAt time.Time + // RepoPath is `Path(os.getenv("CLOUDSECURITY_REPO_PATH", os.getcwd())).resolve()`, + // which app.py then OVERWRITES with the resolved repo checkout. + RepoPath string + // CheckpointDir is `self.repo_path / ".cloudsecurity"`, likewise rewritten + // by app.py. + CheckpointDir string + + // Config is `ScanConfig.from_input(self.input, str(self.repo_path))`. + Config config.ScanConfig + // BudgetConfig is `self.config.budget`. + BudgetConfig config.BudgetConfig + + MaxCostUSD *float64 + MaxDurationSeconds *int + + TotalCostUSD float64 + CostBreakdown map[string]float64 + AgentInvocations int + BudgetExhausted bool + + FindingsNotVerified int + + // nowFn is the test seam behind both time.monotonic() and + // datetime.now(UTC). Never nil after New; NewWithClock injects a stub. + nowFn func() time.Time +} + +// New ports `ScanOrchestrator(app=app, input=input)`. +// +// Python: +// +// self.started_at = time.monotonic() +// self.repo_path = Path(os.getenv("CLOUDSECURITY_REPO_PATH", os.getcwd())).resolve() +// self.checkpoint_dir = self.repo_path / ".cloudsecurity" +// self.config = ScanConfig.from_input(self.input, str(self.repo_path)) +// self.budget_config = self.config.budget +// self.max_cost_usd = input.max_cost_usd +// self.max_duration_seconds = input.max_duration_seconds +// self.total_cost_usd = 0.0 +// self.cost_breakdown = {phase: 0.0 for phase in self._PHASE_ORDER} +// self.agent_invocations = 0 +// self.budget_exhausted = False +// self.findings_not_verified = 0 +// +// Python parity: ScanConfig.from_input parses `depth` with the STRICT +// DepthProfile constructor, so an unrecognized depth raises a ValueError here — +// before run() is ever awaited. app.py's try/except only wraps run(), so that +// ValueError escapes as a 500 rather than the 400 the depth error looks like it +// should be. The Go port returns the error from New for the node to map, and +// the node must reproduce whichever status it wants deliberately. +func New(app appx.App, input schemas.CloudSecurityInput) (*ScanOrchestrator, error) { + return NewWithClock(app, input, time.Now) +} + +// NewWithClock is New with an injectable clock, used by tests to make +// duration_seconds and timestamp deterministic. nowFn nil means time.Now. +func NewWithClock(app appx.App, input schemas.CloudSecurityInput, nowFn func() time.Time) (*ScanOrchestrator, error) { + if nowFn == nil { + nowFn = time.Now + } + + repoPath, err := defaultRepoPath() + if err != nil { + return nil, err + } + + cfg, err := config.ScanConfigFromInput(input, repoPath) + if err != nil { + return nil, err + } + + costBreakdown := make(map[string]float64, len(PhaseOrder)) + for _, phase := range PhaseOrder { + costBreakdown[phase] = 0.0 + } + + return &ScanOrchestrator{ + App: app, + Input: input, + StartedAt: nowFn(), + RepoPath: repoPath, + CheckpointDir: filepath.Join(repoPath, CheckpointDirName), + Config: cfg, + BudgetConfig: cfg.Budget, + MaxCostUSD: input.MaxCostUSD, + MaxDurationSeconds: input.MaxDurationSeconds, + TotalCostUSD: 0.0, + CostBreakdown: costBreakdown, + AgentInvocations: 0, + BudgetExhausted: false, + FindingsNotVerified: 0, + nowFn: nowFn, + }, nil +} + +// defaultRepoPath ports +// `Path(os.getenv("CLOUDSECURITY_REPO_PATH", os.getcwd())).resolve()`. +// +// Python parity: os.getenv substitutes the default only when the key is ABSENT, +// so CLOUDSECURITY_REPO_PATH="" yields Path("") — which is Path(".") — and +// resolves to the cwd anyway. util.ResolvePath("") does the same. +func defaultRepoPath() (string, error) { + raw, present := os.LookupEnv("CLOUDSECURITY_REPO_PATH") + if !present { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("orch: resolve repo path: %w", err) + } + raw = cwd + } + return util.ResolvePath(raw), nil +} + +// SetCheckpointDirFromRepoPath re-derives CheckpointDir from the current +// RepoPath. app.py does exactly this after overwriting repo_path: +// +// orchestrator.repo_path = Path(repo_path) +// orchestrator.checkpoint_dir = orchestrator.repo_path / ".cloudsecurity" +// +// Python parity: app.py does NOT re-run ScanConfig.from_input, so Config.RepoPath +// keeps pointing at the CLOUDSECURITY_REPO_PATH/cwd value. Nothing reads it, but +// the staleness is real and is reproduced rather than fixed. +func (o *ScanOrchestrator) SetCheckpointDirFromRepoPath() { + o.CheckpointDir = filepath.Join(o.RepoPath, CheckpointDirName) +} + +// NodeID ports `node_id = os.getenv("NODE_ID", "cloudsecurity")` — read inside +// run(), not at import. +// +// It delegates to config.NodeID for the same reason internal/phases does: the +// prefix of the five phase Call targets must be resolved by the identical rule +// internal/node uses for the id the agent registers under, or the orchestrator +// can emit ".recon_phase" against a node registered as "cloudsecurity". +func NodeID() string { return config.NodeID() } + +// errMissingVerifiedKey reproduces the KeyError Python raises when a phase's +// reply has no "verified" key. app.py renders it as +// `scan execution failed: 'verified'`, because str(KeyError("verified")) is the +// REPR of the key, quotes included. +var errMissingVerifiedKey = errors.New("'verified'") + +// Run ports ScanOrchestrator.run(): the five sequential phase calls, their +// duration bookkeeping, their checkpoints and the final output generation. +func (o *ScanOrchestrator) Run(ctx context.Context) (schemas.CloudSecurityScanResult, error) { + var zero schemas.CloudSecurityScanResult + nodeID := NodeID() + + // --- RECON ------------------------------------------------------------- + var cloudConfig any + if o.Input.Cloud != nil { + dumped, err := afx.ToMap(*o.Input.Cloud) + if err != nil { + return zero, err + } + cloudConfig = dumped + } + recon, err := callModel[schemas.ReconResult](ctx, o.App, + nodeID+".recon_phase", "recon_phase", + map[string]any{ + "repo_path": o.RepoPath, + "depth": string(o.Config.Depth), + "tier": o.Config.Tier, + "cloud_config": cloudConfig, + }) + if err != nil { + return zero, err + } + recon.ReconDurationSeconds = o.elapsedSeconds() + if err := o.WriteCheckpoint("recon", recon); err != nil { + return zero, err + } + o.EmitProgress("recon", 1, 1, 0) + + // --- HUNT -------------------------------------------------------------- + hunt, err := callModel[schemas.HuntResult](ctx, o.App, + nodeID+".hunt_phase", "hunt_phase", + map[string]any{ + "repo_path": o.RepoPath, + "resource_graph_path": recon.ResourceGraph.GraphSavedPath, + "inventory_path": recon.Inventory.InventorySavedPath, + "depth": string(o.Config.Depth), + "max_concurrent_hunters": o.BudgetConfig.MaxConcurrentHunters, + }) + if err != nil { + return zero, err + } + hunt.HuntDurationSeconds = o.elapsedSeconds() - recon.ReconDurationSeconds + if err := o.WriteCheckpoint("hunt", hunt); err != nil { + return zero, err + } + o.EmitProgress("hunt", 1, 1, len(hunt.Findings)) + + // --- CHAIN ------------------------------------------------------------- + findingDumps := make([]map[string]any, 0, len(hunt.Findings)) + for _, f := range hunt.Findings { + dumped, dumpErr := afx.ToMap(f) + if dumpErr != nil { + return zero, dumpErr + } + findingDumps = append(findingDumps, dumped) + } + var driftReport any + if recon.DriftReport != nil { + dumped, dumpErr := afx.ToMap(*recon.DriftReport) + if dumpErr != nil { + return zero, dumpErr + } + driftReport = dumped + } + chain, err := callModel[schemas.ChainResult](ctx, o.App, + nodeID+".chain_phase", "chain_phase", + map[string]any{ + "findings": findingDumps, + "resource_graph_path": recon.ResourceGraph.GraphSavedPath, + "drift_report": driftReport, + "depth": string(o.Config.Depth), + "max_children": o.BudgetConfig.MaxConcurrentChainChildren, + }) + if err != nil { + return zero, err + } + if err := o.WriteCheckpoint("chain", chain); err != nil { + return zero, err + } + + // --- PROVE ------------------------------------------------------------- + huntDump, err := afx.ToMap(hunt) + if err != nil { + return zero, err + } + chainDump, err := afx.ToMap(chain) + if err != nil { + return zero, err + } + proveDict, err := callDict(ctx, o.App, + nodeID+".prove_phase", "prove_phase", + map[string]any{ + "repo_path": o.RepoPath, + "hunt_result": huntDump, + "chain_result": chainDump, + "depth": string(o.Config.Depth), + "tier": o.Config.Tier, + "max_concurrent_provers": o.BudgetConfig.MaxConcurrentProvers, + }) + if err != nil { + return zero, err + } + verified, err := bindVerifiedList(proveDict) + if err != nil { + return zero, err + } + o.FindingsNotVerified = intFromPayload(proveDict, "not_verified", 0) + if err := o.WriteCheckpointList("prove", verified); err != nil { + return zero, err + } + + // --- REMEDIATION ------------------------------------------------------- + verifiedDumps := make([]map[string]any, 0, len(verified)) + for _, v := range verified { + dumped, dumpErr := afx.ToMap(v) + if dumpErr != nil { + return zero, dumpErr + } + verifiedDumps = append(verifiedDumps, dumped) + } + remediationDict, err := callDict(ctx, o.App, + nodeID+".remediation_phase", "remediation_phase", + map[string]any{ + "repo_path": o.RepoPath, + "verified_findings": verifiedDumps, + }) + if err != nil { + return zero, err + } + verified, err = bindVerifiedList(remediationDict) + if err != nil { + return zero, err + } + + // Python parity: agent_invocations is ASSIGNED here, discarding whatever + // the (unreachable) _PhaseHarnessProxy accumulated. The "+ 5" is the five + // phase reasoners themselves. + o.AgentInvocations = intFromPayload(proveDict, "total_selected", 0) + len(hunt.StrategiesRun) + 5 + + return o.GenerateOutput(recon, hunt, chain, verified), nil +} + +// elapsedSeconds ports `time.monotonic() - self.started_at`. +func (o *ScanOrchestrator) elapsedSeconds() float64 { + return o.nowFn().Sub(o.StartedAt).Seconds() +} + +// nowUTC ports `datetime.now(UTC)`. +func (o *ScanOrchestrator) nowUTC() schemas.Timestamp { + return schemas.NewTimestamp(o.nowFn().UTC()) +} + +// unwrapDict ports `_as_dict(_unwrap(raw, name), name)` — the orchestrator's own +// copy of the strict pair, byte-identical to reasoners/phases.py's. +func unwrapDict(raw any, name string) (map[string]any, error) { + payload, err := afx.UnwrapStrict(raw, name) + if err != nil { + return nil, err + } + return afx.AsMap(payload, name) +} + +// callDict performs one phase call and returns the unwrapped reply dict. +func callDict(ctx context.Context, app appx.Caller, target, name string, kwargs map[string]any) (map[string]any, error) { + raw, err := app.Call(ctx, target, kwargs) + if err != nil { + return nil, err + } + return unwrapDict(raw, name) +} + +// callModel is callDict plus `Model.model_validate(...)`. +func callModel[T any](ctx context.Context, app appx.Caller, target, name string, kwargs map[string]any) (T, error) { + var zero T + payload, err := callDict(ctx, app, target, name, kwargs) + if err != nil { + return zero, err + } + return afx.Bind[T](payload) +} + +// bindVerifiedList ports +// `[VerifiedFinding.model_validate(v) for v in payload["verified"]]` +// (orchestrator.py:121 and :131). +// +// Python parity: the subscript is a plain `[...]`, so a missing key is a +// KeyError, not a silent empty list — hence errMissingVerifiedKey rather than a +// nil result. That is NOT ValueError-class, so app.py answers it 500. +// +// Each element goes through afx.Bind, not a bulk json.Unmarshal into +// []VerifiedFinding. Bind is what carries the other half of model_validate — +// pydantic's REQUIRED-field check (VerifiedFinding declares title, verdict, +// severity and category, none of which has a default). A bulk decode accepts +// `{"id": "x", "iac_file": "main.tf"}` and yields a finding whose verdict is "" +// — uncounted in the verdict tallies, a bogus "" key in by_severity, and +// dropped outright by the default severity_threshold — where the repo venv +// raises `4 validation errors for VerifiedFinding`. Every failure Bind reports +// is an *afx.ValidationError, i.e. ValueError-class, so it reaches app.py's +// 400 branch exactly as pydantic's ValidationError does. Verified against the +// venv: model_validate of a dict missing required keys, of a bad `verdict` +// enum, and of a non-dict are all ValidationError (a ValueError subclass). +// +// The JSON round trip tolerates both `[]any` (what the control plane hands +// back) and a typed slice (what a test fake may hand back); UseNumber keeps +// integer literals inside the models' free-form fields integral, matching every +// other Bind site. +// +// DIVERGENCE (unreachable): a `verified` value that is neither a list nor a +// JSON array — a dict, say — makes Python iterate its KEYS and raise +// ValidationError per key (400); the decode below fails instead and takes the +// 500 branch. prove_phase always replies with a list. +func bindVerifiedList(payload map[string]any) ([]schemas.VerifiedFinding, error) { + raw, present := payload["verified"] + if !present { + return nil, errMissingVerifiedKey + } + encoded, err := json.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("orch: encode verified findings: %w", err) + } + dec := json.NewDecoder(bytes.NewReader(encoded)) + dec.UseNumber() + var elements []any + if err := dec.Decode(&elements); err != nil { + return nil, fmt.Errorf("orch: decode verified findings: %w", err) + } + out := make([]schemas.VerifiedFinding, 0, len(elements)) + for _, element := range elements { + obj, ok := element.(map[string]any) + if !ok { + // pydantic: "Input should be a valid dictionary or instance of + // VerifiedFinding" — a ValidationError, hence ValueError-class. + return nil, &afx.ValidationError{ + Err: fmt.Errorf("1 validation error for VerifiedFinding: Input should be a valid dictionary or instance of VerifiedFinding, got %T", element), + } + } + bound, err := afx.Bind[schemas.VerifiedFinding](obj) + if err != nil { + return nil, err + } + out = append(out, bound) + } + return out, nil +} + +// intFromPayload ports `payload.get(key, default)` for the two integer counters +// the orchestrator reads out of prove_phase's reply. +// +// Python parity caveat: Python takes whatever the key holds, so a float 3.0 +// would flow into `agent_invocations` as a float. Every value here crosses the +// control plane as JSON — where the phase wrote a Go int — so it arrives as a +// JSON number that Go decodes to float64; the conversion back to int is exact +// for every value the phase can produce. A non-numeric value (which Python +// would blow up on, one line later) falls back to the default. +func intFromPayload(payload map[string]any, key string, def int) int { + raw, present := payload[key] + if !present { + return def + } + switch v := raw.(type) { + case int: + return v + case int32: + return int(v) + case int64: + return int(v) + case float32: + return int(v) + case float64: + return int(v) + case json.Number: + if n, err := v.Int64(); err == nil { + return int(n) + } + if f, err := v.Float64(); err == nil { + return int(f) + } + } + return def +} diff --git a/go/internal/orch/orchestrator_test.go b/go/internal/orch/orchestrator_test.go new file mode 100644 index 0000000..66384f4 --- /dev/null +++ b/go/internal/orch/orchestrator_test.go @@ -0,0 +1,592 @@ +package orch + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/agents/util" + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// pipelineReplies is a scripted control plane: one canned reply per phase +// reasoner, recorded by appx.Fake. +type pipelineReplies struct { + recon schemas.ReconResult + hunt schemas.HuntResult + chain schemas.ChainResult + proveExtra map[string]any + verified []schemas.VerifiedFinding + remediated []schemas.VerifiedFinding + overrideFor map[string]func(map[string]any) (map[string]any, error) +} + +func defaultReplies(t *testing.T) *pipelineReplies { + t.Helper() + recon := schemas.NewReconResult() + recon.Inventory.InventorySavedPath = "/inv.json" + recon.Inventory.TotalResources = 3 + recon.Inventory.IaCType = "terraform" + recon.ResourceGraph.GraphSavedPath = "/graph.json" + recon.ResourceGraph.TotalNodes = 3 + recon.ResourceGraph.TotalEdges = 2 + recon.IaCType = "terraform" + recon.ProvidersDetected = []string{"aws"} + recon.TotalResources = 3 + recon.TotalEdges = 2 + + hunt := schemas.NewHuntResult() + hunt.Findings = []schemas.RawFinding{rawFinding("f1", scoring.SeverityHigh, "fp1")} + hunt.TotalRaw = 4 + hunt.DeduplicatedCount = 1 + hunt.StrategiesRun = []string{"iam", "network"} + + chain := schemas.NewChainResult() + + verified := []schemas.VerifiedFinding{verifiedFinding("f1", schemas.VerdictConfirmed, scoring.SeverityHigh)} + + return &pipelineReplies{ + recon: recon, + hunt: hunt, + chain: chain, + verified: verified, + remediated: verified, + proveExtra: map[string]any{"total_selected": 1, "total_findings": 1, "not_verified": 0}, + } +} + +func (p *pipelineReplies) fake(t *testing.T) *appx.Fake { + t.Helper() + return &appx.Fake{CallFn: func(_ context.Context, target string, in map[string]any) (map[string]any, error) { + if override, present := p.overrideFor[target]; present { + return override(in) + } + switch target { + case "cloudsecurity.recon_phase": + return jsonMap(t, p.recon), nil + case "cloudsecurity.hunt_phase": + return jsonMap(t, p.hunt), nil + case "cloudsecurity.chain_phase": + return jsonMap(t, p.chain), nil + case "cloudsecurity.prove_phase": + out := map[string]any{"verified": dumpAll(t, p.verified)} + for k, v := range p.proveExtra { + out[k] = v + } + return out, nil + case "cloudsecurity.remediation_phase": + return map[string]any{"verified": dumpAll(t, p.remediated)}, nil + } + t.Errorf("unexpected target %q", target) + return nil, nil + }} +} + +func dumpAll(t *testing.T, findings []schemas.VerifiedFinding) []any { + t.Helper() + out := make([]any, 0, len(findings)) + for _, f := range findings { + out = append(out, jsonMap(t, f)) + } + return out +} + +// TestRun_CallsFivePhasesInOrder pins the DAG's first level: five sequential +// children, in Python's order, on the NODE_ID the environment names. +func TestRun_CallsFivePhasesInOrder(t *testing.T) { + t.Setenv("NODE_ID", "cloudsecurity") + replies := defaultReplies(t) + fake := replies.fake(t) + o := newTestOrchestrator(t, fake, scanInput(t, nil), fixedClock()) + + if _, err := o.Run(context.Background()); err != nil { + t.Fatalf("Run: %v", err) + } + want := []string{ + "cloudsecurity.recon_phase", + "cloudsecurity.hunt_phase", + "cloudsecurity.chain_phase", + "cloudsecurity.prove_phase", + "cloudsecurity.remediation_phase", + } + if got := fake.CallTargets(); !equalStrings(got, want) { + t.Fatalf("targets = %v, want %v", got, want) + } +} + +// TestRun_HonoursNodeIDEnv proves the target prefix is read at call time. +func TestRun_HonoursNodeIDEnv(t *testing.T) { + t.Setenv("NODE_ID", "cloudsecurity-go") + replies := defaultReplies(t) + fake := &appx.Fake{CallFn: func(ctx context.Context, target string, in map[string]any) (map[string]any, error) { + inner := replies.fake(t) + return inner.CallFn(ctx, "cloudsecurity"+target[len("cloudsecurity-go"):], in) + }} + o := newTestOrchestrator(t, fake, scanInput(t, nil), fixedClock()) + if _, err := o.Run(context.Background()); err != nil { + t.Fatalf("Run: %v", err) + } + for _, target := range fake.CallTargets() { + if len(target) < len("cloudsecurity-go.") || target[:len("cloudsecurity-go.")] != "cloudsecurity-go." { + t.Fatalf("target %q does not use the NODE_ID prefix", target) + } + } +} + +// TestRun_PhaseKwargs pins every kwarg key AND the values that come from the +// resolved config/budget, for all five phases. +func TestRun_PhaseKwargs(t *testing.T) { + t.Setenv("NODE_ID", "cloudsecurity") + replies := defaultReplies(t) + fake := replies.fake(t) + input := scanInput(t, func(in *schemas.CloudSecurityInput) { + in.Depth = "quick" + in.MaxConcurrentHunters = intPtr(2) + in.MaxConcurrentProvers = intPtr(5) + }) + o := newTestOrchestrator(t, fake, input, fixedClock()) + if _, err := o.Run(context.Background()); err != nil { + t.Fatalf("Run: %v", err) + } + + byTarget := map[string]map[string]any{} + for _, c := range fake.Calls { + byTarget[c.Target] = c.Input + } + + recon := byTarget["cloudsecurity.recon_phase"] + if got := keysOf(recon); !equalStrings(got, []string{"cloud_config", "depth", "repo_path", "tier"}) { + t.Errorf("recon_phase kwargs = %v", got) + } + if recon["repo_path"] != o.RepoPath || recon["depth"] != "quick" || recon["tier"] != 1 { + t.Errorf("recon_phase values = %#v", recon) + } + if recon["cloud_config"] != nil { + t.Errorf("cloud_config should be nil for a static scan, got %#v", recon["cloud_config"]) + } + + hunt := byTarget["cloudsecurity.hunt_phase"] + if got := keysOf(hunt); !equalStrings(got, []string{ + "depth", "inventory_path", "max_concurrent_hunters", "repo_path", "resource_graph_path"}) { + t.Errorf("hunt_phase kwargs = %v", got) + } + if hunt["resource_graph_path"] != "/graph.json" || hunt["inventory_path"] != "/inv.json" { + t.Errorf("hunt_phase paths = %#v", hunt) + } + if hunt["max_concurrent_hunters"] != 2 { + t.Errorf("max_concurrent_hunters = %v, want the budget override 2", hunt["max_concurrent_hunters"]) + } + + chain := byTarget["cloudsecurity.chain_phase"] + if got := keysOf(chain); !equalStrings(got, []string{ + "depth", "drift_report", "findings", "max_children", "resource_graph_path"}) { + t.Errorf("chain_phase kwargs = %v", got) + } + if chain["drift_report"] != nil { + t.Errorf("drift_report should be nil without a drift report, got %#v", chain["drift_report"]) + } + if chain["max_children"] != 3 { + t.Errorf("max_children = %v", chain["max_children"]) + } + findings, ok := chain["findings"].([]map[string]any) + if !ok || len(findings) != 1 || findings[0]["id"] != "f1" { + t.Errorf("chain findings = %#v", chain["findings"]) + } + + prove := byTarget["cloudsecurity.prove_phase"] + if got := keysOf(prove); !equalStrings(got, []string{ + "chain_result", "depth", "hunt_result", "max_concurrent_provers", "repo_path", "tier"}) { + t.Errorf("prove_phase kwargs = %v", got) + } + if prove["max_concurrent_provers"] != 5 { + t.Errorf("max_concurrent_provers = %v, want the budget override 5", prove["max_concurrent_provers"]) + } + if prove["tier"] != 1 { + t.Errorf("tier = %v", prove["tier"]) + } + + remediation := byTarget["cloudsecurity.remediation_phase"] + if got := keysOf(remediation); !equalStrings(got, []string{"repo_path", "verified_findings"}) { + t.Errorf("remediation_phase kwargs = %v", got) + } + verifiedFindings, ok := remediation["verified_findings"].([]map[string]any) + if !ok || len(verifiedFindings) != 1 { + t.Fatalf("verified_findings = %#v", remediation["verified_findings"]) + } + // The orchestrator hands over model_dump() (NOT exclude_none), so the null + // optional fields are present. + if _, present := verifiedFindings[0]["remediation"]; !present { + t.Errorf("verified_findings should be a full model_dump: %v", keysOf(verifiedFindings[0])) + } +} + +// TestRun_CloudConfigIsDumpedForTierTwo covers the +// `self.input.cloud.model_dump() if self.input.cloud else None` ternary. +func TestRun_CloudConfigIsDumpedForTierTwo(t *testing.T) { + t.Setenv("NODE_ID", "cloudsecurity") + replies := defaultReplies(t) + fake := replies.fake(t) + + cloud := schemas.NewCloudConfig() + cloud.Provider = "aws" + cloud.Regions = []string{"eu-west-1"} + cloud.AssumeRoleARN = stringPtr("arn:aws:iam::1:role/x") + input := scanInput(t, func(in *schemas.CloudSecurityInput) { in.Cloud = &cloud }) + + o := newTestOrchestrator(t, fake, input, fixedClock()) + if _, err := o.Run(context.Background()); err != nil { + t.Fatalf("Run: %v", err) + } + recon := fake.Calls[0].Input + got := jsonMap(t, recon["cloud_config"]) + if got["provider"] != "aws" || got["assume_role_arn"] != "arn:aws:iam::1:role/x" { + t.Fatalf("cloud_config = %#v", got) + } + if recon["tier"] != 2 { + t.Fatalf("tier = %v, want 2 for a cloud scan", recon["tier"]) + } + if fake.Calls[3].Input["tier"] != 2 { + t.Fatalf("prove tier = %v, want 2", fake.Calls[3].Input["tier"]) + } +} + +// TestRun_DriftReportIsForwardedToChain covers the second dump-or-None ternary. +func TestRun_DriftReportIsForwardedToChain(t *testing.T) { + t.Setenv("NODE_ID", "cloudsecurity") + replies := defaultReplies(t) + drift := schemas.NewDriftReport() + drift.CloudOnlyResources = []string{"shadow-1"} + replies.recon.DriftReport = &drift + fake := replies.fake(t) + + o := newTestOrchestrator(t, fake, scanInput(t, nil), fixedClock()) + if _, err := o.Run(context.Background()); err != nil { + t.Fatalf("Run: %v", err) + } + chain := fake.Calls[2].Input + got := jsonMap(t, chain["drift_report"]) + cloudOnly, ok := got["cloud_only_resources"].([]any) + if !ok || len(cloudOnly) != 1 || cloudOnly[0] != "shadow-1" { + t.Fatalf("drift_report = %#v", got) + } +} + +// TestRun_AgentInvocationsFormula pins +// `total_selected + len(strategies_run) + 5`. +func TestRun_AgentInvocationsFormula(t *testing.T) { + t.Setenv("NODE_ID", "cloudsecurity") + cases := []struct { + totalSelected any + strategies []string + want int + }{ + {totalSelected: 7, strategies: []string{"iam", "network"}, want: 14}, + {totalSelected: float64(7), strategies: []string{"iam", "network"}, want: 14}, + // Python parity: `strategies_run` has default_factory=list, so an empty + // HuntResult dumps `[]` — never null, which model_validate rejects. + {totalSelected: 0, strategies: []string{}, want: 5}, + {totalSelected: nil, strategies: []string{"iam"}, want: 6}, // key absent -> .get default 0 + } + for _, tc := range cases { + replies := defaultReplies(t) + replies.hunt.StrategiesRun = tc.strategies + replies.proveExtra = map[string]any{"not_verified": 0} + if tc.totalSelected != nil { + replies.proveExtra["total_selected"] = tc.totalSelected + } + fake := replies.fake(t) + o := newTestOrchestrator(t, fake, scanInput(t, nil), fixedClock()) + result, err := o.Run(context.Background()) + if err != nil { + t.Fatalf("Run: %v", err) + } + if o.AgentInvocations != tc.want || result.AgentInvocations != tc.want { + t.Fatalf("agent_invocations = %d/%d, want %d", o.AgentInvocations, result.AgentInvocations, tc.want) + } + } +} + +// TestRun_FindingsNotVerifiedIsCarriedIntoMetadata pins +// `self.findings_not_verified = prove_dict.get("not_verified", 0)` and its trip +// into the result metadata. +func TestRun_FindingsNotVerifiedIsCarriedIntoMetadata(t *testing.T) { + t.Setenv("NODE_ID", "cloudsecurity") + replies := defaultReplies(t) + replies.proveExtra = map[string]any{"total_selected": 1, "not_verified": float64(4)} + fake := replies.fake(t) + o := newTestOrchestrator(t, fake, scanInput(t, nil), fixedClock()) + result, err := o.Run(context.Background()) + if err != nil { + t.Fatalf("Run: %v", err) + } + if o.FindingsNotVerified != 4 { + t.Fatalf("FindingsNotVerified = %d", o.FindingsNotVerified) + } + if result.Metadata["findings_not_verified"] != 4 { + t.Fatalf("metadata = %#v", result.Metadata) + } +} + +// TestRun_RemediationReplyReplacesTheVerifiedList proves the result carries the +// REMEDIATED findings, not the prove-phase ones. +func TestRun_RemediationReplyReplacesTheVerifiedList(t *testing.T) { + t.Setenv("NODE_ID", "cloudsecurity") + replies := defaultReplies(t) + remediated := verifiedFinding("f1", schemas.VerdictConfirmed, scoring.SeverityHigh) + suggestion := schemas.NewRemediationSuggestion() + suggestion.FindingID = "f1" + suggestion.Description = "fix it" + remediated.Remediation = &suggestion + replies.remediated = []schemas.VerifiedFinding{remediated} + fake := replies.fake(t) + + o := newTestOrchestrator(t, fake, scanInput(t, nil), fixedClock()) + result, err := o.Run(context.Background()) + if err != nil { + t.Fatalf("Run: %v", err) + } + if len(result.Findings) != 1 || result.Findings[0].Remediation == nil { + t.Fatalf("findings = %#v", result.Findings) + } + if result.Findings[0].Remediation.Description != "fix it" { + t.Fatalf("remediation = %#v", result.Findings[0].Remediation) + } +} + +// TestRun_MissingVerifiedKeyIsAKeyError pins the KeyError parity: Python's +// prove_dict["verified"] raises KeyError('verified'), which app.py renders as +// "scan execution failed: 'verified'". +func TestRun_MissingVerifiedKeyIsAKeyError(t *testing.T) { + t.Setenv("NODE_ID", "cloudsecurity") + for _, target := range []string{"cloudsecurity.prove_phase", "cloudsecurity.remediation_phase"} { + t.Run(target, func(t *testing.T) { + replies := defaultReplies(t) + replies.overrideFor = map[string]func(map[string]any) (map[string]any, error){ + target: func(map[string]any) (map[string]any, error) { + return map[string]any{"total_selected": 0}, nil + }, + } + fake := replies.fake(t) + o := newTestOrchestrator(t, fake, scanInput(t, nil), fixedClock()) + _, err := o.Run(context.Background()) + if err == nil || err.Error() != "'verified'" { + t.Fatalf("err = %v, want 'verified'", err) + } + }) + } +} + +// TestRun_PhaseFailurePropagates covers the strict _unwrap arms at the +// orchestrator level and proves the pipeline stops at the failing phase. +func TestRun_PhaseFailurePropagates(t *testing.T) { + t.Setenv("NODE_ID", "cloudsecurity") + cases := []struct { + target string + reply map[string]any + callErr error + wantErr string + wantCalls int + }{ + { + target: "cloudsecurity.recon_phase", + reply: map[string]any{"error_message": "no iac"}, + wantErr: "recon_phase failed: no iac", + wantCalls: 1, + }, + { + target: "cloudsecurity.hunt_phase", + reply: map[string]any{"status": "failed", "error_message": "hunters died"}, + wantErr: "hunt_phase failed: hunters died", + wantCalls: 2, + }, + { + target: "cloudsecurity.chain_phase", + reply: map[string]any{"error": map[string]any{"detail": "no graph"}}, + wantErr: "chain_phase failed: no graph", + wantCalls: 3, + }, + { + target: "cloudsecurity.prove_phase", + callErr: errors.New("control plane unreachable"), + wantErr: "control plane unreachable", + wantCalls: 4, + }, + } + for _, tc := range cases { + t.Run(tc.target, func(t *testing.T) { + replies := defaultReplies(t) + replies.overrideFor = map[string]func(map[string]any) (map[string]any, error){ + tc.target: func(map[string]any) (map[string]any, error) { return tc.reply, tc.callErr }, + } + fake := replies.fake(t) + o := newTestOrchestrator(t, fake, scanInput(t, nil), fixedClock()) + _, err := o.Run(context.Background()) + if err == nil || err.Error() != tc.wantErr { + t.Fatalf("err = %v, want %q", err, tc.wantErr) + } + if len(fake.Calls) != tc.wantCalls { + t.Fatalf("calls = %d, want %d (%v)", len(fake.Calls), tc.wantCalls, fake.CallTargets()) + } + }) + } +} + +// TestRun_WritesFourCheckpoints: recon, hunt, chain and prove get a checkpoint; +// remediation does NOT. +func TestRun_WritesFourCheckpoints(t *testing.T) { + t.Setenv("NODE_ID", "cloudsecurity") + replies := defaultReplies(t) + fake := replies.fake(t) + o := newTestOrchestrator(t, fake, scanInput(t, nil), fixedClock()) + if _, err := o.Run(context.Background()); err != nil { + t.Fatalf("Run: %v", err) + } + + entries, err := os.ReadDir(o.CheckpointDir) + if err != nil { + t.Fatalf("read checkpoint dir: %v", err) + } + got := make([]string, 0, len(entries)) + for _, e := range entries { + got = append(got, e.Name()) + } + sortStrings(got) + want := []string{"checkpoint-chain.json", "checkpoint-hunt.json", "checkpoint-prove.json", "checkpoint-recon.json"} + if !equalStrings(got, want) { + t.Fatalf("checkpoints = %v, want %v", got, want) + } +} + +// TestRun_DurationsAreDerivedFromTheMonotonicClock pins +// recon_duration_seconds = elapsed and +// hunt_duration_seconds = elapsed - recon_duration. +func TestRun_DurationsAreDerivedFromTheMonotonicClock(t *testing.T) { + t.Setenv("NODE_ID", "cloudsecurity") + replies := defaultReplies(t) + fake := replies.fake(t) + // StartedAt consumes call 0; each later nowFn call advances by one second. + o := newTestOrchestrator(t, fake, scanInput(t, nil), steppingClock(1000*1000*1000)) + result, err := o.Run(context.Background()) + if err != nil { + t.Fatalf("Run: %v", err) + } + // recon checkpoint holds the duration the orchestrator stamped. + recon, err := ReadCheckpoint[schemas.ReconResult](o, "recon") + if err != nil { + t.Fatalf("ReadCheckpoint: %v", err) + } + if recon.ReconDurationSeconds <= 0 { + t.Fatalf("recon_duration_seconds = %v, want > 0", recon.ReconDurationSeconds) + } + hunt, err := ReadCheckpoint[schemas.HuntResult](o, "hunt") + if err != nil { + t.Fatalf("ReadCheckpoint: %v", err) + } + if hunt.HuntDurationSeconds <= 0 { + t.Fatalf("hunt_duration_seconds = %v, want > 0", hunt.HuntDurationSeconds) + } + if result.DurationSeconds <= recon.ReconDurationSeconds { + t.Fatalf("duration_seconds = %v, want > recon %v", result.DurationSeconds, recon.ReconDurationSeconds) + } +} + +// TestNew_RepoPathAndCheckpointDir covers the constructor's path resolution and +// the app.py-style override. +func TestNew_RepoPathAndCheckpointDir(t *testing.T) { + repo := t.TempDir() + t.Setenv("CLOUDSECURITY_REPO_PATH", repo) + o, err := New(&appx.Fake{}, scanInput(t, nil)) + if err != nil { + t.Fatalf("New: %v", err) + } + want := util.ResolvePath(repo) + if o.RepoPath != want { + t.Fatalf("RepoPath = %q, want %q", o.RepoPath, want) + } + if o.CheckpointDir != filepath.Join(want, ".cloudsecurity") { + t.Fatalf("CheckpointDir = %q", o.CheckpointDir) + } + if o.Config.RepoPath != want { + t.Fatalf("Config.RepoPath = %q", o.Config.RepoPath) + } + + // app.py's override. + other := t.TempDir() + o.RepoPath = other + o.SetCheckpointDirFromRepoPath() + if o.CheckpointDir != filepath.Join(other, ".cloudsecurity") { + t.Fatalf("CheckpointDir after override = %q", o.CheckpointDir) + } + // Python parity: ScanConfig is NOT re-derived, so its repo_path goes stale. + if o.Config.RepoPath != want { + t.Fatalf("Config.RepoPath should stay stale, got %q", o.Config.RepoPath) + } +} + +// TestNew_SeedsBudgetAndCostBreakdown covers the remaining constructor state. +func TestNew_SeedsBudgetAndCostBreakdown(t *testing.T) { + t.Setenv("CLOUDSECURITY_REPO_PATH", t.TempDir()) + input := scanInput(t, func(in *schemas.CloudSecurityInput) { + in.MaxCostUSD = floatPtr(2.5) + in.MaxDurationSeconds = intPtr(600) + }) + o, err := New(&appx.Fake{}, input) + if err != nil { + t.Fatalf("New: %v", err) + } + if o.MaxCostUSD == nil || *o.MaxCostUSD != 2.5 { + t.Errorf("MaxCostUSD = %v", o.MaxCostUSD) + } + if o.MaxDurationSeconds == nil || *o.MaxDurationSeconds != 600 { + t.Errorf("MaxDurationSeconds = %v", o.MaxDurationSeconds) + } + if o.TotalCostUSD != 0 || o.AgentInvocations != 0 || o.BudgetExhausted || o.FindingsNotVerified != 0 { + t.Errorf("counters = %#v", o) + } + if len(o.CostBreakdown) != len(PhaseOrder) { + t.Fatalf("cost_breakdown = %#v", o.CostBreakdown) + } + for _, phase := range PhaseOrder { + if v, present := o.CostBreakdown[phase]; !present || v != 0.0 { + t.Fatalf("cost_breakdown[%q] = %v (present=%v)", phase, v, present) + } + } + if o.BudgetConfig.MaxConcurrentHunters != 4 || o.BudgetConfig.MaxConcurrentProvers != 3 || + o.BudgetConfig.MaxConcurrentChainChildren != 3 { + t.Fatalf("budget = %#v", o.BudgetConfig) + } +} + +// TestNew_RejectsUnknownDepth pins the ValueError ScanConfig.from_input raises +// before run() is ever reached. +func TestNew_RejectsUnknownDepth(t *testing.T) { + t.Setenv("CLOUDSECURITY_REPO_PATH", t.TempDir()) + input := scanInput(t, func(in *schemas.CloudSecurityInput) { in.Depth = "extreme" }) + if _, err := New(&appx.Fake{}, input); err == nil || + err.Error() != "'extreme' is not a valid DepthProfile" { + t.Fatalf("err = %v", err) + } +} + +// TestRun_EmitsNoNotes pins the fact that orchestrator.py never calls app.note +// — not even from _emit_progress, which builds a ScanProgress and drops it. +func TestRun_EmitsNoNotes(t *testing.T) { + t.Setenv("NODE_ID", "cloudsecurity") + replies := defaultReplies(t) + fake := replies.fake(t) + o := newTestOrchestrator(t, fake, scanInput(t, nil), fixedClock()) + if _, err := o.Run(context.Background()); err != nil { + t.Fatalf("Run: %v", err) + } + if len(fake.Notes) != 0 { + t.Fatalf("the orchestrator must not emit notes, got %v", fake.NoteMessages()) + } + if len(fake.Harnesses) != 0 || len(fake.AIs) != 0 { + t.Fatalf("the orchestrator must only use app.call, got %d harness / %d ai calls", + len(fake.Harnesses), len(fake.AIs)) + } +} diff --git a/go/internal/orch/output.go b/go/internal/orch/output.go new file mode 100644 index 0000000..d36fbf6 --- /dev/null +++ b/go/internal/orch/output.go @@ -0,0 +1,203 @@ +package orch + +import ( + "strings" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/output" + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// ProofToEvidence ports orchestrator.py's module-level _PROOF_TO_EVIDENCE. +// +// ProofMethod (LLM output) → EvidenceMethod (scoring input) mapping. +// The two enums use different value strings, so direct casting fails. +// +// Note it is NOT total: ProofMethod has four members and all four are mapped, +// but the lookup still carries a HEURISTIC_MATCH fallback for a proof method +// that was never a member (impossible through the strict enum decoder, kept for +// parity). +var ProofToEvidence = map[schemas.ProofMethod]scoring.EvidenceMethod{ + schemas.ProofMethodStaticAnalysis: scoring.EvidenceMethodStaticConfigMatch, + schemas.ProofMethodLiveAPIVerification: scoring.EvidenceMethodLiveVerified, + schemas.ProofMethodIAMSimulation: scoring.EvidenceMethodIAMSimulated, + schemas.ProofMethodDriftComparison: scoring.EvidenceMethodDriftConfirmed, +} + +// outputSeverityOrder ports the `severity_order` dict local to +// _generate_output. It is NOT scoring.severityOrder (which shares the same +// values but is keyed for the benchmark floor); the two happen to agree. +var outputSeverityOrder = map[string]int{ + "critical": 4, + "high": 3, + "medium": 2, + "low": 1, + "info": 0, +} + +// GenerateOutput ports ScanOrchestrator._generate_output. +// +// Steps, in Python's order: +// +// 1. Drop findings below input.severity_threshold, but ONLY when the +// threshold itself ranks above 0. The pydantic default "low" ranks 1, so +// the default run DOES filter — it drops "info" findings. A threshold of +// "info", or an unrecognized string, ranks 0 and filters nothing. +// 2. For every surviving finding: raise its severity to the benchmark floor of +// its FIRST compliance mapping, recompute risk_score from +// (severity, proof method, Exposure.VPC_INTERNAL, has attack path, has +// drift), and mirror the score into sarif_security_severity. +// 3. Count verdicts and severities, compute the noise-reduction percentage +// against hunt.total_raw, and count drift/shadow-IT resources. +// 4. Build the CloudSecurityScanResult, then fill in its `sarif` field from +// generate_sarif(result) — which means the SARIF document is rendered from +// the ALREADY-scored findings. +// +// Python parity: step 2 MUTATES the VerifiedFinding objects, and Python's list +// — filtered or not — holds the SAME objects as the caller's, so run()'s local +// `verified` sees the new severities and risk scores too. Go structs are values, +// so this function copies the slice up front and mutates only its own copy, +// making it side-effect free. run() never reads its `verified` again after +// calling this, so the difference is unobservable on the live path. +// +// Python parity: `compliance_gaps` is never populated — it keeps its +// default_factory=list value, i.e. an empty list. +func (o *ScanOrchestrator) GenerateOutput( + recon schemas.ReconResult, + hunt schemas.HuntResult, + chain schemas.ChainResult, + verified []schemas.VerifiedFinding, +) schemas.CloudSecurityScanResult { + // --- 1. severity threshold -------------------------------------------- + thresholdValue := outputSeverityOrder[strings.ToLower(o.Input.SeverityThreshold)] + if thresholdValue > 0 { + kept := make([]schemas.VerifiedFinding, 0, len(verified)) + for _, finding := range verified { + if outputSeverityOrder[strings.ToLower(finding.Severity.String())] >= thresholdValue { + kept = append(kept, finding) + } + } + verified = kept + } else { + // Keep the caller's slice from aliasing the result's Findings when no + // filter ran, so the in-place scoring below cannot surprise a caller + // that reuses its own list (Python's aliasing is harmless there for + // the same reason: nothing reads it again). + kept := make([]schemas.VerifiedFinding, len(verified)) + copy(kept, verified) + verified = kept + } + + // --- 2. benchmark floor + risk score ---------------------------------- + for i := range verified { + finding := &verified[i] + + var benchmark *string + if len(finding.ComplianceMappings) > 0 { + benchmark = &finding.ComplianceMappings[0] + } + finding.Severity = scoring.ApplyBenchmarkSeverityFloor(benchmark, finding.Severity) + + evidence, mapped := ProofToEvidence[finding.Proof.Method] + if !mapped { + evidence = scoring.EvidenceMethodHeuristicMatch + } + finding.RiskScore = scoring.ComputeRiskScore( + finding.Severity, + evidence, + // Python parity: exposure is HARD-CODED to VPC_INTERNAL here; the + // scan never infers a real exposure level. + scoring.ExposureVPCInternal, + finding.AttackPath != nil, + finding.Drift != nil, + ) + finding.SARIFSecuritySeverity = finding.RiskScore + } + + // --- 3. counts -------------------------------------------------------- + verdictCounts := make(map[schemas.Verdict]int, len(schemas.AllVerdicts)) + for _, verdict := range schemas.AllVerdicts { + verdictCounts[verdict] = 0 + } + severityCounts := make(map[string]int, len(scoring.AllSeverities)) + for _, severity := range scoring.AllSeverities { + severityCounts[severity.String()] = 0 + } + for _, finding := range verified { + // Python parity: verdict_counts is subscript-assigned, so an + // off-enum verdict would KeyError. schemas.Verdict's decoder rejects + // those before they can get here. + verdictCounts[finding.Verdict]++ + // Python parity: severity_counts uses .get(), so an off-enum severity + // silently creates a new bucket — which a Go map increment also does. + severityCounts[finding.Severity.String()]++ + } + + totalRaw := hunt.TotalRaw + notExploitable := verdictCounts[schemas.VerdictNotExploitable] + noiseReduction := 0.0 + if totalRaw > 0 { + noiseReduction = float64(notExploitable) / float64(totalRaw) * 100.0 + } + + driftResources := 0 + shadowIT := 0 + if recon.DriftReport != nil { + driftResources = len(recon.DriftReport.DriftedResources) + shadowIT = len(recon.DriftReport.CloudOnlyResources) + } + + // --- 4. result -------------------------------------------------------- + commitSHA := "HEAD" + if o.Input.CommitSHA != nil && *o.Input.CommitSHA != "" { + commitSHA = *o.Input.CommitSHA + } + branch := o.Input.Branch + + costBreakdown := make(map[string]float64, len(o.CostBreakdown)) + for phase, cost := range o.CostBreakdown { + costBreakdown[phase] = pyfmt.Round(cost, 4) + } + + result := schemas.NewCloudSecurityScanResult() + result.Repository = o.Input.RepoURL + result.CommitSHA = commitSHA + result.Branch = &branch + result.Timestamp = o.nowUTC() + // Python parity: depth_profile is the RAW input string, not the normalized + // ScanConfig.depth. + result.DepthProfile = o.Input.Depth + result.Tier = o.Config.Tier + result.ProvidersDetected = recon.ProvidersDetected + result.Findings = verified + result.AttackPaths = chain.AttackPaths + result.TotalResourcesScanned = recon.TotalResources + 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.DriftResources = driftResources + result.ShadowITResources = shadowIT + // Python parity: compliance_frameworks has default_factory=list, so it is + // never None. A Go caller that built CloudSecurityInput as a literal could + // leave it nil, which would render as JSON null instead of []; normalize. + complianceFrameworks := o.Input.ComplianceFrameworks + if complianceFrameworks == nil { + complianceFrameworks = []string{} + } + result.ComplianceFrameworksChecked = complianceFrameworks + result.StrategiesUsed = hunt.StrategiesRun + result.DurationSeconds = o.elapsedSeconds() + result.AgentInvocations = o.AgentInvocations + result.CostUSD = pyfmt.Round(o.TotalCostUSD, 4) + result.CostBreakdown = costBreakdown + result.Metadata = map[string]any{"findings_not_verified": o.FindingsNotVerified} + result.SARIF = "" + + result.SARIF = output.GenerateSarif(result) + return result +} diff --git a/go/internal/orch/output_test.go b/go/internal/orch/output_test.go new file mode 100644 index 0000000..b547ba1 --- /dev/null +++ b/go/internal/orch/output_test.go @@ -0,0 +1,425 @@ +package orch + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/output" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// probeFixture rebuilds the exact inputs the Python probe fed to +// ScanOrchestrator._generate_output. +func probeFixture(t *testing.T) (schemas.ReconResult, schemas.HuntResult, schemas.ChainResult, []schemas.VerifiedFinding) { + t.Helper() + + drifted := schemas.NewDriftedResource() + drifted.ResourceID = "a" + drifted.ResourceType = "t" + drifted.SecurityRelevant = true + drifted.Significance = "high" + + drift := schemas.NewDriftReport() + drift.DriftedResources = []schemas.DriftedResource{drifted} + drift.CloudOnlyResources = []string{"shadow1", "shadow2"} + + recon := schemas.NewReconResult() + recon.Inventory.InventorySavedPath = "/inv.json" + recon.Inventory.TotalResources = 3 + recon.Inventory.IaCType = "terraform" + recon.ResourceGraph.GraphSavedPath = "/g.json" + recon.ResourceGraph.TotalNodes = 3 + recon.ResourceGraph.TotalEdges = 2 + recon.DriftReport = &drift + recon.IaCType = "terraform" + recon.ProvidersDetected = []string{"aws"} + recon.TotalResources = 3 + recon.TotalEdges = 2 + + hunt := schemas.NewHuntResult() + hunt.TotalRaw = 10 + hunt.StrategiesRun = []string{"iam", "network"} + + chain := schemas.NewChainResult() + + c1 := verifiedFinding("c1", schemas.VerdictConfirmed, scoring.SeverityMedium) + c1.ComplianceMappings = []string{"CIS-AWS-1.4"} + c2 := verifiedFinding("c2", schemas.VerdictNotExploitable, scoring.SeverityLow) + c3 := verifiedFinding("c3", schemas.VerdictNotExploitable, scoring.SeverityInfo) + c4 := verifiedFinding("c4", schemas.VerdictLikely, scoring.SeverityHigh) + c4.Proof.Method = schemas.ProofMethodLiveAPIVerification + + return recon, hunt, chain, []schemas.VerifiedFinding{c1, c2, c3, c4} +} + +// TestGenerateOutput_ProbeParity reproduces the Python probe's _generate_output +// run field by field. +func TestGenerateOutput_ProbeParity(t *testing.T) { + recon, hunt, chain, verified := probeFixture(t) + o := newTestOrchestrator(t, &appx.Fake{}, scanInput(t, nil), fixedClock()) + o.FindingsNotVerified = 2 + + got := o.GenerateOutput(recon, hunt, chain, verified) + + if got.Confirmed != 1 { + t.Errorf("confirmed = %d, want 1", got.Confirmed) + } + if got.Likely != 1 { + t.Errorf("likely = %d, want 1", got.Likely) + } + if got.Inconclusive != 0 { + t.Errorf("inconclusive = %d, want 0", got.Inconclusive) + } + if got.NotExploitable != 1 { + t.Errorf("not_exploitable = %d, want 1 (c3 was filtered by the threshold)", got.NotExploitable) + } + if got.NoiseReductionPct != 10.0 { + t.Errorf("noise_reduction_pct = %v, want 10.0", got.NoiseReductionPct) + } + wantSeverity := map[string]int{"critical": 1, "high": 1, "medium": 0, "low": 1, "info": 0} + for k, v := range wantSeverity { + if got.BySeverity[k] != v { + t.Errorf("by_severity[%q] = %d, want %d (%v)", k, got.BySeverity[k], v, got.BySeverity) + } + } + if len(got.BySeverity) != len(wantSeverity) { + t.Errorf("by_severity = %v", got.BySeverity) + } + if got.DriftResources != 1 || got.ShadowITResources != 2 { + t.Errorf("drift/shadow = %d/%d, want 1/2", got.DriftResources, got.ShadowITResources) + } + if got.TotalRawFindings != 10 { + t.Errorf("total_raw_findings = %d", got.TotalRawFindings) + } + if got.CommitSHA != "HEAD" { + t.Errorf("commit_sha = %q", got.CommitSHA) + } + if got.Branch == nil || *got.Branch != "main" { + t.Errorf("branch = %v", got.Branch) + } + if got.DepthProfile != "quick" { + t.Errorf("depth_profile = %q", got.DepthProfile) + } + if got.Tier != 1 { + t.Errorf("tier = %d", got.Tier) + } + if got.Metadata["findings_not_verified"] != 2 { + t.Errorf("metadata = %v", got.Metadata) + } + if got.CostUSD != 0.0 { + t.Errorf("cost_usd = %v", got.CostUSD) + } + for _, phase := range PhaseOrder { + if got.CostBreakdown[phase] != 0.0 { + t.Errorf("cost_breakdown[%q] = %v", phase, got.CostBreakdown[phase]) + } + } + if len(got.ComplianceGaps) != 0 { + t.Errorf("compliance_gaps = %v, want []", got.ComplianceGaps) + } + if !equalStrings(got.StrategiesUsed, []string{"iam", "network"}) { + t.Errorf("strategies_used = %v", got.StrategiesUsed) + } + if !equalStrings(got.ProvidersDetected, []string{"aws"}) { + t.Errorf("providers_detected = %v", got.ProvidersDetected) + } + if !equalStrings(got.ComplianceFrameworksChecked, []string{"cis_aws"}) { + t.Errorf("compliance_frameworks_checked = %v", got.ComplianceFrameworksChecked) + } + if got.TotalResourcesScanned != 3 { + t.Errorf("total_resources_scanned = %d", got.TotalResourcesScanned) + } + + // probe: [('c1', critical, 3.5, 3.5), ('c2', low, 1.05, 1.05), ('c4', high, 5.6, 5.6)] + type row struct { + id string + severity scoring.Severity + score float64 + } + want := []row{ + {id: "c1", severity: scoring.SeverityCritical, score: 3.5}, + {id: "c2", severity: scoring.SeverityLow, score: 1.05}, + {id: "c4", severity: scoring.SeverityHigh, score: 5.6}, + } + if len(got.Findings) != len(want) { + t.Fatalf("findings = %d, want %d", len(got.Findings), len(want)) + } + for i, w := range want { + f := got.Findings[i] + if f.ID != w.id || f.Severity != w.severity || f.RiskScore != w.score || f.SARIFSecuritySeverity != w.score { + t.Errorf("finding %d = (%s, %s, %v, %v), want (%s, %s, %v, %v)", + i, f.ID, f.Severity, f.RiskScore, f.SARIFSecuritySeverity, w.id, w.severity, w.score, w.score) + } + } +} + +// TestGenerateOutput_SeverityThreshold pins the filter, including the +// rank-0 no-op arms. +func TestGenerateOutput_SeverityThreshold(t *testing.T) { + recon, hunt, chain, _ := probeFixture(t) + findings := []schemas.VerifiedFinding{ + verifiedFinding("a", schemas.VerdictConfirmed, scoring.SeverityMedium), + verifiedFinding("b", schemas.VerdictConfirmed, scoring.SeverityCritical), + verifiedFinding("c", schemas.VerdictConfirmed, scoring.SeverityInfo), + } + cases := []struct { + threshold string + want []string + }{ + {threshold: "high", want: []string{"b"}}, // probe: THRESH_HIGH ['b'] + {threshold: "low", want: []string{"a", "b"}}, // info drops out + {threshold: "info", want: []string{"a", "b", "c"}}, // rank 0 -> no filter + {threshold: "", want: []string{"a", "b", "c"}}, // unknown -> rank 0 + {threshold: "BOGUS", want: []string{"a", "b", "c"}}, + {threshold: "CRITICAL", want: []string{"b"}}, // .lower() is applied + } + for _, tc := range cases { + t.Run(tc.threshold, func(t *testing.T) { + input := scanInput(t, func(in *schemas.CloudSecurityInput) { in.SeverityThreshold = tc.threshold }) + o := newTestOrchestrator(t, &appx.Fake{}, input, fixedClock()) + got := o.GenerateOutput(recon, hunt, chain, findings) + ids := make([]string, 0, len(got.Findings)) + for _, f := range got.Findings { + ids = append(ids, f.ID) + } + if !equalStrings(ids, tc.want) { + t.Fatalf("findings = %v, want %v", ids, tc.want) + } + }) + } +} + +// TestGenerateOutput_RiskScoreInputs pins every argument compute_risk_score +// receives: the (possibly raised) severity, the mapped evidence method, the +// hard-coded VPC_INTERNAL exposure, and the two boolean bonuses. +func TestGenerateOutput_RiskScoreInputs(t *testing.T) { + recon, hunt, chain, _ := probeFixture(t) + o := newTestOrchestrator(t, &appx.Fake{}, scanInput(t, func(in *schemas.CloudSecurityInput) { + in.SeverityThreshold = "info" + }), fixedClock()) + + path := schemas.NewAttackPath() + path.ID = "ap" + drift := schemas.NewDriftedResource() + drift.ResourceID = "r" + + cases := []struct { + name string + build func() schemas.VerifiedFinding + want float64 + }{ + { + name: "static analysis maps to static_config_match", + build: func() schemas.VerifiedFinding { + f := verifiedFinding("x", schemas.VerdictConfirmed, scoring.SeverityHigh) + f.Proof.Method = schemas.ProofMethodStaticAnalysis + return f + }, + want: 2.8, // 8.0 * 0.5 * 0.7 + }, + { + name: "iam simulation maps to iam_simulated", + build: func() schemas.VerifiedFinding { + f := verifiedFinding("x", schemas.VerdictConfirmed, scoring.SeverityHigh) + f.Proof.Method = schemas.ProofMethodIAMSimulation + return f + }, + want: 5.04, // 8.0 * 0.9 * 0.7 + }, + { + name: "drift comparison maps to drift_confirmed", + build: func() schemas.VerifiedFinding { + f := verifiedFinding("x", schemas.VerdictConfirmed, scoring.SeverityHigh) + f.Proof.Method = schemas.ProofMethodDriftComparison + return f + }, + want: 4.76, // 8.0 * 0.85 * 0.7 + }, + { + name: "attack path doubles the score", + build: func() schemas.VerifiedFinding { + f := verifiedFinding("x", schemas.VerdictConfirmed, scoring.SeverityHigh) + f.AttackPath = &path + return f + }, + want: 5.6, // 8.0 * 0.5 * 0.7 * 2 + }, + { + name: "drift adds 30 percent", + build: func() schemas.VerifiedFinding { + f := verifiedFinding("x", schemas.VerdictConfirmed, scoring.SeverityHigh) + f.Drift = &drift + return f + }, + want: 3.64, // 8.0 * 0.5 * 0.7 * 1.3 + }, + { + name: "score is clamped at 10", + build: func() schemas.VerifiedFinding { + f := verifiedFinding("x", schemas.VerdictConfirmed, scoring.SeverityCritical) + f.Proof.Method = schemas.ProofMethodLiveAPIVerification + f.AttackPath = &path + f.Drift = &drift + return f + }, + want: 10.0, // 10 * 1 * 0.7 * 2 * 1.3 = 18.2 -> clamped + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := o.GenerateOutput(recon, hunt, chain, []schemas.VerifiedFinding{tc.build()}) + if got.Findings[0].RiskScore != tc.want { + t.Fatalf("risk_score = %v, want %v", got.Findings[0].RiskScore, tc.want) + } + }) + } +} + +// TestGenerateOutput_BenchmarkFloorUsesFirstComplianceMapping pins +// `finding.compliance_mappings[0] if finding.compliance_mappings else None`. +func TestGenerateOutput_BenchmarkFloorUsesFirstComplianceMapping(t *testing.T) { + recon, hunt, chain, _ := probeFixture(t) + o := newTestOrchestrator(t, &appx.Fake{}, scanInput(t, func(in *schemas.CloudSecurityInput) { + in.SeverityThreshold = "info" + }), fixedClock()) + + first := verifiedFinding("first", schemas.VerdictConfirmed, scoring.SeverityLow) + first.ComplianceMappings = []string{"CIS-AWS-1.4", "CIS-AWS-2.1.1"} + second := verifiedFinding("second", schemas.VerdictConfirmed, scoring.SeverityLow) + second.ComplianceMappings = []string{"not-a-benchmark", "CIS-AWS-1.4"} + none := verifiedFinding("none", schemas.VerdictConfirmed, scoring.SeverityLow) + + got := o.GenerateOutput(recon, hunt, chain, []schemas.VerifiedFinding{first, second, none}) + if got.Findings[0].Severity != scoring.SeverityCritical { + t.Errorf("first mapping should raise to critical, got %s", got.Findings[0].Severity) + } + if got.Findings[1].Severity != scoring.SeverityLow { + t.Errorf("only the FIRST mapping is consulted, got %s", got.Findings[1].Severity) + } + if got.Findings[2].Severity != scoring.SeverityLow { + t.Errorf("no mappings must leave severity alone, got %s", got.Findings[2].Severity) + } +} + +// TestGenerateOutput_NoiseReductionEdgeCases pins the +// `if total_raw > 0 else 0.0` guard and the round(…, 2). +func TestGenerateOutput_NoiseReductionEdgeCases(t *testing.T) { + recon, _, chain, _ := probeFixture(t) + cases := []struct { + name string + totalRaw int + verdicts []schemas.Verdict + want float64 + }{ + {name: "no raw findings", totalRaw: 0, verdicts: []schemas.Verdict{schemas.VerdictNotExploitable}, want: 0.0}, + {name: "all noise", totalRaw: 2, verdicts: []schemas.Verdict{schemas.VerdictNotExploitable, schemas.VerdictNotExploitable}, want: 100.0}, + {name: "rounds to two places", totalRaw: 3, verdicts: []schemas.Verdict{schemas.VerdictNotExploitable}, want: 33.33}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + hunt := schemas.NewHuntResult() + hunt.TotalRaw = tc.totalRaw + findings := make([]schemas.VerifiedFinding, 0, len(tc.verdicts)) + for i, v := range tc.verdicts { + findings = append(findings, verifiedFinding(string(rune('a'+i)), v, scoring.SeverityHigh)) + } + o := newTestOrchestrator(t, &appx.Fake{}, scanInput(t, nil), fixedClock()) + got := o.GenerateOutput(recon, hunt, chain, findings) + if got.NoiseReductionPct != tc.want { + t.Fatalf("noise_reduction_pct = %v, want %v", got.NoiseReductionPct, tc.want) + } + }) + } +} + +// TestGenerateOutput_NoDriftReportZeroesTheDriftCounts. +func TestGenerateOutput_NoDriftReportZeroesTheDriftCounts(t *testing.T) { + recon, hunt, chain, verified := probeFixture(t) + recon.DriftReport = nil + o := newTestOrchestrator(t, &appx.Fake{}, scanInput(t, nil), fixedClock()) + got := o.GenerateOutput(recon, hunt, chain, verified) + if got.DriftResources != 0 || got.ShadowITResources != 0 { + t.Fatalf("drift/shadow = %d/%d, want 0/0", got.DriftResources, got.ShadowITResources) + } +} + +// TestGenerateOutput_CommitShaFallsBackToHEAD pins `input.commit_sha or "HEAD"`. +func TestGenerateOutput_CommitShaFallsBackToHEAD(t *testing.T) { + recon, hunt, chain, verified := probeFixture(t) + cases := []struct { + name string + sha *string + want string + }{ + {name: "nil", sha: nil, want: "HEAD"}, + {name: "empty string is falsy", sha: stringPtr(""), want: "HEAD"}, + {name: "real sha", sha: stringPtr("deadbeef"), want: "deadbeef"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + input := scanInput(t, func(in *schemas.CloudSecurityInput) { in.CommitSHA = tc.sha }) + o := newTestOrchestrator(t, &appx.Fake{}, input, fixedClock()) + if got := o.GenerateOutput(recon, hunt, chain, verified).CommitSHA; got != tc.want { + t.Fatalf("commit_sha = %q, want %q", got, tc.want) + } + }) + } +} + +// TestGenerateOutput_SarifIsRenderedFromTheScoredFindings: the `sarif` field is +// filled AFTER the risk scores are recomputed, so the document carries the final +// security-severity values, and not_exploitable findings are excluded. +func TestGenerateOutput_SarifIsRenderedFromTheScoredFindings(t *testing.T) { + recon, hunt, chain, verified := probeFixture(t) + o := newTestOrchestrator(t, &appx.Fake{}, scanInput(t, nil), fixedClock()) + got := o.GenerateOutput(recon, hunt, chain, verified) + + if got.SARIF == "" { + t.Fatal("sarif is empty") + } + var doc map[string]any + if err := json.Unmarshal([]byte(got.SARIF), &doc); err != nil { + t.Fatalf("sarif is not valid JSON: %v", err) + } + if doc["version"] != "2.1.0" { + t.Fatalf("sarif version = %v", doc["version"]) + } + if !strings.Contains(got.SARIF, "3.5") { + t.Error("sarif should carry the recomputed security-severity 3.5") + } + // generate_sarif is called on the finished result, so re-rendering it must + // be a no-op modulo the (already-populated) sarif string itself. + rerendered := got + rerendered.SARIF = "" + if output.GenerateSarif(rerendered) != got.SARIF { + t.Error("sarif was not rendered from the final result") + } +} + +// TestGenerateOutput_CostsAreRoundedToFourPlaces pins round(cost, 4). +func TestGenerateOutput_CostsAreRoundedToFourPlaces(t *testing.T) { + recon, hunt, chain, verified := probeFixture(t) + o := newTestOrchestrator(t, &appx.Fake{}, scanInput(t, nil), fixedClock()) + o.TotalCostUSD = 0.123456 + o.CostBreakdown["recon"] = 0.987654 + got := o.GenerateOutput(recon, hunt, chain, verified) + if got.CostUSD != 0.1235 { + t.Errorf("cost_usd = %v, want 0.1235", got.CostUSD) + } + if got.CostBreakdown["recon"] != 0.9877 { + t.Errorf("cost_breakdown[recon] = %v, want 0.9877", got.CostBreakdown["recon"]) + } +} + +// TestGenerateOutput_TimestampUsesTheInjectedClock. +func TestGenerateOutput_TimestampUsesTheInjectedClock(t *testing.T) { + recon, hunt, chain, verified := probeFixture(t) + o := newTestOrchestrator(t, &appx.Fake{}, scanInput(t, nil), fixedClock()) + got := o.GenerateOutput(recon, hunt, chain, verified) + if want := "2026-01-02T03:04:05.123456+00:00"; got.Timestamp.ISOFormat() != want { + t.Fatalf("timestamp = %q, want %q", got.Timestamp.ISOFormat(), want) + } +} diff --git a/go/internal/phases/chain.go b/go/internal/phases/chain.go new file mode 100644 index 0000000..6b1b7ae --- /dev/null +++ b/go/internal/phases/chain.go @@ -0,0 +1,84 @@ +package phases + +import ( + "context" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/afx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/config" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// ChainPhase ports src/cloudsecurity_af/reasoners/phases.py chain_phase. +// +// Python: +// +// @router.reasoner() +// async def chain_phase(findings: list[dict[str, Any]], resource_graph_path: str, +// drift_report: dict[str, Any] | None = None, +// depth: str = "standard", max_children: int = 3) -> dict[str, Any]: +// +// DAG shape: exactly ONE child, run_path_constructor. The fan-out that the +// CHAIN phase is famous for happens INSIDE the path constructor (meta-prompting +// over max_children child investigations), not here. +// +// Python parity notes: +// +// - `findings` and `drift_report` are forwarded VERBATIM — chain_phase never +// binds them to a model, so a malformed finding reaches +// run_path_constructor unchanged and the CHILD is what fails +// (RawFinding.model_validate inside the path constructor), producing a +// failed child execution in the DAG and the relayed +// "run_path_constructor failed: ..." error. `findings` is therefore +// `[]any`, not `[]map[string]any`: a non-object element must survive this +// hop, exactly as it does in Python, instead of failing the parent's bind. +// `drift_report` stays a raw map for the same reason. +// - max_paths comes from DEPTH_CHAIN_LIMITS (quick 5, standard 15, +// thorough 100), NOT from a parameter. +// - drift_report is passed even when nil, so `drift_report` is always a key +// of the child's input (value null). Dropping the key would change the +// child reasoner's binding. +func ChainPhase( + ctx context.Context, + app appx.Caller, + nodeID string, + findings []any, + resourceGraphPath string, + driftReport map[string]any, + depth string, + maxChildren int, +) (afx.Payload, error) { + profile := config.NormalizeDepth(depth) + maxPaths := chainLimitFor(profile) + + // A nil Go map must reach the child as JSON null, not as {}. Boxing it in + // an `any` keeps encoding/json's nil-map-to-null rule; the explicit nil + // keeps the intent obvious. + var driftArg any + if driftReport != nil { + driftArg = driftReport + } + // Python types `findings` as a required list, so it is never None. A nil + // Go slice would marshal to null, which the child would bind as an empty + // list anyway; normalizing keeps the emitted kwargs shape honest. + var findingsArg any = findings + if findings == nil { + findingsArg = []any{} + } + + chain, err := callModel[schemas.ChainResult](ctx, app, + nodeID+".run_path_constructor", "run_path_constructor", + map[string]any{ + "findings": findingsArg, + "resource_graph_path": resourceGraphPath, + "max_paths": maxPaths, + "max_children": maxChildren, + "drift_report": driftArg, + }) + if err != nil { + return nil, err + } + + // Python: `return chain_result.model_dump()`. + return afx.Dump(chain) +} diff --git a/go/internal/phases/chain_test.go b/go/internal/phases/chain_test.go new file mode 100644 index 0000000..5857e35 --- /dev/null +++ b/go/internal/phases/chain_test.go @@ -0,0 +1,153 @@ +package phases + +import ( + "context" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +func chainFake(t *testing.T, capture *map[string]any) *appx.Fake { + t.Helper() + chain := schemas.NewChainResult() + return &appx.Fake{CallFn: func(_ context.Context, _ string, in map[string]any) (map[string]any, error) { + if capture != nil { + *capture = in + } + return mustMap(t, chain), nil + }} +} + +// TestChainPhase_SingleChildAndKwargs pins the one child and the five kwargs the +// Python probe recorded: +// +// [('cloudsecurity.run_path_constructor', +// ['drift_report', 'findings', 'max_children', 'max_paths', 'resource_graph_path'])] +func TestChainPhase_SingleChildAndKwargs(t *testing.T) { + var captured map[string]any + fake := chainFake(t, &captured) + + findings := []any{map[string]any{"id": "x"}} + out, err := ChainPhase(context.Background(), fake, testNodeID, findings, "/g.json", nil, "quick", 3) + if err != nil { + t.Fatalf("ChainPhase: %v", err) + } + + if got := fake.CallTargets(); !equalStrings(got, []string{testNodeID + ".run_path_constructor"}) { + t.Fatalf("targets = %v", got) + } + want := []string{"drift_report", "findings", "max_children", "max_paths", "resource_graph_path"} + if got := keysOf(captured); !equalStrings(got, want) { + t.Fatalf("kwargs = %v, want %v", got, want) + } + if captured["drift_report"] != nil { + t.Errorf("drift_report should be nil, got %#v", captured["drift_report"]) + } + if captured["resource_graph_path"] != "/g.json" { + t.Errorf("resource_graph_path = %v", captured["resource_graph_path"]) + } + if captured["max_children"] != 3 { + t.Errorf("max_children = %v", captured["max_children"]) + } + + // probe: CHAIN_KEYS ['attack_paths', 'chain_duration_seconds', + // 'total_paths_evaluated', 'viable_paths'] + wantKeys := []string{"attack_paths", "chain_duration_seconds", "total_paths_evaluated", "viable_paths"} + if got := payloadKeys(out); !equalStrings(got, wantKeys) { + t.Fatalf("chain keys = %v, want %v", got, wantKeys) + } +} + +// TestChainPhase_MaxPathsFromDepth pins DEPTH_CHAIN_LIMITS (the probe recorded +// CHAIN_MAXPATHS 5 for depth="quick"). +func TestChainPhase_MaxPathsFromDepth(t *testing.T) { + cases := []struct { + depth string + want int + }{ + {depth: "quick", want: 5}, + {depth: "standard", want: 15}, + {depth: "thorough", want: 100}, + {depth: "THOROUGH", want: 100}, + {depth: "bogus", want: 15}, + {depth: "", want: 15}, + } + for _, tc := range cases { + t.Run(tc.depth, func(t *testing.T) { + var captured map[string]any + fake := chainFake(t, &captured) + if _, err := ChainPhase(context.Background(), fake, testNodeID, nil, "/g.json", nil, tc.depth, 3); err != nil { + t.Fatalf("ChainPhase: %v", err) + } + if captured["max_paths"] != tc.want { + t.Fatalf("max_paths = %v, want %d", captured["max_paths"], tc.want) + } + }) + } +} + +// TestChainPhase_ForwardsFindingsAndDriftVerbatim: chain_phase never binds +// either value, so whatever it was handed reaches run_path_constructor unchanged. +func TestChainPhase_ForwardsFindingsAndDriftVerbatim(t *testing.T) { + var captured map[string]any + fake := chainFake(t, &captured) + + findings := []any{map[string]any{"id": "x", "not_a_real_field": 1}, map[string]any{"id": "y"}} + drift := map[string]any{"drifted_resources": []any{}, "extra": "kept"} + if _, err := ChainPhase(context.Background(), fake, testNodeID, findings, "/g.json", drift, "standard", 7); err != nil { + t.Fatalf("ChainPhase: %v", err) + } + + gotFindings, ok := captured["findings"].([]any) + if !ok { + t.Fatalf("findings is %T", captured["findings"]) + } + first, ok := gotFindings[0].(map[string]any) + if !ok { + t.Fatalf("findings[0] is %T", gotFindings[0]) + } + if len(gotFindings) != 2 || first["not_a_real_field"] != 1 { + t.Fatalf("findings were not forwarded verbatim: %#v", gotFindings) + } + gotDrift, ok := captured["drift_report"].(map[string]any) + if !ok { + t.Fatalf("drift_report is %T", captured["drift_report"]) + } + if gotDrift["extra"] != "kept" { + t.Fatalf("drift_report was not forwarded verbatim: %#v", gotDrift) + } + if captured["max_children"] != 7 { + t.Fatalf("max_children = %v", captured["max_children"]) + } +} + +// TestChainPhase_StrictUnwrapFailurePropagates: unlike hunt_phase, chain_phase +// has no try/except — a failed path constructor fails the whole phase. +func TestChainPhase_StrictUnwrapFailurePropagates(t *testing.T) { + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { + return map[string]any{"status": "error", "error_message": "no graph"}, nil + }} + _, err := ChainPhase(context.Background(), fake, testNodeID, nil, "/g.json", nil, "standard", 3) + if err == nil || err.Error() != "run_path_constructor failed: no graph" { + t.Fatalf("err = %v", err) + } +} + +// TestChainPhase_UnwrapsOutputEnvelope proves the ported _unwrap still peels an +// {"output": {...}} envelope before validating. +func TestChainPhase_UnwrapsOutputEnvelope(t *testing.T) { + chain := schemas.NewChainResult() + chain.TotalPathsEvaluated = 4 + chain.ViablePaths = 2 + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { + return map[string]any{"output": mustMap(t, chain)}, nil + }} + out, err := ChainPhase(context.Background(), fake, testNodeID, nil, "/g.json", nil, "standard", 3) + if err != nil { + t.Fatalf("ChainPhase: %v", err) + } + if pm(out)["total_paths_evaluated"] != 4 || pm(out)["viable_paths"] != 2 { + t.Fatalf("out = %#v", out) + } +} diff --git a/go/internal/phases/doc.go b/go/internal/phases/doc.go new file mode 100644 index 0000000..24776a3 --- /dev/null +++ b/go/internal/phases/doc.go @@ -0,0 +1,62 @@ +// Package phases ports src/cloudsecurity_af/reasoners/phases.py — the five +// phase reasoners (recon_phase, hunt_phase, chain_phase, prove_phase, +// remediation_phase) that fan the scan out across the individual agent +// reasoners. +// +// These functions ARE the DAG's second level. Every place the Python source +// does +// +// await _runtime_router.call(f"{NODE_ID}.run_x", **kwargs) +// +// the Go port does +// +// app.Call(ctx, nodeID+".run_x", map[string]any{...}) +// +// with the SAME target name and the SAME kwargs keys, so the control plane +// records an identical parent→child execution tree. A phase NEVER calls the +// agent function in-process; doing so would collapse a DAG node. +// +// Parity notes that apply package-wide: +// +// - Envelope handling. Python's phases.py defines its own _unwrap, the +// STRICT variant that also fails on `error_message` and on +// `status in ("failed", "error")`. Every call site here goes through +// afx.UnwrapStrict + afx.AsMap, which are the exact ports of that pair, +// with the same error strings. +// - Model materialization. `Model.model_validate(payload)` becomes +// afx.Bind[Model](payload), whose UnmarshalJSON seeds the pydantic +// defaults AND — unlike a bare json.Unmarshal — RAISES on a missing +// required field, exactly as pydantic does. afx.Bind walks the payload +// against the model tree first (internal/afx/required.go) and returns an +// *afx.ValidationError wrapping an *afx.MissingFieldError, the stand-in for +// pydantic's ValidationError with type=missing; only the message TEXT +// differs (no input value, no docs URL). So a malformed iac-reader reply +// missing ResourceInventory.inventory_saved_path aborts recon_phase here +// the way it aborts it in Python, rather than carrying an empty path into +// run_resource_graph_builder. prove_phase depends on the same raise to take +// its `_fallback_verified(finding, "Schema parse failed: ...")` branch, and +// internal/orch depends on it for app.py's 400 (ValueError) branch — see +// orch's TestBindVerifiedList_MissingRequiredFieldsRaise. The one genuine +// narrowing is scope, not behavior: a model is checked only against the +// required list it declares via afx.RequiredFielder, and internal/schemas' +// required_test.go cross-checks every declared list against the committed +// pydantic schema fixtures' `required` arrays. Python parity: `missing` +// fires on an ABSENT key; a key present with an explicit null is a type +// error, left to the decode. +// - Concurrency. asyncio.Semaphore(n) becomes a buffered channel of +// capacity n; asyncio.gather becomes a sync.WaitGroup writing into a +// pre-indexed result slice, so results keep ARGUMENT order rather than +// completion order (gather does the same). Nothing here cancels ctx on the +// first error, because asyncio.gather does not cancel its siblings either. +// - Depth. Every phase normalizes its `depth` string with +// config.NormalizeDepth (the _normalize_depth port): lowercased, and +// anything unrecognized silently becomes "standard". +// - NODE_ID. phases.py reads NODE_ID ONCE at import time into a module +// constant. The Go phases take nodeID as an explicit parameter so nothing +// is frozen at init and tests are deterministic; NodeID() reproduces the +// env lookup for callers that want it. +// +// The phases take the narrowest capability they need — appx.Caller — because +// not one of them uses harness, ai or note. (In particular hunt_phase swallows +// hunter failures WITHOUT emitting a note; the port must not add one.) +package phases diff --git a/go/internal/phases/helpers_test.go b/go/internal/phases/helpers_test.go new file mode 100644 index 0000000..611de4c --- /dev/null +++ b/go/internal/phases/helpers_test.go @@ -0,0 +1,161 @@ +package phases + +import ( + "encoding/json" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/afx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/pyfmt" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// testNodeID is the NODE_ID the Python probe ran under. +const testNodeID = "cloudsecurity" + +// rawFinding mirrors the probe's helper: +// +// RawFinding(hunter_strategy="iam", title="t", description="d", +// category="public_access", iac_file="main.tf", iac_line=1, +// config_snippet="", fingerprint="") +// +// with the id/severity/fingerprint/category overridable. +func rawFinding(id string, severity scoring.Severity, fingerprint, category string) schemas.RawFinding { + f := schemas.NewRawFinding() + f.ID = id + f.HunterStrategy = "iam" + f.Title = "t" + f.Description = "d" + f.Category = category + f.EstimatedSeverity = severity + f.IaCFile = "main.tf" + f.IaCLine = 1 + f.ConfigSnippet = "" + f.Fingerprint = fingerprint + return f +} + +// verifiedFinding mirrors the probe's vf() helper. +func verifiedFinding(id string, verdict schemas.Verdict, severity scoring.Severity) schemas.VerifiedFinding { + v := schemas.NewVerifiedFinding() + v.ID = id + v.Title = "t" + v.Verdict = verdict + v.Severity = severity + v.Category = "public_access" + v.IaCFile = "main.tf" + v.IaCLine = 2 + v.ConfigSnippet = "" + v.Description = "d" + v.Fingerprint = "fp" + v.HunterStrategy = "iam" + v.SARIFRuleID = "r" + v.SARIFSecuritySeverity = 0.0 + return v +} + +// mustMap is afx.ToMap with a fatal on error. +func mustMap(t *testing.T, v any) map[string]any { + t.Helper() + m, err := afx.ToMap(v) + if err != nil { + t.Fatalf("ToMap(%T): %v", v, err) + } + return m +} + +// jsonMap re-encodes v through JSON so a test can compare a kwarg value that +// ToMap left typed against a plain map literal. +func jsonMap(t *testing.T, v any) map[string]any { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal %T: %v", v, err) + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("unmarshal %T: %v", v, err) + } + return out +} + +// pm renders a phase reply as a plain map, for the assertions that only look a +// value up by key. The phases return an afx.Payload — an INSERTION-ORDERED +// object — because the wire byte order is part of the parity contract +// (payload_order_test.go pins it against the Python dict order). +func pm(p afx.Payload) map[string]any { return p.Map() } + +// verifiedList unwraps a phase reply's `verified` list. The elements are +// afx.Payload — model_dump(exclude_none=True) results, insertion-ordered like +// the Python dicts they port — so the assertions read them as maps. +func verifiedList(t *testing.T, p afx.Payload) []map[string]any { + t.Helper() + raw, ok := p.Get("verified") + if !ok { + t.Fatalf("reply has no \"verified\" key: %v", p) + } + list, ok := raw.([]afx.Payload) + if !ok { + t.Fatalf("verified is %T, want []afx.Payload", raw) + } + out := make([]map[string]any, 0, len(list)) + for _, entry := range list { + out = append(out, entry.Map()) + } + return out +} + +// proofEvidence reads a verified finding's proof.evidence list. The nested +// objects are pyfmt.Ordered — insertion-ordered, like the Python dicts +// model_dump produces. +func proofEvidence(t *testing.T, finding map[string]any) []any { + t.Helper() + proof, ok := finding["proof"].(pyfmt.Ordered) + if !ok { + t.Fatalf("proof is %T, want pyfmt.Ordered", finding["proof"]) + } + evidence, ok := proof.Get("evidence") + if !ok { + t.Fatalf("proof has no evidence key: %v", proof) + } + list, ok := evidence.([]any) + if !ok { + t.Fatalf("evidence is %T, want a list", evidence) + } + return list +} + +// payloadKeys is keysOf for a phase reply: the SORTED key set, so a test that +// only cares about the key SET stays readable. The ORDER is asserted separately. +func payloadKeys(p afx.Payload) []string { return keysOf(p.Map()) } + +// keysOf returns the sorted key set of a kwargs map, the shape the Python probe +// printed for every recorded call. +func keysOf(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sortStrings(out) + return out +} + +func sortStrings(s []string) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j] < s[j-1]; j-- { + s[j], s[j-1] = s[j-1], s[j] + } + } +} + +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 +} diff --git a/go/internal/phases/hunt.go b/go/internal/phases/hunt.go new file mode 100644 index 0000000..000665a --- /dev/null +++ b/go/internal/phases/hunt.go @@ -0,0 +1,208 @@ +package phases + +import ( + "context" + "strconv" + "sync" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/afx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/config" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// severityRank ports the sev_rank dict that phases.py builds twice (once in +// _cross_hunter_dedup, once in _prioritize_findings) with identical contents: +// +// {CRITICAL: 5, HIGH: 4, MEDIUM: 3, LOW: 2, INFO: 1} +// +// Both call sites read it with `.get(severity, 0)`, so an unknown severity +// ranks 0 — below INFO. +var severityRank = map[scoring.Severity]int{ + scoring.SeverityCritical: 5, + scoring.SeverityHigh: 4, + scoring.SeverityMedium: 3, + scoring.SeverityLow: 2, + scoring.SeverityInfo: 1, +} + +// HuntPhase ports src/cloudsecurity_af/reasoners/phases.py hunt_phase. +// +// Python: +// +// @router.reasoner() +// async def hunt_phase(repo_path: str, resource_graph_path: str, inventory_path: str, +// depth: str = "standard", max_concurrent_hunters: int = 3) -> dict[str, Any]: +// +// DAG shape: one `run__hunter` child per entry of DEPTH_HUNTER_MAP for +// the normalized depth (5 for quick, 7 for standard/thorough), fanned out under +// a semaphore of max(1, min(max_concurrent_hunters, len(active_hunters))). +// +// Python parity notes: +// +// - A hunter that fails — transport error, strict-unwrap failure or a payload +// that will not bind — contributes an EMPTY batch and NOTHING else. Python +// catches the exception and never notes it (`except Exception as exc:` with +// `exc` unused); the port must not add a note either. +// - The producer/consumer split is real, not decorative: an asyncio.Queue +// feeds a consumer coroutine that dedups by fingerprint AS BATCHES ARRIVE, +// so total_raw counts every finding the hunters produced while `findings` +// holds only the first occurrence of each fingerprint. The Go port keeps +// the same shape with a buffered channel plus a consumer goroutine. +// - Batch arrival order is COMPLETION order, in Go as in Python. Which of two +// same-fingerprint findings survives, and the order of the surviving list, +// therefore depends on hunter timing in both implementations. (The +// downstream consumers — chain_phase's max_paths, prove_phase's severity +// sort — do not depend on it.) +// - A finding with an empty fingerprint is assigned +// f"{iac_file}:{iac_line}:{category}" and MUTATED in place, so the +// synthesized fingerprint travels on into the HuntResult. +// - hunt_duration_seconds is hard-coded to 0.0 here; the orchestrator +// overwrites it after the call returns. +func HuntPhase( + ctx context.Context, + app appx.Caller, + nodeID string, + repoPath string, + resourceGraphPath string, + inventoryPath string, + depth string, + maxConcurrentHunters int, +) (afx.Payload, error) { + profile := config.NormalizeDepth(depth) + activeHunters := config.HuntersForDepth(profile) + + concurrencyLimit := maxConcurrentHunters + if len(activeHunters) < concurrencyLimit { + concurrencyLimit = len(activeHunters) + } + if concurrencyLimit < 1 { + concurrencyLimit = 1 + } + sem := newSemaphore(concurrencyLimit) + + // asyncio.Queue() is unbounded; a channel sized to the producer count can + // never block a producer either, which is what keeps the semaphore's hold + // time identical to Python's (`async with semaphore:` wraps the put). + batches := make(chan []schemas.RawFinding, len(activeHunters)) + + var wg sync.WaitGroup + for _, hunter := range activeHunters { + wg.Add(1) + go func(hunterName string) { + defer wg.Done() + sem.acquire() + defer sem.release() + + operationName := "run_" + hunterName + "_hunter" + payload, err := callModel[schemas.HuntResult](ctx, app, + nodeID+"."+operationName, operationName, + map[string]any{ + "repo_path": repoPath, + "resource_graph_path": resourceGraphPath, + "inventory_path": inventoryPath, + "depth": depth, + }) + if err != nil { + // Python parity: `except Exception: await queue.put([])` — + // silent, no note, no error propagation. + batches <- []schemas.RawFinding{} + return + } + batches <- payload.Findings + }(hunter) + } + + type dedupResult struct { + findings []schemas.RawFinding + totalRaw int + } + consumed := make(chan dedupResult, 1) + go func() { + allFindings := make([]schemas.RawFinding, 0) + seenFingerprints := make(map[string]struct{}) + totalRaw := 0 + + for completed := 0; completed < len(activeHunters); completed++ { + batch := <-batches + totalRaw += len(batch) + for _, finding := range batch { + fingerprint := finding.Fingerprint + if fingerprint == "" { + fingerprint = synthesizeFingerprint(finding) + finding.Fingerprint = fingerprint + } + if _, dup := seenFingerprints[fingerprint]; dup { + continue + } + seenFingerprints[fingerprint] = struct{}{} + allFindings = append(allFindings, finding) + } + } + consumed <- dedupResult{findings: crossHunterDedup(allFindings), totalRaw: totalRaw} + }() + + wg.Wait() + result := <-consumed + + hunt := schemas.NewHuntResult() + hunt.Findings = result.findings + hunt.TotalRaw = result.totalRaw + hunt.DeduplicatedCount = len(result.findings) + hunt.StrategiesRun = activeHunters + // Python parity: hunt_duration_seconds=0.0 — the phase does not time itself. + hunt.HuntDurationSeconds = 0.0 + + // Python: `return hunt_result.model_dump()`. + return afx.Dump(hunt) +} + +// synthesizeFingerprint ports +// `fp = f"{finding.iac_file}:{finding.iac_line}:{finding.category}"`. +func synthesizeFingerprint(finding schemas.RawFinding) string { + return finding.IaCFile + ":" + strconv.Itoa(finding.IaCLine) + ":" + finding.Category +} + +// crossHunterDedup ports phases.py _cross_hunter_dedup: collapse findings that +// name the same primary resource AND the same category, keeping the +// highest-severity one. +// +// primary_resource = f.resources[0].resource_id if f.resources else f.iac_file +// dedup_key = f"{primary_resource}::{f.category}" +// +// Python parity: the winner REPLACES the loser at the loser's position, because +// `seen[dedup_key] = f` rewrites an existing dict entry in place and +// `list(seen.values())` walks insertion order. The Go port keeps an explicit +// key order slice to reproduce that; ranging a Go map would be randomized. +// +// Python parity: the replacement test is STRICTLY greater, so the FIRST finding +// at the top severity for a key wins ties. +func crossHunterDedup(findings []schemas.RawFinding) []schemas.RawFinding { + seen := make(map[string]schemas.RawFinding, len(findings)) + order := make([]string, 0, len(findings)) + + for _, finding := range findings { + primaryResource := finding.IaCFile + if len(finding.Resources) > 0 { + primaryResource = finding.Resources[0].ResourceID + } + dedupKey := primaryResource + "::" + finding.Category + + existing, present := seen[dedupKey] + if !present { + seen[dedupKey] = finding + order = append(order, dedupKey) + continue + } + if severityRank[finding.EstimatedSeverity] > severityRank[existing.EstimatedSeverity] { + seen[dedupKey] = finding + } + } + + out := make([]schemas.RawFinding, 0, len(order)) + for _, key := range order { + out = append(out, seen[key]) + } + return out +} diff --git a/go/internal/phases/hunt_test.go b/go/internal/phases/hunt_test.go new file mode 100644 index 0000000..981e480 --- /dev/null +++ b/go/internal/phases/hunt_test.go @@ -0,0 +1,305 @@ +package phases + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// huntReply builds the HuntResult dump a hunter returns. +func huntReply(t *testing.T, findings ...schemas.RawFinding) map[string]any { + t.Helper() + h := schemas.NewHuntResult() + h.Findings = findings + h.TotalRaw = len(findings) + h.DeduplicatedCount = len(findings) + h.StrategiesRun = []string{} + return mustMap(t, h) +} + +// hunterNameOf turns "cloudsecurity.run_iam_hunter" into "iam". +func hunterNameOf(target string) string { + name := strings.TrimPrefix(target, testNodeID+".run_") + return strings.TrimSuffix(name, "_hunter") +} + +// TestHuntPhase_DepthSelectsHunters pins DEPTH_HUNTER_MAP's fan-out. The Python +// probe recorded, for depth="quick": +// +// ['cloudsecurity.run_iam_hunter', 'cloudsecurity.run_network_hunter', +// 'cloudsecurity.run_data_hunter', 'cloudsecurity.run_secrets_hunter', +// 'cloudsecurity.run_compute_hunter'] +func TestHuntPhase_DepthSelectsHunters(t *testing.T) { + cases := []struct { + depth string + want []string + }{ + {depth: "quick", want: []string{"iam", "network", "data", "secrets", "compute"}}, + {depth: "standard", want: []string{"iam", "network", "data", "secrets", "compute", "logging", "compliance"}}, + {depth: "thorough", want: []string{"iam", "network", "data", "secrets", "compute", "logging", "compliance"}}, + // _normalize_depth lowercases, then falls back to STANDARD. + {depth: "QUICK", want: []string{"iam", "network", "data", "secrets", "compute"}}, + {depth: "bogus", want: []string{"iam", "network", "data", "secrets", "compute", "logging", "compliance"}}, + {depth: "", want: []string{"iam", "network", "data", "secrets", "compute", "logging", "compliance"}}, + } + for _, tc := range cases { + t.Run(tc.depth, func(t *testing.T) { + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { + return huntReply(t), nil + }} + out, err := HuntPhase(context.Background(), fake, testNodeID, "/repo", "/g.json", "/i.json", tc.depth, 3) + if err != nil { + t.Fatalf("HuntPhase: %v", err) + } + + got := make([]string, 0, len(fake.Calls)) + for _, c := range fake.CallTargets() { + got = append(got, hunterNameOf(c)) + } + sortStrings(got) + wantSorted := append([]string(nil), tc.want...) + sortStrings(wantSorted) + if !equalStrings(got, wantSorted) { + t.Fatalf("hunters called = %v, want %v", got, wantSorted) + } + // strategies_run keeps DEPTH_HUNTER_MAP's declaration order. + strategies, ok := pm(out)["strategies_run"].([]string) + if !ok { + t.Fatalf("strategies_run is %T", pm(out)["strategies_run"]) + } + if !equalStrings(strategies, tc.want) { + t.Fatalf("strategies_run = %v, want %v", strategies, tc.want) + } + }) + } +} + +// TestHuntPhase_HunterKwargs pins the four kwargs every hunter receives; the +// Python probe recorded ['depth', 'inventory_path', 'repo_path', +// 'resource_graph_path']. +func TestHuntPhase_HunterKwargs(t *testing.T) { + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { + return huntReply(t), nil + }} + if _, err := HuntPhase(context.Background(), fake, testNodeID, "/repo", "/g.json", "/i.json", "quick", 3); err != nil { + t.Fatalf("HuntPhase: %v", err) + } + for _, c := range fake.Calls { + if got := keysOf(c.Input); !equalStrings(got, []string{"depth", "inventory_path", "repo_path", "resource_graph_path"}) { + t.Fatalf("%s kwargs = %v", c.Target, got) + } + if c.Input["repo_path"] != "/repo" || c.Input["resource_graph_path"] != "/g.json" || + c.Input["inventory_path"] != "/i.json" || c.Input["depth"] != "quick" { + t.Fatalf("%s kwargs values = %v", c.Target, c.Input) + } + } +} + +// TestHuntPhase_FailedHunterContributesEmptyBatchAndNoNote reproduces the probe +// run where run_secrets_hunter raised: 5 hunters called, 4 findings, total_raw 4, +// strategies_run still lists all five, and NOT ONE note is emitted. +func TestHuntPhase_FailedHunterContributesEmptyBatchAndNoNote(t *testing.T) { + fake := &appx.Fake{CallFn: func(_ context.Context, target string, _ map[string]any) (map[string]any, error) { + name := hunterNameOf(target) + if name == "secrets" { + return map[string]any{"error_message": "nope"}, nil + } + return huntReply(t, rawFinding("run_"+name+"_hunter", scoring.SeverityMedium, "fp-run_"+name+"_hunter", name)), nil + }} + + out, err := HuntPhase(context.Background(), fake, testNodeID, "/repo", "/g.json", "/i.json", "quick", 3) + if err != nil { + t.Fatalf("HuntPhase: %v", err) + } + if len(fake.Calls) != 5 { + t.Fatalf("call count = %d", len(fake.Calls)) + } + if pm(out)["total_raw"] != 4 { + t.Errorf("total_raw = %v, want 4", pm(out)["total_raw"]) + } + if pm(out)["deduplicated_count"] != 4 { + t.Errorf("deduplicated_count = %v, want 4", pm(out)["deduplicated_count"]) + } + if pm(out)["hunt_duration_seconds"] != 0.0 { + t.Errorf("hunt_duration_seconds = %v, want 0.0", pm(out)["hunt_duration_seconds"]) + } + ids := make([]string, 0, 4) + for _, f := range pm(out)["findings"].([]schemas.RawFinding) { + ids = append(ids, f.ID) + } + sortStrings(ids) + want := []string{"run_compute_hunter", "run_data_hunter", "run_iam_hunter", "run_network_hunter"} + if !equalStrings(ids, want) { + t.Fatalf("finding ids = %v, want %v", ids, want) + } + if len(fake.Notes) != 0 { + t.Fatalf("hunt_phase must not emit notes, got %v", fake.NoteMessages()) + } +} + +// TestHuntPhase_TransportErrorAlsoYieldsEmptyBatch covers the other half of the +// bare `except Exception` — a call that fails at the transport level, not in the +// envelope. +func TestHuntPhase_TransportErrorAlsoYieldsEmptyBatch(t *testing.T) { + fake := &appx.Fake{CallFn: func(_ context.Context, target string, _ map[string]any) (map[string]any, error) { + if hunterNameOf(target) == "iam" { + return nil, context.DeadlineExceeded + } + return huntReply(t), nil + }} + out, err := HuntPhase(context.Background(), fake, testNodeID, "/repo", "/g.json", "/i.json", "quick", 3) + if err != nil { + t.Fatalf("HuntPhase must swallow hunter failures, got %v", err) + } + if pm(out)["total_raw"] != 0 { + t.Errorf("total_raw = %v", pm(out)["total_raw"]) + } +} + +// TestHuntPhase_IncrementalFingerprintDedup: total_raw counts every finding the +// hunters produced, while findings holds only the first per fingerprint. +func TestHuntPhase_IncrementalFingerprintDedup(t *testing.T) { + fake := &appx.Fake{CallFn: func(_ context.Context, target string, _ map[string]any) (map[string]any, error) { + name := hunterNameOf(target) + // Every hunter reports the SAME fingerprint plus one of its own, and + // each finding gets its own category so cross-hunter dedup is a no-op. + return huntReply(t, + rawFinding("shared-"+name, scoring.SeverityMedium, "shared-fp", "cat-shared-"+name), + rawFinding("own-"+name, scoring.SeverityMedium, "fp-"+name, "cat-own-"+name), + ), nil + }} + out, err := HuntPhase(context.Background(), fake, testNodeID, "/repo", "/g.json", "/i.json", "quick", 3) + if err != nil { + t.Fatalf("HuntPhase: %v", err) + } + if pm(out)["total_raw"] != 10 { + t.Errorf("total_raw = %v, want 10", pm(out)["total_raw"]) + } + // 5 own + 1 shared survivor. + if pm(out)["deduplicated_count"] != 6 { + t.Errorf("deduplicated_count = %v, want 6", pm(out)["deduplicated_count"]) + } +} + +// TestHuntPhase_SynthesizesMissingFingerprint pins +// f"{iac_file}:{iac_line}:{category}" and the fact that it is written back onto +// the finding. +func TestHuntPhase_SynthesizesMissingFingerprint(t *testing.T) { + fake := &appx.Fake{CallFn: func(_ context.Context, target string, _ map[string]any) (map[string]any, error) { + if hunterNameOf(target) != "iam" { + return huntReply(t), nil + } + blank := rawFinding("blank", scoring.SeverityMedium, "", "public_access") + blank.IaCFile = "infra/main.tf" + blank.IaCLine = 42 + return huntReply(t, blank), nil + }} + out, err := HuntPhase(context.Background(), fake, testNodeID, "/repo", "/g.json", "/i.json", "quick", 3) + if err != nil { + t.Fatalf("HuntPhase: %v", err) + } + findings := pm(out)["findings"].([]schemas.RawFinding) + if len(findings) != 1 { + t.Fatalf("findings = %d", len(findings)) + } + if got, want := findings[0].Fingerprint, "infra/main.tf:42:public_access"; got != want { + t.Fatalf("fingerprint = %q, want %q", got, want) + } +} + +// TestHuntPhase_SemaphoreBoundsConcurrency asserts the +// max(1, min(max_concurrent_hunters, len(active_hunters))) bound. +func TestHuntPhase_SemaphoreBoundsConcurrency(t *testing.T) { + cases := []struct { + name string + depth string + limit int + wantPeak int + }{ + {name: "limit below hunter count", depth: "standard", limit: 2, wantPeak: 2}, + {name: "default limit", depth: "standard", limit: 3, wantPeak: 3}, + {name: "limit above hunter count", depth: "quick", limit: 50, wantPeak: 5}, + {name: "zero limit clamps to one", depth: "quick", limit: 0, wantPeak: 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { + time.Sleep(25 * time.Millisecond) + return huntReply(t), nil + }} + if _, err := HuntPhase(context.Background(), fake, testNodeID, "/repo", "/g.json", "/i.json", tc.depth, tc.limit); err != nil { + t.Fatalf("HuntPhase: %v", err) + } + if peak := fake.MaxConcurrentCalls(); peak > tc.wantPeak { + t.Fatalf("peak concurrency = %d, want <= %d", peak, tc.wantPeak) + } else if peak < tc.wantPeak { + t.Fatalf("peak concurrency = %d, want %d (the semaphore should be saturated)", peak, tc.wantPeak) + } + }) + } +} + +// TestCrossHunterDedup reproduces the Python probe's three cases exactly. +func TestCrossHunterDedup(t *testing.T) { + t.Run("highest severity wins and keeps the first position", func(t *testing.T) { + // probe: CROSS_DEDUP ['b', 'c'] + in := []schemas.RawFinding{ + rawFinding("a", scoring.SeverityLow, "fp1", "public_access"), + rawFinding("b", scoring.SeverityCritical, "fp2", "public_access"), + rawFinding("c", scoring.SeverityHigh, "fp3", "other"), + } + got := idsOf(crossHunterDedup(in)) + if !equalStrings(got, []string{"b", "c"}) { + t.Fatalf("ids = %v, want [b c]", got) + } + }) + + t.Run("ties keep the first finding", func(t *testing.T) { + // probe: CROSS_TIE ['d'] + in := []schemas.RawFinding{ + rawFinding("d", scoring.SeverityHigh, "fp4", "public_access"), + rawFinding("e", scoring.SeverityHigh, "fp5", "public_access"), + } + got := idsOf(crossHunterDedup(in)) + if !equalStrings(got, []string{"d"}) { + t.Fatalf("ids = %v, want [d]", got) + } + }) + + t.Run("dedup key prefers resources[0].resource_id over iac_file", func(t *testing.T) { + withResource := rawFinding("r1", scoring.SeverityLow, "fp1", "public_access") + withResource.Resources = []schemas.AffectedResource{{ResourceID: "aws_s3_bucket.a"}} + other := rawFinding("r2", scoring.SeverityCritical, "fp2", "public_access") + other.Resources = []schemas.AffectedResource{{ResourceID: "aws_s3_bucket.b"}} + same := rawFinding("r3", scoring.SeverityCritical, "fp3", "public_access") + same.Resources = []schemas.AffectedResource{{ResourceID: "aws_s3_bucket.a"}} + + got := idsOf(crossHunterDedup([]schemas.RawFinding{withResource, other, same})) + // r3 replaces r1 in r1's slot (higher severity, same key). + if !equalStrings(got, []string{"r3", "r2"}) { + t.Fatalf("ids = %v, want [r3 r2]", got) + } + }) + + t.Run("unknown severity ranks below info", func(t *testing.T) { + unknown := rawFinding("u", scoring.Severity("weird"), "fp1", "public_access") + info := rawFinding("i", scoring.SeverityInfo, "fp2", "public_access") + got := idsOf(crossHunterDedup([]schemas.RawFinding{unknown, info})) + if !equalStrings(got, []string{"i"}) { + t.Fatalf("ids = %v, want [i]", got) + } + }) +} + +func idsOf(findings []schemas.RawFinding) []string { + out := make([]string, 0, len(findings)) + for _, f := range findings { + out = append(out, f.ID) + } + return out +} diff --git a/go/internal/phases/inputs.go b/go/internal/phases/inputs.go new file mode 100644 index 0000000..803155d --- /dev/null +++ b/go/internal/phases/inputs.go @@ -0,0 +1,226 @@ +package phases + +import ( + "context" + "encoding/json" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/afx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" +) + +// This file transcribes the five phase reasoners' Python signatures — parameter +// names AND default values — into bindable input structs, so the reasoner +// adapters in internal/reasoners cannot drift from phases.py's contract and so +// the defaults are asserted by a test rather than repeated by hand. +// +// Each struct's UnmarshalJSON seeds the Python defaults before decoding, which +// is what pydantic/FastAPI does for a reasoner whose parameters have defaults: +// a key absent from the request body takes the signature's default, a key +// present with a null does NOT (it fails validation in Python; here it lands on +// the Go zero value, which is the same practical outcome for the two `| None` +// parameters and unreachable for the rest). +// +// Each struct also carries a Run method that is a thin, allocation-free +// forwarder to the positional phase function — the positional function stays +// the primary API. + +// ReconPhaseInput is recon_phase's signature. +type ReconPhaseInput struct { + RepoPath string `json:"repo_path"` + Depth string `json:"depth"` + Tier int `json:"tier"` + CloudConfig map[string]any `json:"cloud_config"` +} + +// NewReconPhaseInput returns recon_phase's default arguments. +func NewReconPhaseInput() ReconPhaseInput { + return ReconPhaseInput{Depth: DefaultDepth, Tier: DefaultTier} +} + +// UnmarshalJSON seeds recon_phase's defaults before decoding. +func (in *ReconPhaseInput) UnmarshalJSON(b []byte) error { + *in = NewReconPhaseInput() + type alias ReconPhaseInput + return json.Unmarshal(b, (*alias)(in)) +} + +// Run invokes ReconPhase with these arguments. +func (in ReconPhaseInput) Run(ctx context.Context, app appx.Caller, nodeID string) (afx.Payload, error) { + return ReconPhase(ctx, app, nodeID, in.RepoPath, in.Depth, in.Tier, in.CloudConfig) +} + +// HuntPhaseInput is hunt_phase's signature. +type HuntPhaseInput struct { + RepoPath string `json:"repo_path"` + ResourceGraphPath string `json:"resource_graph_path"` + InventoryPath string `json:"inventory_path"` + Depth string `json:"depth"` + MaxConcurrentHunters int `json:"max_concurrent_hunters"` +} + +// NewHuntPhaseInput returns hunt_phase's default arguments. +func NewHuntPhaseInput() HuntPhaseInput { + return HuntPhaseInput{Depth: DefaultDepth, MaxConcurrentHunters: DefaultMaxConcurrentHunters} +} + +// UnmarshalJSON seeds hunt_phase's defaults before decoding. +func (in *HuntPhaseInput) UnmarshalJSON(b []byte) error { + *in = NewHuntPhaseInput() + type alias HuntPhaseInput + return json.Unmarshal(b, (*alias)(in)) +} + +// Run invokes HuntPhase with these arguments. +func (in HuntPhaseInput) Run(ctx context.Context, app appx.Caller, nodeID string) (afx.Payload, error) { + return HuntPhase(ctx, app, nodeID, in.RepoPath, in.ResourceGraphPath, in.InventoryPath, in.Depth, in.MaxConcurrentHunters) +} + +// ChainPhaseInput is chain_phase's signature. +// +// Findings is `[]any`, NOT `[]map[string]any`, and that is load-bearing. +// chain_phase is the ONE ported reasoner whose list parameter Python never +// binds: `findings: list[dict[str, Any]]` is forwarded straight into +// `run_path_constructor` (phases.py:225-243), and the SDK's +// `Agent._validate_handler_input` only checks `isinstance(value, list)` for a +// `list[...]` annotation — never the element type. Binding the elements to +// `map[string]any` here would fail the PARENT on `{"findings": [1, 2, "x"]}`, +// where Python fails the CHILD (RawFinding.model_validate(1) inside +// run_path_constructor) — a different DAG (no child node at all), a different +// error string and a 422 where Python answers with a relayed child failure. +// Compare ProvePhaseInput / RemediationPhaseInput, whose Python bodies DO +// model_validate, and whose Go fields are typed accordingly. +type ChainPhaseInput struct { + Findings []any `json:"findings"` + ResourceGraphPath string `json:"resource_graph_path"` + DriftReport map[string]any `json:"drift_report"` + Depth string `json:"depth"` + MaxChildren int `json:"max_children"` +} + +// NewChainPhaseInput returns chain_phase's default arguments. +func NewChainPhaseInput() ChainPhaseInput { + return ChainPhaseInput{Depth: DefaultDepth, MaxChildren: DefaultMaxChildren} +} + +// UnmarshalJSON seeds chain_phase's defaults before decoding. +func (in *ChainPhaseInput) UnmarshalJSON(b []byte) error { + *in = NewChainPhaseInput() + type alias ChainPhaseInput + return json.Unmarshal(b, (*alias)(in)) +} + +// Run invokes ChainPhase with these arguments. +func (in ChainPhaseInput) Run(ctx context.Context, app appx.Caller, nodeID string) (afx.Payload, error) { + return ChainPhase(ctx, app, nodeID, in.Findings, in.ResourceGraphPath, in.DriftReport, in.Depth, in.MaxChildren) +} + +// ProvePhaseInput is prove_phase's signature. +type ProvePhaseInput struct { + RepoPath string `json:"repo_path"` + HuntResult map[string]any `json:"hunt_result"` + ChainResult map[string]any `json:"chain_result"` + Depth string `json:"depth"` + Tier int `json:"tier"` + MaxConcurrentProvers int `json:"max_concurrent_provers"` +} + +// NewProvePhaseInput returns prove_phase's default arguments. +func NewProvePhaseInput() ProvePhaseInput { + return ProvePhaseInput{Depth: DefaultDepth, Tier: DefaultTier, MaxConcurrentProvers: DefaultMaxConcurrentProvers} +} + +// UnmarshalJSON seeds prove_phase's defaults before decoding. +func (in *ProvePhaseInput) UnmarshalJSON(b []byte) error { + *in = NewProvePhaseInput() + type alias ProvePhaseInput + return json.Unmarshal(b, (*alias)(in)) +} + +// Run invokes ProvePhase with these arguments. +func (in ProvePhaseInput) Run(ctx context.Context, app appx.Caller, nodeID string) (afx.Payload, error) { + return ProvePhase(ctx, app, nodeID, in.RepoPath, in.HuntResult, in.ChainResult, in.Depth, in.Tier, in.MaxConcurrentProvers) +} + +// RemediationPhaseInput is remediation_phase's signature. +type RemediationPhaseInput struct { + RepoPath string `json:"repo_path"` + VerifiedFindings []map[string]any `json:"verified_findings"` + MaxConcurrentRemediations int `json:"max_concurrent_remediations"` +} + +// NewRemediationPhaseInput returns remediation_phase's default arguments. +func NewRemediationPhaseInput() RemediationPhaseInput { + return RemediationPhaseInput{MaxConcurrentRemediations: DefaultMaxConcurrentRemediations} +} + +// UnmarshalJSON seeds remediation_phase's defaults before decoding. +func (in *RemediationPhaseInput) UnmarshalJSON(b []byte) error { + *in = NewRemediationPhaseInput() + type alias RemediationPhaseInput + return json.Unmarshal(b, (*alias)(in)) +} + +// Run invokes RemediationPhase with these arguments. +func (in RemediationPhaseInput) Run(ctx context.Context, app appx.Caller, nodeID string) (afx.Payload, error) { + return RemediationPhase(ctx, app, nodeID, in.RepoPath, in.VerifiedFindings, in.MaxConcurrentRemediations) +} + +// --- Python signature transcriptions for the SDK input validation ------------ +// +// HandlerInputFields feeds afx.ValidateHandlerInput, the port of the Python +// SDK's _validate_handler_input, which runs on the request body before the +// coroutine is entered. Required == the signature has NO default; Optional == +// the annotation admits None. See internal/afx/handlerinput.go. + +// HandlerInputFields is recon_phase's signature. +func (ReconPhaseInput) HandlerInputFields() []afx.Field { + return []afx.Field{ + {Name: "repo_path", Type: afx.TypeStr, Required: true}, + {Name: "depth", Type: afx.TypeStr}, + {Name: "tier", Type: afx.TypeInt}, + {Name: "cloud_config", Type: afx.TypeDict, Optional: true}, + } +} + +// HandlerInputFields is hunt_phase's signature. +func (HuntPhaseInput) HandlerInputFields() []afx.Field { + return []afx.Field{ + {Name: "repo_path", Type: afx.TypeStr, Required: true}, + {Name: "resource_graph_path", Type: afx.TypeStr, Required: true}, + {Name: "inventory_path", Type: afx.TypeStr, Required: true}, + {Name: "depth", Type: afx.TypeStr}, + {Name: "max_concurrent_hunters", Type: afx.TypeInt}, + } +} + +// HandlerInputFields is chain_phase's signature. +func (ChainPhaseInput) HandlerInputFields() []afx.Field { + return []afx.Field{ + {Name: "findings", Type: afx.TypeList, Required: true}, + {Name: "resource_graph_path", Type: afx.TypeStr, Required: true}, + {Name: "drift_report", Type: afx.TypeDict, Optional: true}, + {Name: "depth", Type: afx.TypeStr}, + {Name: "max_children", Type: afx.TypeInt}, + } +} + +// HandlerInputFields is prove_phase's signature. +func (ProvePhaseInput) HandlerInputFields() []afx.Field { + return []afx.Field{ + {Name: "repo_path", Type: afx.TypeStr, Required: true}, + {Name: "hunt_result", Type: afx.TypeDict, Required: true}, + {Name: "chain_result", Type: afx.TypeDict, Required: true}, + {Name: "depth", Type: afx.TypeStr}, + {Name: "tier", Type: afx.TypeInt}, + {Name: "max_concurrent_provers", Type: afx.TypeInt}, + } +} + +// HandlerInputFields is remediation_phase's signature. +func (RemediationPhaseInput) HandlerInputFields() []afx.Field { + return []afx.Field{ + {Name: "repo_path", Type: afx.TypeStr, Required: true}, + {Name: "verified_findings", Type: afx.TypeList, Required: true}, + {Name: "max_concurrent_remediations", Type: afx.TypeInt}, + } +} diff --git a/go/internal/phases/inputs_test.go b/go/internal/phases/inputs_test.go new file mode 100644 index 0000000..26ccfe1 --- /dev/null +++ b/go/internal/phases/inputs_test.go @@ -0,0 +1,87 @@ +package phases + +import ( + "encoding/json" + "os" + "testing" +) + +// TestPhaseInputDefaults pins every default argument in phases.py's five +// reasoner signatures, decoded from an EMPTY body the way a caller that omits +// the optional parameters produces. +func TestPhaseInputDefaults(t *testing.T) { + var recon ReconPhaseInput + mustUnmarshal(t, `{"repo_path":"/repo"}`, &recon) + if recon.Depth != "standard" || recon.Tier != 1 || recon.CloudConfig != nil { + t.Errorf("recon_phase defaults = %#v", recon) + } + + var hunt HuntPhaseInput + mustUnmarshal(t, `{"repo_path":"/repo"}`, &hunt) + if hunt.Depth != "standard" || hunt.MaxConcurrentHunters != 3 { + t.Errorf("hunt_phase defaults = %#v", hunt) + } + + var chain ChainPhaseInput + mustUnmarshal(t, `{"resource_graph_path":"/g.json"}`, &chain) + if chain.Depth != "standard" || chain.MaxChildren != 3 || chain.DriftReport != nil { + t.Errorf("chain_phase defaults = %#v", chain) + } + + var prove ProvePhaseInput + mustUnmarshal(t, `{"repo_path":"/repo"}`, &prove) + if prove.Depth != "standard" || prove.Tier != 1 || prove.MaxConcurrentProvers != 3 { + t.Errorf("prove_phase defaults = %#v", prove) + } + + var remediation RemediationPhaseInput + mustUnmarshal(t, `{"repo_path":"/repo"}`, &remediation) + if remediation.MaxConcurrentRemediations != 3 { + t.Errorf("remediation_phase defaults = %#v", remediation) + } +} + +// TestPhaseInputExplicitValuesWin makes sure the default seeding does not +// clobber a supplied value (including a deliberate zero). +func TestPhaseInputExplicitValuesWin(t *testing.T) { + var hunt HuntPhaseInput + mustUnmarshal(t, `{"repo_path":"/r","depth":"quick","max_concurrent_hunters":0}`, &hunt) + if hunt.Depth != "quick" || hunt.MaxConcurrentHunters != 0 { + t.Fatalf("hunt = %#v", hunt) + } +} + +// TestNodeID covers `os.getenv("NODE_ID", "cloudsecurity")`. +func TestNodeID(t *testing.T) { + t.Setenv("NODE_ID", "cloudsecurity-go") + if got := NodeID(); got != "cloudsecurity-go" { + t.Fatalf("NodeID() = %q", got) + } + if err := os.Unsetenv("NODE_ID"); err != nil { + t.Fatalf("unsetenv: %v", err) + } + if got := NodeID(); got != DefaultNodeID { + t.Fatalf("NodeID() = %q, want %q", got, DefaultNodeID) + } + // An EXPORTED-EMPTY NODE_ID resolves to the default, not to "". + // + // This is the port's one deliberate divergence from os.getenv, and it is + // only safe because internal/node resolves the REGISTERED id with the same + // helper. Python cannot desynchronise (app.py, phases.py and + // orchestrator.py all spell `os.getenv("NODE_ID", "cloudsecurity")`), so + // its registered id and its call-target prefix always agree; when Go read + // this one with os.LookupEnv the node registered as "cloudsecurity" and + // then called ".run_iac_reader", a target the SDK does not repair because + // it already contains a dot. + t.Setenv("NODE_ID", "") + if got := NodeID(); got != DefaultNodeID { + t.Fatalf("NodeID() = %q, want %q", got, DefaultNodeID) + } +} + +func mustUnmarshal(t *testing.T, body string, dest any) { + t.Helper() + if err := json.Unmarshal([]byte(body), dest); err != nil { + t.Fatalf("unmarshal %s: %v", body, err) + } +} diff --git a/go/internal/phases/payload_order_test.go b/go/internal/phases/payload_order_test.go new file mode 100644 index 0000000..2f96848 --- /dev/null +++ b/go/internal/phases/payload_order_test.go @@ -0,0 +1,231 @@ +package phases + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// VALIDATION CONTRACT — the reasoner reply BYTES. +// +// A Python reasoner returns `result.model_dump()` (or a dict literal) and +// FastAPI serialises it with json.dumps, which preserves a dict's insertion +// order. So the reply's key order is pydantic's FIELD-DECLARATION order, and +// every float field is spelled the Python way. Both were read off the live +// models under the repo venv: +// +// list(ReconResult.model_fields) -> inventory, resource_graph, drift_report, +// live_inventory, iac_type, providers_detected, total_resources, +// total_edges, recon_duration_seconds +// list(HuntResult.model_fields) -> findings, total_raw, deduplicated_count, +// strategies_run, hunt_duration_seconds +// list(ChainResult.model_fields) -> attack_paths, total_paths_evaluated, +// viable_paths, chain_duration_seconds +// reasoners/phases.py:317-322 -> verified, total_selected, total_findings, +// not_verified (a dict LITERAL) +// reasoners/phases.py:367 -> verified +// json.dumps(VerifiedFinding(...).model_dump(exclude_none=True)) +// -> ..."risk_score": 0.0, ..., +// "sarif_security_severity": 0.0, ... +// +// A Go map return produced sorted keys (deduplicated_count, findings, +// hunt_duration_seconds, strategies_run, total_raw) and `0` for every integral +// float. afx.Payload restores both. + +// jsonKeys returns the top-level keys of a JSON object in DOCUMENT order. +func jsonKeys(t *testing.T, body []byte) []string { + t.Helper() + dec := json.NewDecoder(strings.NewReader(string(body))) + tok, err := dec.Token() + if err != nil || tok != json.Delim('{') { + t.Fatalf("body is not a JSON object: %s", body) + } + var keys []string + depth := 0 + for dec.More() || depth > 0 { + tok, err := dec.Token() + if err != nil { + t.Fatalf("scan %s: %v", body, err) + } + switch v := tok.(type) { + case json.Delim: + switch v { + case '{', '[': + depth++ + case '}', ']': + depth-- + if depth < 0 { + return keys + } + } + case string: + if depth == 0 { + keys = append(keys, v) + // Skip this key's value wholesale. + var raw json.RawMessage + if err := dec.Decode(&raw); err != nil { + t.Fatalf("decode value of %q: %v", v, err) + } + } + } + } + return keys +} + +func TestPhaseReplies_KeepPydanticFieldOrderOnTheWire(t *testing.T) { + t.Run("recon_phase", 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_iac_reader") { + return map[string]any{"inventory_saved_path": "/tmp/inv.json"}, nil + } + return map[string]any{"graph_saved_path": "/tmp/graph.json"}, nil + }} + out, err := ReconPhase(context.Background(), fake, testNodeID, "/repo", "standard", 1, nil) + if err != nil { + t.Fatalf("ReconPhase: %v", err) + } + assertKeyOrder(t, out, []string{ + "inventory", "resource_graph", "drift_report", "live_inventory", "iac_type", + "providers_detected", "total_resources", "total_edges", "recon_duration_seconds", + }) + }) + + t.Run("prove_phase", func(t *testing.T) { + hunt := huntResultMap(t) + out, err := ProvePhase(context.Background(), &appx.Fake{}, testNodeID, "/repo", hunt, chainResultMap(t), "standard", 1, 3) + if err != nil { + t.Fatalf("ProvePhase: %v", err) + } + // phases.py:317-322 returns the LITERAL dict in this order. + assertKeyOrder(t, out, []string{"verified", "total_selected", "total_findings", "not_verified"}) + }) + + t.Run("remediation_phase", func(t *testing.T) { + out, err := RemediationPhase(context.Background(), &appx.Fake{}, testNodeID, "/repo", nil, 3) + if err != nil { + t.Fatalf("RemediationPhase: %v", err) + } + assertKeyOrder(t, out, []string{"verified"}) + }) +} + +// assertKeyOrder marshals the reply exactly as the SDK does and compares the +// document's key order. +func assertKeyOrder(t *testing.T, reply any, want []string) { + t.Helper() + body, err := json.Marshal(reply) + if err != nil { + t.Fatalf("marshal reply: %v", err) + } + got := jsonKeys(t, body) + if len(got) != len(want) { + t.Fatalf("keys = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("key %d = %q, want %q (full order %v, want %v)", i, got[i], want[i], got, want) + } + } +} + +// Python renders a pydantic float field as `0.0`; encoding/json renders +// float64(0) as `0`. The reply bytes are what an external consumer parses, so +// the spelling is observable. +func TestPhaseReplies_SpellFloatsThePythonWay(t *testing.T) { + hunt := huntResultMap(t, rawFinding("p1", scoring.SeverityHigh, "fp1", "c")) + fake := &appx.Fake{CallFn: func(context.Context, string, map[string]any) (map[string]any, error) { + // A prover reply with no risk_score: the model default 0.0 applies. + return map[string]any{ + "title": "t", "verdict": string(schemas.VerdictConfirmed), + "severity": string(scoring.SeverityHigh), "category": "c", + }, nil + }} + out, err := ProvePhase(context.Background(), fake, testNodeID, "/repo", hunt, chainResultMap(t), "standard", 1, 3) + if err != nil { + t.Fatalf("ProvePhase: %v", err) + } + body, err := json.Marshal(out) + if err != nil { + t.Fatalf("marshal: %v", err) + } + // encoding/json COMPACTS a Marshaler's output, dropping the ", " / ": " + // separators — which is also what FastAPI's JSONResponse does + // (json.dumps(..., separators=(",", ":"))), so the wire bytes agree. + for _, want := range []string{`"risk_score":0.0`, `"sarif_security_severity":0.0`} { + if !strings.Contains(string(body), want) { + t.Errorf("reply does not contain %s\n%s", want, body) + } + } + // hunt_duration_seconds is the same story on the hunt reply. + huntOut, err := HuntPhase(context.Background(), &appx.Fake{}, testNodeID, "/repo", "/g", "/i", "standard", 3) + if err != nil { + t.Fatalf("HuntPhase: %v", err) + } + huntBody, err := json.Marshal(huntOut) + if err != nil { + t.Fatalf("marshal hunt: %v", err) + } + if !strings.Contains(string(huntBody), `"hunt_duration_seconds":0.0`) { + t.Errorf("hunt reply does not spell hunt_duration_seconds as 0.0\n%s", huntBody) + } +} + +// The `verified` ENTRIES are model_dump(exclude_none=True) dicts, and Python +// keeps their field order too. Ground truth from the repo venv: +// +// json.dumps(VerifiedFinding(title="t", verdict="confirmed", severity="high", +// category="c").model_dump(exclude_none=True)) +// -> id, title, verdict, severity, category, resources, proof, +// compliance_mappings, risk_score, sarif_rule_id, sarif_security_severity, +// iac_file, iac_line, config_snippet, description, fingerprint, +// hunter_strategy +// +// (attack_path, drift, remediation and drop_reason are dropped by exclude_none.) +func TestProvePhase_VerifiedEntriesKeepPydanticFieldOrder(t *testing.T) { + hunt := huntResultMap(t, rawFinding("p1", scoring.SeverityHigh, "fp1", "c")) + fake := &appx.Fake{CallFn: func(context.Context, string, map[string]any) (map[string]any, error) { + return map[string]any{ + "title": "t", "verdict": string(schemas.VerdictConfirmed), + "severity": string(scoring.SeverityHigh), "category": "c", + }, nil + }} + out, err := ProvePhase(context.Background(), fake, testNodeID, "/repo", hunt, chainResultMap(t), "standard", 1, 3) + if err != nil { + t.Fatalf("ProvePhase: %v", err) + } + body, err := json.Marshal(out) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + // Pull the first object out of the "verified" array and read its key order. + var envelope struct { + Verified []json.RawMessage `json:"verified"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + t.Fatalf("decode envelope: %v\n%s", err, body) + } + if len(envelope.Verified) != 1 { + t.Fatalf("verified = %d entries, want 1", len(envelope.Verified)) + } + got := jsonKeys(t, envelope.Verified[0]) + want := []string{ + "id", "title", "verdict", "severity", "category", "resources", "proof", + "compliance_mappings", "risk_score", "sarif_rule_id", "sarif_security_severity", + "iac_file", "iac_line", "config_snippet", "description", "fingerprint", + "hunter_strategy", + } + if len(got) != len(want) { + t.Fatalf("verified entry keys = %v\nwant %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("verified entry key %d = %q, want %q\ngot %v\nwant %v", i, got[i], want[i], got, want) + } + } +} diff --git a/go/internal/phases/phases.go b/go/internal/phases/phases.go new file mode 100644 index 0000000..006ee77 --- /dev/null +++ b/go/internal/phases/phases.go @@ -0,0 +1,154 @@ +package phases + +import ( + "context" + "reflect" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/afx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/config" +) + +// DefaultNodeID is the fallback in `os.getenv("NODE_ID", "cloudsecurity")`. +const DefaultNodeID = config.DefaultNodeID + +// NodeID ports phases.py's module-level +// +// NODE_ID = os.getenv("NODE_ID", "cloudsecurity") +// +// It delegates to config.NodeID so the prefix of every Call target this package +// builds is resolved by the SAME rule internal/node uses for the id the agent +// REGISTERS under. Python gets that invariant for free (one os.getenv spelling +// in all three modules); in Go it has to be a shared helper, because the two +// resolutions living apart is exactly how an exported-empty NODE_ID once +// produced a node registered as `cloudsecurity` that called ".run_iac_reader". +// +// Python parity caveat: Python evaluates that ONCE, at import time, so a later +// os.environ change is invisible to the running node. Go reads the environment +// at CALL time — a deliberate divergence that makes t.Setenv deterministic and +// that cannot change live behavior, because the node's NODE_ID is fixed before +// the first reasoner runs. +func NodeID() string { return config.NodeID() } + +// The Python reasoner signatures' default arguments, named so the reasoner +// adapters (internal/reasoners) and the orchestrator cannot drift from them. +const ( + // DefaultDepth is `depth: str = "standard"` on every phase reasoner. + DefaultDepth = "standard" + // DefaultTier is `tier: int = 1` on recon_phase and prove_phase. + DefaultTier = 1 + // DefaultMaxConcurrentHunters is `max_concurrent_hunters: int = 3` on + // hunt_phase. NOTE it differs from BudgetConfig.max_concurrent_hunters + // (4), which is what the orchestrator actually passes. + DefaultMaxConcurrentHunters = 3 + // DefaultMaxChildren is `max_children: int = 3` on chain_phase. + DefaultMaxChildren = 3 + // DefaultMaxConcurrentProvers is `max_concurrent_provers: int = 3` on + // prove_phase. + DefaultMaxConcurrentProvers = 3 + // DefaultMaxConcurrentRemediations is + // `max_concurrent_remediations: int = 3` on remediation_phase. + DefaultMaxConcurrentRemediations = 3 +) + +// chainLimitFallback / proverCapFallback are the literal second arguments of +// the two `.get(profile, N)` lookups in phases.py. They are unreachable — a +// _normalize_depth result is always a key of both tables — but they are part of +// the ported source, so they are named rather than inlined. +const ( + chainLimitFallback = 15 + proverCapFallback = 30 +) + +// chainLimitFor ports `DEPTH_CHAIN_LIMITS.get(profile, 15)`. +func chainLimitFor(profile config.DepthProfile) int { + if v, ok := config.DepthChainLimits[profile]; ok { + return v + } + return chainLimitFallback +} + +// proverCapFor ports `DEPTH_PROVER_CAPS.get(profile, 30)`. +func proverCapFor(profile config.DepthProfile) int { + if v, ok := config.DepthProverCaps[profile]; ok { + return v + } + return proverCapFallback +} + +// callModel performs one `await router.call(f"{nodeID}.{reasoner}", **kwargs)` +// and materializes the reply the way phases.py does: +// +// Model.model_validate(_as_dict(_unwrap(raw, name), name)) +// +// `name` is the string phases.py passes to _unwrap/_as_dict, which is normally +// the reasoner name but deliberately NOT always (prove_phase passes the literal +// "prover"), so it is a separate parameter from the call target. +func callModel[T any](ctx context.Context, app appx.Caller, target, name string, kwargs map[string]any) (T, error) { + var zero T + raw, err := app.Call(ctx, target, kwargs) + if err != nil { + return zero, err + } + return bindCallResult[T](raw, name) +} + +// bindCallResult is callModel's pure tail: unwrap the envelope, require a dict, +// bind the model. prove_phase needs it separately because it collects raw +// results from a gather() before unwrapping them. +func bindCallResult[T any](raw any, name string) (T, error) { + var zero T + payload, err := afx.UnwrapStrict(raw, name) + if err != nil { + return zero, err + } + m, err := afx.AsMap(payload, name) + if err != nil { + return zero, err + } + return afx.Bind[T](m) +} + +// semaphore is asyncio.Semaphore(n): a buffered channel used as a counting +// semaphore. Capacity is clamped to at least 1 so a zero/negative limit cannot +// deadlock (every caller already computes max(1, ...), matching Python). +type semaphore chan struct{} + +func newSemaphore(n int) semaphore { + if n < 1 { + n = 1 + } + return make(semaphore, n) +} + +func (s semaphore) acquire() { s <- struct{}{} } +func (s semaphore) release() { <-s } + +// pyTruthy reproduces Python's bool(v) for the JSON value kinds that reach the +// providers scan (None, False, 0, "", [], {} are falsy). +// +// It is a local copy of the same predicate afx keeps unexported; duplicating +// six lines is preferable to widening afx's API for one call site. +func pyTruthy(v any) bool { + if v == nil { + return false + } + rv := reflect.ValueOf(v) + 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: + return !rv.IsNil() + } + return true +} diff --git a/go/internal/phases/prove.go b/go/internal/phases/prove.go new file mode 100644 index 0000000..e8374d2 --- /dev/null +++ b/go/internal/phases/prove.go @@ -0,0 +1,246 @@ +package phases + +import ( + "context" + "sort" + "sync" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/afx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/config" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// PrioritizeFindings ports phases.py _prioritize_findings: +// +// sorted(findings, key=lambda f: sev.get(f.estimated_severity, 0), reverse=True) +// +// Python's sorted() is stable and `reverse=True` does NOT reverse ties (CPython +// reverses the list, sorts, and reverses again), so findings of equal severity +// keep their input order — which is exactly sort.SliceStable with a strict +// greater-than comparison. It returns a NEW slice; the input is not reordered, +// matching sorted(). +func PrioritizeFindings(findings []schemas.RawFinding) []schemas.RawFinding { + out := make([]schemas.RawFinding, len(findings)) + copy(out, findings) + sort.SliceStable(out, func(i, j int) bool { + return severityRank[out[i].EstimatedSeverity] > severityRank[out[j].EstimatedSeverity] + }) + return out +} + +// ProvePhase ports src/cloudsecurity_af/reasoners/phases.py prove_phase. +// +// Python: +// +// @router.reasoner() +// async def prove_phase(repo_path: str, hunt_result: dict[str, Any], chain_result: dict[str, Any], +// depth: str = "standard", tier: int = 1, +// max_concurrent_provers: int = 3) -> dict[str, Any]: +// +// DAG shape: K children, all of them run_static_prover when tier < 2 and all of +// them run_live_prover otherwise, under a semaphore of +// max(1, min(max_concurrent_provers, len(selected))). +// +// Python parity notes: +// +// - K = min(len(hunt.findings), DEPTH_PROVER_CAPS[depth]) — quick 20, +// standard 30, thorough 10000 — over the severity-prioritized order. +// - The `attack_path` kwarg is present ONLY for a finding that appears in +// some AttackPath.findings_involved. Its value is the LAST path that names +// the finding (`attack_path_map[fid] = path.model_dump()` overwrites), in +// chain.attack_paths order. +// - gather(return_exceptions=True): a failed prover does not abort the phase. +// A transport/unwrap failure yields _fallback_verified(finding, str(exc)); +// a payload that will not bind yields +// _fallback_verified(finding, f"Schema parse failed: {exc}"). The evidence +// text therefore embeds a Go error string where Python embeds a Python +// exception string — the one place the two nodes' bytes cannot match. +// - `_unwrap(raw, "prover")` uses the literal name "prover", NOT the prover +// reasoner's name, so a failed envelope reads "prover failed: ...". +// - The result dict's `verified` entries are model_dump(exclude_none=True); +// the three counters are plain ints. +func ProvePhase( + ctx context.Context, + app appx.Caller, + nodeID string, + repoPath string, + huntResult map[string]any, + chainResult map[string]any, + depth string, + tier int, + maxConcurrentProvers int, +) (afx.Payload, error) { + hunt, err := afx.Bind[schemas.HuntResult](huntResult) + if err != nil { + return nil, err + } + chain, err := afx.Bind[schemas.ChainResult](chainResult) + if err != nil { + return nil, err + } + + profile := config.NormalizeDepth(depth) + proverCap := proverCapFor(profile) + + prioritized := PrioritizeFindings(hunt.Findings) + selected := prioritized + if len(selected) > proverCap { + selected = selected[:proverCap] + } + + // Python parity: `max(1, min(max_concurrent_provers, len(selected))) if selected else 1`. + concurrencyLimit := 1 + if len(selected) > 0 { + concurrencyLimit = maxConcurrentProvers + if len(selected) < concurrencyLimit { + concurrencyLimit = len(selected) + } + if concurrencyLimit < 1 { + concurrencyLimit = 1 + } + } + sem := newSemaphore(concurrencyLimit) + + attackPathMap := make(map[string]map[string]any) + for _, path := range chain.AttackPaths { + dumped, dumpErr := afx.ToMap(path) + if dumpErr != nil { + return nil, dumpErr + } + for _, findingID := range path.FindingsInvolved { + attackPathMap[findingID] = dumped + } + } + + proverName := "run_static_prover" + if tier >= 2 { + proverName = "run_live_prover" + } + + type proveOutcome struct { + raw any + err error + } + outcomes := make([]proveOutcome, len(selected)) + + var wg sync.WaitGroup + for i := range selected { + wg.Add(1) + go func(idx int) { + defer wg.Done() + sem.acquire() + defer sem.release() + + finding := selected[idx] + dumped, dumpErr := afx.ToMap(finding) + if dumpErr != nil { + outcomes[idx] = proveOutcome{err: dumpErr} + return + } + kwargs := map[string]any{ + "repo_path": repoPath, + "finding": dumped, + "tier": tier, + } + if attackPath, present := attackPathMap[finding.ID]; present { + kwargs["attack_path"] = attackPath + } + raw, callErr := app.Call(ctx, nodeID+"."+proverName, kwargs) + outcomes[idx] = proveOutcome{raw: raw, err: callErr} + }(i) + } + wg.Wait() + + verified := make([]schemas.VerifiedFinding, 0, len(selected)) + for idx, outcome := range outcomes { + finding := selected[idx] + if outcome.err != nil { + verified = append(verified, FallbackVerified(finding, outcome.err.Error())) + continue + } + bound, bindErr := bindCallResult[schemas.VerifiedFinding](outcome.raw, "prover") + if bindErr != nil { + verified = append(verified, FallbackVerified(finding, "Schema parse failed: "+bindErr.Error())) + continue + } + verified = append(verified, bound) + } + + dumps := make([]afx.Payload, 0, len(verified)) + for _, v := range verified { + dumped, dumpErr := afx.DumpExcludeNone(v) + if dumpErr != nil { + return nil, dumpErr + } + dumps = append(dumps, dumped) + } + + notVerified := len(hunt.Findings) - len(selected) + if notVerified < 0 { + notVerified = 0 + } + + // Python parity — KEY ORDER. phases.py returns the dict LITERAL + // + // {"verified": ..., "total_selected": ..., "total_findings": ..., + // "not_verified": ...} + // + // and json.dumps preserves a dict's insertion order, so those four keys + // reach the caller in that order. A Go map would put them on the wire + // alphabetically (not_verified, total_findings, total_selected, verified). + return afx.Payload{ + {K: "verified", V: dumps}, + {K: "total_selected", V: len(selected)}, + {K: "total_findings", V: len(hunt.Findings)}, + {K: "not_verified", V: notVerified}, + }, nil +} + +// FallbackVerified ports phases.py _fallback_verified: the INCONCLUSIVE stand-in +// a finding gets when its prover call or its reply could not be used. +// +// VerifiedFinding( +// id=..., title=..., verdict=INCONCLUSIVE, severity=finding.estimated_severity, +// category=..., resources=..., proof=Proof(method=STATIC_ANALYSIS, evidence=[error_msg]), +// iac_file=..., iac_line=..., config_snippet=..., description=..., fingerprint=..., +// hunter_strategy=..., sarif_rule_id=f"cloudsecurity/{hunter_strategy}/{category}", +// sarif_security_severity=0.0, drop_reason="prover_error") +// +// Every field NOT listed above keeps its pydantic default, so the Go port starts +// from schemas.NewVerifiedFinding() and overwrites — notably attack_path, drift +// and remediation stay nil (and are therefore dropped by exclude_none), while +// compliance_mappings stays []. +// +// Python parity: `id` and `fingerprint` are copied from the raw finding, so the +// uuid4 default_factories NewVerifiedFinding mints are always overwritten. +// +// Python parity: `resources` shares the RawFinding's list object in Python; the +// Go port copies the slice header, which is the same aliasing, and nothing +// mutates it afterwards. +func FallbackVerified(finding schemas.RawFinding, errorMsg string) schemas.VerifiedFinding { + proof := schemas.NewProof() + proof.Method = schemas.ProofMethodStaticAnalysis + proof.Evidence = []string{errorMsg} + + dropReason := "prover_error" + + out := schemas.NewVerifiedFinding() + out.ID = finding.ID + out.Title = finding.Title + out.Verdict = schemas.VerdictInconclusive + out.Severity = finding.EstimatedSeverity + out.Category = finding.Category + out.Resources = finding.Resources + out.Proof = proof + out.IaCFile = finding.IaCFile + out.IaCLine = finding.IaCLine + out.ConfigSnippet = finding.ConfigSnippet + out.Description = finding.Description + out.Fingerprint = finding.Fingerprint + out.HunterStrategy = finding.HunterStrategy + out.SARIFRuleID = "cloudsecurity/" + finding.HunterStrategy + "/" + finding.Category + out.SARIFSecuritySeverity = 0.0 + out.DropReason = &dropReason + return out +} diff --git a/go/internal/phases/prove_test.go b/go/internal/phases/prove_test.go new file mode 100644 index 0000000..63a0d92 --- /dev/null +++ b/go/internal/phases/prove_test.go @@ -0,0 +1,482 @@ +package phases + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// huntResultMap builds prove_phase's `hunt_result` argument. +// Python parity: every list field of a model_dump() is a LIST, never null — +// `HuntResult(findings=None)` is a pydantic ValidationError — so the seeded +// empty slice must survive a call with no variadic arguments (which hands the +// helper a NIL slice). +func huntResultMap(t *testing.T, findings ...schemas.RawFinding) map[string]any { + t.Helper() + h := schemas.NewHuntResult() + h.Findings = append(h.Findings, findings...) + h.TotalRaw = len(findings) + h.DeduplicatedCount = len(findings) + h.StrategiesRun = []string{"iam"} + return jsonMap(t, h) +} + +// chainResultMap builds prove_phase's `chain_result` argument. +func chainResultMap(t *testing.T, paths ...schemas.AttackPath) map[string]any { + t.Helper() + c := schemas.NewChainResult() + c.AttackPaths = append(c.AttackPaths, paths...) + c.TotalPathsEvaluated = len(paths) + c.ViablePaths = len(paths) + return jsonMap(t, c) +} + +func attackPath(id string, findingIDs ...string) schemas.AttackPath { + p := schemas.NewAttackPath() + p.ID = id + p.Title = "t" + p.Description = "d" + p.EntryPoint = "e" + p.Target = "t2" + p.FindingsInvolved = findingIDs + p.CombinedSeverity = scoring.SeverityHigh + return p +} + +// TestPrioritizeFindings_StableDescending reproduces the probe's +// PRIORITIZE ['cr', 'hi', 'lo', 'lo2'] — descending severity with ties in input +// order. +func TestPrioritizeFindings_StableDescending(t *testing.T) { + in := []schemas.RawFinding{ + rawFinding("lo", scoring.SeverityLow, "a", "c"), + rawFinding("cr", scoring.SeverityCritical, "b", "c"), + rawFinding("lo2", scoring.SeverityLow, "c", "c"), + rawFinding("hi", scoring.SeverityHigh, "d", "c"), + } + got := idsOf(PrioritizeFindings(in)) + if !equalStrings(got, []string{"cr", "hi", "lo", "lo2"}) { + t.Fatalf("order = %v, want [cr hi lo lo2]", got) + } + // sorted() returns a NEW list; the input must be untouched. + if !equalStrings(idsOf(in), []string{"lo", "cr", "lo2", "hi"}) { + t.Fatalf("input was reordered: %v", idsOf(in)) + } +} + +// TestProvePhase_ProbeParity reproduces the Python probe run end to end: two +// findings, one attack path naming the critical one, a prover that always +// raises, depth quick / tier 1. +// +// PROVE_CALLS [('cloudsecurity.run_static_prover', ['attack_path','finding','repo_path','tier']), +// ('cloudsecurity.run_static_prover', ['finding','repo_path','tier'])] +// PROVE_COUNTS {'total_selected': 2, 'total_findings': 2, 'not_verified': 0} +// PROVE_EVIDENCE [['prover down'], ['prover down']] +func TestProvePhase_ProbeParity(t *testing.T) { + hunt := huntResultMap(t, + rawFinding("p1", scoring.SeverityCritical, "a", "public_access"), + rawFinding("p2", scoring.SeverityLow, "b", "public_access"), + ) + chain := chainResultMap(t, attackPath("ap1", "p1")) + + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { + return nil, errors.New("prover down") + }} + + out, err := ProvePhase(context.Background(), fake, testNodeID, "/repo", hunt, chain, "quick", 1, 3) + if err != nil { + t.Fatalf("ProvePhase: %v", err) + } + + if got := fake.CallTargets(); !equalStrings(got, []string{ + testNodeID + ".run_static_prover", testNodeID + ".run_static_prover"}) { + t.Fatalf("targets = %v", got) + } + // The two provers run concurrently, so the RECORDED order is not + // meaningful (Python's gather has the same property); key the assertions on + // the finding each call carried. p1 is the one named by the attack path. + byFinding := map[string]map[string]any{} + for _, c := range fake.Calls { + finding := jsonMap(t, c.Input["finding"]) + byFinding[finding["id"].(string)] = c.Input + } + if got := keysOf(byFinding["p1"]); !equalStrings(got, []string{"attack_path", "finding", "repo_path", "tier"}) { + t.Fatalf("p1 prover kwargs = %v", got) + } + if got := keysOf(byFinding["p2"]); !equalStrings(got, []string{"finding", "repo_path", "tier"}) { + t.Fatalf("p2 prover kwargs = %v", got) + } + if byFinding["p1"]["repo_path"] != "/repo" || byFinding["p1"]["tier"] != 1 { + t.Fatalf("kwargs values = %#v", byFinding["p1"]) + } + + if pm(out)["total_selected"] != 2 || pm(out)["total_findings"] != 2 || pm(out)["not_verified"] != 0 { + t.Fatalf("counters = %#v", out) + } + verified := verifiedList(t, out) + if len(verified) != 2 { + t.Fatalf("verified = %d", len(verified)) + } + for i, v := range verified { + evidence := proofEvidence(t, v) + if len(evidence) != 1 || evidence[0] != "prover down" { + t.Fatalf("verified[%d] evidence = %v", i, evidence) + } + if v["verdict"] != "inconclusive" || v["drop_reason"] != "prover_error" { + t.Fatalf("verified[%d] = %#v", i, v) + } + } + // exclude_none: the three optional model fields are absent, matching the + // probe's PROVE_VERIFIED_KEYS. + wantKeys := []string{ + "category", "compliance_mappings", "config_snippet", "description", "drop_reason", + "fingerprint", "hunter_strategy", "iac_file", "iac_line", "id", "proof", "resources", + "risk_score", "sarif_rule_id", "sarif_security_severity", "severity", "title", "verdict", + } + if got := keysOf(verified[0]); !equalStrings(got, wantKeys) { + t.Fatalf("verified keys = %v\nwant %v", got, wantKeys) + } +} + +// TestProvePhase_TierSelectsProver: tier < 2 -> run_static_prover, else +// run_live_prover. +func TestProvePhase_TierSelectsProver(t *testing.T) { + cases := []struct { + tier int + want string + }{ + {tier: 0, want: "run_static_prover"}, + {tier: 1, want: "run_static_prover"}, + {tier: 2, want: "run_live_prover"}, + {tier: 3, want: "run_live_prover"}, + } + for _, tc := range cases { + t.Run(fmt.Sprintf("tier%d", tc.tier), func(t *testing.T) { + hunt := huntResultMap(t, rawFinding("p1", scoring.SeverityHigh, "a", "c")) + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, in map[string]any) (map[string]any, error) { + if in["tier"] != tc.tier { + t.Errorf("tier kwarg = %v, want %d", in["tier"], tc.tier) + } + return nil, errors.New("x") + }} + if _, err := ProvePhase(context.Background(), fake, testNodeID, "/repo", hunt, + chainResultMap(t), "standard", tc.tier, 3); err != nil { + t.Fatalf("ProvePhase: %v", err) + } + if got := fake.CallTargets(); !equalStrings(got, []string{testNodeID + "." + tc.want}) { + t.Fatalf("targets = %v, want %s", got, tc.want) + } + }) + } +} + +// TestProvePhase_DepthCapsSelection pins DEPTH_PROVER_CAPS (quick 20 — +// tests/test_config.py's 10 is stale — standard 30, thorough 10000) and the +// derived counters. +func TestProvePhase_DepthCapsSelection(t *testing.T) { + cases := []struct { + depth string + findings int + wantSelected int + wantNotVerified int + }{ + {depth: "quick", findings: 25, wantSelected: 20, wantNotVerified: 5}, + {depth: "standard", findings: 25, wantSelected: 25, wantNotVerified: 0}, + {depth: "standard", findings: 40, wantSelected: 30, wantNotVerified: 10}, + {depth: "thorough", findings: 40, wantSelected: 40, wantNotVerified: 0}, + {depth: "bogus", findings: 40, wantSelected: 30, wantNotVerified: 10}, + } + for _, tc := range cases { + t.Run(fmt.Sprintf("%s-%d", tc.depth, tc.findings), func(t *testing.T) { + findings := make([]schemas.RawFinding, 0, tc.findings) + for i := 0; i < tc.findings; i++ { + findings = append(findings, rawFinding(fmt.Sprintf("f%02d", i), scoring.SeverityMedium, + fmt.Sprintf("fp%02d", i), "c")) + } + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { + return nil, errors.New("x") + }} + out, err := ProvePhase(context.Background(), fake, testNodeID, "/repo", + huntResultMap(t, findings...), chainResultMap(t), tc.depth, 1, 3) + if err != nil { + t.Fatalf("ProvePhase: %v", err) + } + if len(fake.Calls) != tc.wantSelected { + t.Fatalf("calls = %d, want %d", len(fake.Calls), tc.wantSelected) + } + if pm(out)["total_selected"] != tc.wantSelected { + t.Errorf("total_selected = %v, want %d", pm(out)["total_selected"], tc.wantSelected) + } + if pm(out)["total_findings"] != tc.findings { + t.Errorf("total_findings = %v, want %d", pm(out)["total_findings"], tc.findings) + } + if pm(out)["not_verified"] != tc.wantNotVerified { + t.Errorf("not_verified = %v, want %d", pm(out)["not_verified"], tc.wantNotVerified) + } + }) + } +} + +// TestProvePhase_AttackPathMapLastPathWins: attack_path_map[fid] is overwritten +// by every later path that names the finding, so the LAST one is passed. +func TestProvePhase_AttackPathMapLastPathWins(t *testing.T) { + hunt := huntResultMap(t, rawFinding("p1", scoring.SeverityHigh, "a", "c")) + chain := chainResultMap(t, attackPath("first", "p1"), attackPath("second", "p1")) + + var captured map[string]any + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, in map[string]any) (map[string]any, error) { + captured = in + return nil, errors.New("x") + }} + if _, err := ProvePhase(context.Background(), fake, testNodeID, "/repo", hunt, chain, "standard", 1, 3); err != nil { + t.Fatalf("ProvePhase: %v", err) + } + ap, ok := captured["attack_path"].(map[string]any) + if !ok { + t.Fatalf("attack_path is %T", captured["attack_path"]) + } + if ap["id"] != "second" { + t.Fatalf("attack_path id = %v, want second", ap["id"]) + } +} + +// TestProvePhase_SchemaParseFailureFallback covers the second _fallback_verified +// arm, whose evidence is prefixed "Schema parse failed: ". +func TestProvePhase_SchemaParseFailureFallback(t *testing.T) { + cases := []struct { + name string + reply map[string]any + wantSuffix string + }{ + { + name: "non dict payload", + reply: map[string]any{"output": "a string"}, + wantSuffix: "prover returned non-dict payload: str", + }, + { + // Every OTHER required field is present, so the only thing the + // bind can fail on is the enum — mirroring pydantic, which reports + // the missing fields AND the bad value when both are wrong. + name: "invalid verdict enum", + reply: map[string]any{ + "title": "t", "verdict": "bogus", "severity": "high", "category": "c", + }, + wantSuffix: `"bogus" is not a valid Verdict`, + }, + { + // Python: VerifiedFinding.model_validate({}) raises + // "4 validation errors for VerifiedFinding ... Field required", so + // prove_phase takes the same _fallback_verified branch. Before the + // port enforced required fields this reply bound successfully to a + // finding with verdict "" and severity "" — uncounted in the + // verdict tallies and dropped by the severity threshold. + name: "missing required fields", + reply: map[string]any{}, + wantSuffix: "4 validation errors for VerifiedFinding: title, verdict, severity, category: Field required", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + hunt := huntResultMap(t, rawFinding("p1", scoring.SeverityHigh, "fp1", "c")) + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { + return tc.reply, nil + }} + out, err := ProvePhase(context.Background(), fake, testNodeID, "/repo", hunt, chainResultMap(t), "standard", 1, 3) + if err != nil { + t.Fatalf("ProvePhase: %v", err) + } + verified := verifiedList(t, out) + evidence := proofEvidence(t, verified[0]) + got := evidence[0].(string) + if !strings.HasPrefix(got, "Schema parse failed: ") { + t.Fatalf("evidence = %q, want the Schema parse failed prefix", got) + } + if !strings.Contains(got, tc.wantSuffix) { + t.Fatalf("evidence = %q, want it to mention %q", got, tc.wantSuffix) + } + if verified[0]["id"] != "p1" { + t.Fatalf("fallback lost the finding id: %#v", verified[0]) + } + }) + } +} + +// TestProvePhase_SuccessfulProverPayloadIsUsed proves a valid reply is bound as +// the VerifiedFinding rather than falling back. +func TestProvePhase_SuccessfulProverPayloadIsUsed(t *testing.T) { + proven := verifiedFinding("p1", schemas.VerdictConfirmed, scoring.SeverityCritical) + proven.RiskScore = 9.5 + hunt := huntResultMap(t, rawFinding("p1", scoring.SeverityHigh, "fp1", "c")) + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { + return jsonMap(t, proven), nil + }} + out, err := ProvePhase(context.Background(), fake, testNodeID, "/repo", hunt, chainResultMap(t), "standard", 1, 3) + if err != nil { + t.Fatalf("ProvePhase: %v", err) + } + verified := verifiedList(t, out) + // afx.DumpExcludeNone renders through pyfmt, so a float field stays a + // float (and re-renders as 9.5 / 0.0) while an integer inside a free-form + // dict stays an int. + if verified[0]["verdict"] != "confirmed" || verified[0]["risk_score"] != 9.5 { + t.Fatalf("verified = %#v", verified[0]) + } + if _, present := verified[0]["drop_reason"]; present { + t.Fatalf("drop_reason must be absent (exclude_none) for a real reply: %#v", verified[0]) + } +} + +// TestProvePhase_SemaphoreBoundsConcurrency asserts +// max(1, min(max_concurrent_provers, len(selected))) — and the `if selected else 1` +// arm for the empty case. +func TestProvePhase_SemaphoreBoundsConcurrency(t *testing.T) { + cases := []struct { + name string + findings int + limit int + wantPeak int + }{ + {name: "limit below selection", findings: 6, limit: 2, wantPeak: 2}, + {name: "default limit", findings: 6, limit: 3, wantPeak: 3}, + {name: "limit above selection", findings: 2, limit: 10, wantPeak: 2}, + {name: "zero limit clamps to one", findings: 3, limit: 0, wantPeak: 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + findings := make([]schemas.RawFinding, 0, tc.findings) + for i := 0; i < tc.findings; i++ { + findings = append(findings, rawFinding(fmt.Sprintf("f%d", i), scoring.SeverityMedium, + fmt.Sprintf("fp%d", i), "c")) + } + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { + time.Sleep(25 * time.Millisecond) + return nil, errors.New("x") + }} + if _, err := ProvePhase(context.Background(), fake, testNodeID, "/repo", + huntResultMap(t, findings...), chainResultMap(t), "standard", 1, tc.limit); err != nil { + t.Fatalf("ProvePhase: %v", err) + } + if peak := fake.MaxConcurrentCalls(); peak != tc.wantPeak { + t.Fatalf("peak concurrency = %d, want %d", peak, tc.wantPeak) + } + }) + } +} + +// TestProvePhase_NoFindingsMakesNoCalls covers the empty-selection path. +func TestProvePhase_NoFindingsMakesNoCalls(t *testing.T) { + fake := &appx.Fake{} + out, err := ProvePhase(context.Background(), fake, testNodeID, "/repo", + huntResultMap(t), chainResultMap(t), "standard", 1, 3) + if err != nil { + t.Fatalf("ProvePhase: %v", err) + } + if len(fake.Calls) != 0 { + t.Fatalf("calls = %v", fake.CallTargets()) + } + if pm(out)["total_selected"] != 0 || pm(out)["total_findings"] != 0 || pm(out)["not_verified"] != 0 { + t.Fatalf("counters = %#v", out) + } + if got := verifiedList(t, out); len(got) != 0 { + t.Fatalf("verified = %v", got) + } +} + +// TestFallbackVerified reproduces the Python probe's FALLBACK dump field by +// field. +func TestFallbackVerified(t *testing.T) { + src := rawFinding("x", scoring.SeverityHigh, "fpx", "public_access") + got := FallbackVerified(src, "boom") + + if got.ID != "x" || got.Title != "t" || got.Category != "public_access" { + t.Errorf("identity fields = %#v", got) + } + if got.Verdict != schemas.VerdictInconclusive { + t.Errorf("verdict = %v", got.Verdict) + } + if got.Severity != scoring.SeverityHigh { + t.Errorf("severity = %v (must come from estimated_severity)", got.Severity) + } + if got.Proof.Method != schemas.ProofMethodStaticAnalysis { + t.Errorf("proof.method = %v", got.Proof.Method) + } + if !equalStrings(got.Proof.Evidence, []string{"boom"}) { + t.Errorf("proof.evidence = %v", got.Proof.Evidence) + } + if got.Proof.VerificationTier != "static" || len(got.Proof.ScriptsExecuted) != 0 { + t.Errorf("proof defaults = %#v", got.Proof) + } + if got.IaCFile != "main.tf" || got.IaCLine != 1 || got.Description != "d" || got.Fingerprint != "fpx" { + t.Errorf("traceability fields = %#v", got) + } + if got.HunterStrategy != "iam" { + t.Errorf("hunter_strategy = %q", got.HunterStrategy) + } + if got.SARIFRuleID != "cloudsecurity/iam/public_access" { + t.Errorf("sarif_rule_id = %q", got.SARIFRuleID) + } + if got.SARIFSecuritySeverity != 0.0 || got.RiskScore != 0.0 { + t.Errorf("scores = %v / %v", got.SARIFSecuritySeverity, got.RiskScore) + } + if got.DropReason == nil || *got.DropReason != "prover_error" { + t.Errorf("drop_reason = %v", got.DropReason) + } + if got.AttackPath != nil || got.Drift != nil || got.Remediation != nil { + t.Errorf("optional models must stay nil: %#v", got) + } + if got.ComplianceMappings == nil || len(got.ComplianceMappings) != 0 { + t.Errorf("compliance_mappings = %#v, want []", got.ComplianceMappings) + } + if got.Resources == nil || len(got.Resources) != 0 { + t.Errorf("resources = %#v, want []", got.Resources) + } +} + +// TestProvePhase_VerifiedFollowsPrioritizedOrder proves the reply list is keyed +// on the ARGUMENT order gather preserves (severity-prioritized), not on the +// order the provers happened to finish in. +func TestProvePhase_VerifiedFollowsPrioritizedOrder(t *testing.T) { + hunt := huntResultMap(t, + rawFinding("low", scoring.SeverityLow, "fp-low", "c"), + rawFinding("critical", scoring.SeverityCritical, "fp-critical", "c"), + rawFinding("high", scoring.SeverityHigh, "fp-high", "c"), + ) + // Make the CRITICAL prover the slowest so completion order is the reverse + // of the prioritized order. + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, in map[string]any) (map[string]any, error) { + id := jsonMap(t, in["finding"])["id"].(string) + switch id { + case "critical": + time.Sleep(40 * time.Millisecond) + case "high": + time.Sleep(20 * time.Millisecond) + } + return nil, fmt.Errorf("failed %s", id) + }} + + out, err := ProvePhase(context.Background(), fake, testNodeID, "/repo", hunt, chainResultMap(t), "standard", 1, 3) + if err != nil { + t.Fatalf("ProvePhase: %v", err) + } + verified := verifiedList(t, out) + got := make([]string, 0, len(verified)) + for _, v := range verified { + got = append(got, v["id"].(string)) + } + if !equalStrings(got, []string{"critical", "high", "low"}) { + t.Fatalf("verified order = %v, want [critical high low]", got) + } + // Each fallback carries ITS OWN finding's error, proving the index pairing. + for i, id := range got { + evidence := proofEvidence(t, verified[i]) + if evidence[0] != "failed "+id { + t.Fatalf("verified[%d] evidence = %v, want the %s error", i, evidence, id) + } + } +} diff --git a/go/internal/phases/recon.go b/go/internal/phases/recon.go new file mode 100644 index 0000000..7714dd2 --- /dev/null +++ b/go/internal/phases/recon.go @@ -0,0 +1,213 @@ +package phases + +import ( + "context" + "encoding/json" + "os" + "sort" + "sync" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/afx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// ReconPhase ports src/cloudsecurity_af/reasoners/phases.py recon_phase. +// +// Python: +// +// @router.reasoner() +// async def recon_phase(repo_path: str, depth: str = "standard", tier: int = 1, +// cloud_config: dict[str, Any] | None = None) -> dict[str, Any]: +// +// DAG shape: +// +// recon_phase +// ├── run_iac_reader (sequential) +// ├── run_resource_graph_builder (sequential, needs the inventory path) +// ├── run_cloud_connector ┐ gather(2) — only when tier >= 2 AND cloud_config is not None +// └── run_drift_detector ┘ +// +// Python parity notes: +// +// - `depth` is accepted and IGNORED. recon_phase never reads it; it exists +// only so the orchestrator can pass the same kwarg to every phase. Keep the +// parameter — dropping it would change the reasoner's input schema. +// - The tier-2 gate is `tier >= 2 and cloud_config is not None`. A nil Go map +// is None; an EMPTY but non-nil map is `{}`, which is not None, so it opens +// the gate exactly as Python's `{} is not None` does. +// - asyncio.gather propagates whichever of the two calls fails FIRST in wall +// clock time, and Python then unwraps the two replies SEQUENTIALLY +// (connector, then detector) after the gather returns. Go unwraps inside +// each goroutine and reports the cloud-connector slot before the drift +// slot. That matches Python for the common cases (only one side fails; both +// sides fail on their envelope) and is a deliberate determinism fix for the +// rest. Neither implementation cancels the sibling call — asyncio.gather +// does not either, so ctx is passed through untouched. +// - providers_detected is read back off the inventory FILE, not off the +// ResourceInventory model, and ANY failure yields an empty list. +func ReconPhase( + ctx context.Context, + app appx.Caller, + nodeID string, + repoPath string, + depth string, + tier int, + cloudConfig map[string]any, +) (afx.Payload, error) { + _ = depth // Python parity: recon_phase declares `depth` but never reads it. + + inventory, err := callModel[schemas.ResourceInventory](ctx, app, + nodeID+".run_iac_reader", "run_iac_reader", + map[string]any{ + "repo_path": repoPath, + }) + if err != nil { + return nil, err + } + + resourceGraph, err := callModel[schemas.ResourceGraph](ctx, app, + nodeID+".run_resource_graph_builder", "run_resource_graph_builder", + map[string]any{ + "repo_path": repoPath, + "inventory_path": inventory.InventorySavedPath, + }) + if err != nil { + return nil, err + } + + var driftReport *schemas.DriftReport + var liveInventory *schemas.ResourceInventory + + if tier >= 2 && cloudConfig != nil { + var ( + wg sync.WaitGroup + live schemas.ResourceInventory + liveErr error + drift schemas.DriftReport + driftErr error + connector = nodeID + ".run_cloud_connector" + detector = nodeID + ".run_drift_detector" + ) + wg.Add(2) + go func() { + defer wg.Done() + live, liveErr = callModel[schemas.ResourceInventory](ctx, app, + connector, "run_cloud_connector", + map[string]any{ + "cloud_config": cloudConfig, + }) + }() + go func() { + defer wg.Done() + drift, driftErr = callModel[schemas.DriftReport](ctx, app, + detector, "run_drift_detector", + map[string]any{ + "iac_graph_path": resourceGraph.GraphSavedPath, + "cloud_config": cloudConfig, + }) + }() + wg.Wait() + + if liveErr != nil { + return nil, liveErr + } + if driftErr != nil { + return nil, driftErr + } + liveInventory = &live + driftReport = &drift + } + + recon := schemas.NewReconResult() + recon.Inventory = inventory + recon.ResourceGraph = resourceGraph + recon.DriftReport = driftReport + recon.LiveInventory = liveInventory + recon.IaCType = inventory.IaCType + recon.ProvidersDetected = providersFromInventoryFile(inventory.InventorySavedPath) + recon.TotalResources = inventory.TotalResources + recon.TotalEdges = resourceGraph.TotalEdges + + // Python: `return recon.model_dump()` — afx.Dump keeps pydantic's field + // declaration order, which json.dumps preserves on the wire. + return afx.Dump(recon) +} + +// providersFromInventoryFile ports the inline block at the end of recon_phase: +// +// try: +// with open(inventory.inventory_saved_path, "r") as f: +// inv_data = json.load(f) +// if not isinstance(inv_data, dict): inv_data = {"resources": []} +// raw_res = inv_data.get("resources", []) +// if not isinstance(raw_res, list): raw_res = [] +// providers = sorted({r.get("provider") for r in raw_res +// if isinstance(r, dict) and r.get("provider")}) +// except Exception: +// providers = [] +// +// i.e. the distinct truthy `provider` strings of the inventory's resources, in +// sorted order — with a missing file, malformed JSON or any other failure +// collapsing to an empty list rather than an error. +// +// sorted() over a set of str sorts by code point; Go's sort.Strings sorts by +// byte, and for UTF-8 the two orders coincide. +// +// DIVERGENCE (unreachable in practice): if a resource carries a NON-string +// truthy provider, Python's sorted() raises a TypeError only when the set is +// mixed-type — an all-int set would sort, and then pydantic would reject the +// list[str] field. Go has nowhere to put a non-string, so it takes the +// documented "any failure -> []" branch. The inventory writer +// (agents/recon/tfparse.go) only ever emits provider strings. +func providersFromInventoryFile(path string) []string { + empty := []string{} + + data, err := os.ReadFile(path) + if err != nil { + return empty + } + var top any + if err := json.Unmarshal(data, &top); err != nil { + return empty + } + + invData, isObject := top.(map[string]any) + if !isObject { + // Python parity: a non-dict document is replaced wholesale by + // {"resources": []}, so there is nothing left to scan. + return empty + } + rawRes, present := invData["resources"] + if !present { + return empty + } + list, isList := rawRes.([]any) + if !isList { + return empty + } + + seen := make(map[string]struct{}, len(list)) + for _, element := range list { + resource, isDict := element.(map[string]any) + if !isDict { + continue + } + provider, has := resource["provider"] + if !has || !pyTruthy(provider) { + continue + } + name, isString := provider.(string) + if !isString { + return empty + } + seen[name] = struct{}{} + } + + providers := make([]string, 0, len(seen)) + for name := range seen { + providers = append(providers, name) + } + sort.Strings(providers) + return providers +} diff --git a/go/internal/phases/recon_test.go b/go/internal/phases/recon_test.go new file mode 100644 index 0000000..a155e1b --- /dev/null +++ b/go/internal/phases/recon_test.go @@ -0,0 +1,365 @@ +package phases + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// writeInventory drops a JSON document at /inventory.json and returns its +// path — the file recon_phase re-reads to derive providers_detected. +func writeInventory(t *testing.T, doc any) string { + t.Helper() + path := filepath.Join(t.TempDir(), "inventory.json") + body, err := json.Marshal(doc) + if err != nil { + t.Fatalf("marshal inventory: %v", err) + } + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatalf("write inventory: %v", err) + } + return path +} + +// reconFake answers run_iac_reader / run_resource_graph_builder and, when +// asked, the two tier-2 reasoners. +func reconFake(t *testing.T, inventoryPath string, extra func(target string, in map[string]any) (map[string]any, error)) *appx.Fake { + t.Helper() + inventory := schemas.NewResourceInventory() + inventory.InventorySavedPath = inventoryPath + inventory.TotalResources = 3 + inventory.IaCType = "terraform" + + graph := schemas.NewResourceGraph() + graph.GraphSavedPath = "/g.json" + graph.TotalNodes = 3 + graph.TotalEdges = 2 + + return &appx.Fake{CallFn: func(_ context.Context, target string, in map[string]any) (map[string]any, error) { + switch target { + case testNodeID + ".run_iac_reader": + return mustMap(t, inventory), nil + case testNodeID + ".run_resource_graph_builder": + return mustMap(t, graph), nil + } + if extra != nil { + return extra(target, in) + } + t.Errorf("unexpected call target %q", target) + return nil, nil + }} +} + +// TestReconPhase_Tier1CallsIacReaderThenGraphBuilder pins the two sequential +// children and their kwargs — the Python probe recorded +// +// [('cloudsecurity.run_iac_reader', ['repo_path']), +// ('cloudsecurity.run_resource_graph_builder', ['inventory_path', 'repo_path'])] +func TestReconPhase_Tier1CallsIacReaderThenGraphBuilder(t *testing.T) { + inventoryPath := writeInventory(t, map[string]any{"resources": []any{}}) + fake := reconFake(t, inventoryPath, nil) + + out, err := ReconPhase(context.Background(), fake, testNodeID, "/repo", "quick", 1, nil) + if err != nil { + t.Fatalf("ReconPhase: %v", err) + } + + wantTargets := []string{testNodeID + ".run_iac_reader", testNodeID + ".run_resource_graph_builder"} + if got := fake.CallTargets(); !equalStrings(got, wantTargets) { + t.Fatalf("targets = %v, want %v", got, wantTargets) + } + if got := keysOf(fake.Calls[0].Input); !equalStrings(got, []string{"repo_path"}) { + t.Errorf("run_iac_reader kwargs = %v", got) + } + if got := fake.Calls[0].Input["repo_path"]; got != "/repo" { + t.Errorf("repo_path = %v", got) + } + if got := keysOf(fake.Calls[1].Input); !equalStrings(got, []string{"inventory_path", "repo_path"}) { + t.Errorf("run_resource_graph_builder kwargs = %v", got) + } + if got := fake.Calls[1].Input["inventory_path"]; got != inventoryPath { + t.Errorf("inventory_path = %v, want %v", got, inventoryPath) + } + + // The Python probe printed these ReconResult keys. + wantKeys := []string{ + "drift_report", "iac_type", "inventory", "live_inventory", "providers_detected", + "recon_duration_seconds", "resource_graph", "total_edges", "total_resources", + } + if got := payloadKeys(out); !equalStrings(got, wantKeys) { + t.Fatalf("recon keys = %v, want %v", got, wantKeys) + } + if pm(out)["iac_type"] != "terraform" { + t.Errorf("iac_type = %v", pm(out)["iac_type"]) + } + if pm(out)["total_resources"] != 3 { + t.Errorf("total_resources = %v", pm(out)["total_resources"]) + } + if pm(out)["total_edges"] != 2 { + t.Errorf("total_edges = %v", pm(out)["total_edges"]) + } + if pm(out)["drift_report"] != (*schemas.DriftReport)(nil) { + t.Errorf("drift_report should be nil for tier 1, got %#v", pm(out)["drift_report"]) + } +} + +// TestReconPhase_ProvidersDetected reproduces the probe's inventory fixture — +// duplicates collapse, falsy and non-dict entries are skipped, and the result is +// sorted: ['aws', 'gcp']. +func TestReconPhase_ProvidersDetected(t *testing.T) { + cases := []struct { + name string + doc any + want []string + }{ + { + name: "sorted unique truthy providers", + doc: map[string]any{"resources": []any{ + map[string]any{"provider": "aws"}, + map[string]any{"provider": "gcp"}, + map[string]any{"provider": "aws"}, + map[string]any{"provider": ""}, + map[string]any{"noprov": 1}, + "notadict", + }}, + want: []string{"aws", "gcp"}, + }, + {name: "no resources key", doc: map[string]any{}, want: []string{}}, + {name: "resources not a list", doc: map[string]any{"resources": "nope"}, want: []string{}}, + {name: "document not an object", doc: []any{1, 2}, want: []string{}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := writeInventory(t, tc.doc) + fake := reconFake(t, path, nil) + out, err := ReconPhase(context.Background(), fake, testNodeID, "/repo", "standard", 1, nil) + if err != nil { + t.Fatalf("ReconPhase: %v", err) + } + got, ok := pm(out)["providers_detected"].([]string) + if !ok { + t.Fatalf("providers_detected is %T", pm(out)["providers_detected"]) + } + if !equalStrings(got, tc.want) { + t.Fatalf("providers = %v, want %v", got, tc.want) + } + }) + } +} + +// TestReconPhase_MissingInventoryFileYieldsNoProviders covers the bare +// `except Exception: providers = []` arm. +func TestReconPhase_MissingInventoryFileYieldsNoProviders(t *testing.T) { + fake := reconFake(t, filepath.Join(t.TempDir(), "does-not-exist.json"), nil) + out, err := ReconPhase(context.Background(), fake, testNodeID, "/repo", "standard", 1, nil) + if err != nil { + t.Fatalf("ReconPhase: %v", err) + } + if got := pm(out)["providers_detected"].([]string); len(got) != 0 { + t.Fatalf("providers = %v, want []", got) + } +} + +// TestReconPhase_TierTwoGatesOnBothTierAndCloudConfig pins the +// `tier >= 2 and cloud_config is not None` gate, including the fact that an +// EMPTY but non-nil map is not None and therefore opens it. +func TestReconPhase_TierTwoGatesOnBothTierAndCloudConfig(t *testing.T) { + cases := []struct { + name string + tier int + cloudConfig map[string]any + wantCalls int + }{ + {name: "tier 1 with config", tier: 1, cloudConfig: map[string]any{"provider": "aws"}, wantCalls: 2}, + {name: "tier 2 without config", tier: 2, cloudConfig: nil, wantCalls: 2}, + {name: "tier 2 with config", tier: 2, cloudConfig: map[string]any{"provider": "aws"}, wantCalls: 4}, + {name: "tier 2 with empty config", tier: 2, cloudConfig: map[string]any{}, wantCalls: 4}, + {name: "tier 3 with config", tier: 3, cloudConfig: map[string]any{"provider": "aws"}, wantCalls: 4}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := writeInventory(t, map[string]any{"resources": []any{}}) + live := schemas.NewResourceInventory() + live.InventorySavedPath = "/live.json" + live.IaCType = "terraform" + drift := schemas.NewDriftReport() + drift.CloudOnlyResources = []string{"shadow"} + + fake := reconFake(t, path, func(target string, _ map[string]any) (map[string]any, error) { + switch target { + case testNodeID + ".run_cloud_connector": + return mustMap(t, live), nil + case testNodeID + ".run_drift_detector": + return mustMap(t, drift), nil + } + t.Errorf("unexpected target %q", target) + return nil, nil + }) + + out, err := ReconPhase(context.Background(), fake, testNodeID, "/repo", "standard", tc.tier, tc.cloudConfig) + if err != nil { + t.Fatalf("ReconPhase: %v", err) + } + if got := len(fake.Calls); got != tc.wantCalls { + t.Fatalf("call count = %d, want %d (%v)", got, tc.wantCalls, fake.CallTargets()) + } + if tc.wantCalls == 4 { + if pm(out)["drift_report"] == (*schemas.DriftReport)(nil) { + t.Error("drift_report should be populated for tier >= 2 with a cloud config") + } + if pm(out)["live_inventory"] == (*schemas.ResourceInventory)(nil) { + t.Error("live_inventory should be populated for tier >= 2 with a cloud config") + } + } else if pm(out)["drift_report"] != (*schemas.DriftReport)(nil) { + t.Error("drift_report should stay nil") + } + }) + } +} + +// TestReconPhase_TierTwoKwargsAndConcurrency pins the two tier-2 kwarg sets and +// proves the pair really is a gather: the fake blocks each call until both are +// in flight, which deadlocks (and times out the test) if they run serially. +func TestReconPhase_TierTwoKwargsAndConcurrency(t *testing.T) { + path := writeInventory(t, map[string]any{"resources": []any{}}) + live := schemas.NewResourceInventory() + live.InventorySavedPath = "/live.json" + drift := schemas.NewDriftReport() + + var barrier sync.WaitGroup + barrier.Add(2) + fake := reconFake(t, path, func(target string, _ map[string]any) (map[string]any, error) { + barrier.Done() + barrier.Wait() + switch target { + case testNodeID + ".run_cloud_connector": + return mustMap(t, live), nil + default: + return mustMap(t, drift), nil + } + }) + + cloudConfig := map[string]any{"provider": "aws", "regions": []any{"us-east-1"}} + if _, err := ReconPhase(context.Background(), fake, testNodeID, "/repo", "standard", 2, cloudConfig); err != nil { + t.Fatalf("ReconPhase: %v", err) + } + + var connector, detector map[string]any + for _, c := range fake.Calls { + switch c.Target { + case testNodeID + ".run_cloud_connector": + connector = c.Input + case testNodeID + ".run_drift_detector": + detector = c.Input + } + } + if connector == nil || detector == nil { + t.Fatalf("missing tier-2 calls: %v", fake.CallTargets()) + } + if got := keysOf(connector); !equalStrings(got, []string{"cloud_config"}) { + t.Errorf("run_cloud_connector kwargs = %v", got) + } + if got := keysOf(detector); !equalStrings(got, []string{"cloud_config", "iac_graph_path"}) { + t.Errorf("run_drift_detector kwargs = %v", got) + } + if got := detector["iac_graph_path"]; got != "/g.json" { + t.Errorf("iac_graph_path = %v", got) + } + if fake.MaxConcurrentCalls() < 2 { + t.Errorf("tier-2 calls did not overlap: max concurrency %d", fake.MaxConcurrentCalls()) + } +} + +// TestReconPhase_StrictUnwrapFailuresPropagate covers the phases.py-specific +// _unwrap arms (error_message and status) at every call site. +func TestReconPhase_StrictUnwrapFailuresPropagate(t *testing.T) { + cases := []struct { + name string + failing string + reply map[string]any + wantErr string + }{ + { + name: "iac reader error_message", + failing: testNodeID + ".run_iac_reader", + reply: map[string]any{"error_message": "no terraform"}, + wantErr: "run_iac_reader failed: no terraform", + }, + { + name: "graph builder failed status", + failing: testNodeID + ".run_resource_graph_builder", + reply: map[string]any{"status": "failed"}, + wantErr: "run_resource_graph_builder failed: Unknown error", + }, + { + name: "graph builder error dict", + failing: testNodeID + ".run_resource_graph_builder", + reply: map[string]any{"error": map[string]any{"message": "boom"}}, + wantErr: "run_resource_graph_builder failed: boom", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := writeInventory(t, map[string]any{"resources": []any{}}) + base := reconFake(t, path, nil) + inner := base.CallFn + fake := &appx.Fake{CallFn: func(ctx context.Context, target string, in map[string]any) (map[string]any, error) { + if target == tc.failing { + return tc.reply, nil + } + return inner(ctx, target, in) + }} + _, err := ReconPhase(context.Background(), fake, testNodeID, "/repo", "standard", 1, nil) + if err == nil || err.Error() != tc.wantErr { + t.Fatalf("err = %v, want %q", err, tc.wantErr) + } + }) + } +} + +// TestReconPhase_TierTwoConnectorErrorWinsOverDriftError documents the +// determinism fix: asyncio.gather propagates whichever call fails first in wall +// clock time, Go always reports the cloud-connector slot first. +func TestReconPhase_TierTwoConnectorErrorWinsOverDriftError(t *testing.T) { + path := writeInventory(t, map[string]any{"resources": []any{}}) + fake := reconFake(t, path, func(target string, _ map[string]any) (map[string]any, error) { + switch target { + case testNodeID + ".run_cloud_connector": + return map[string]any{"error_message": "connector down"}, nil + default: + return map[string]any{"error_message": "detector down"}, nil + } + }) + _, err := ReconPhase(context.Background(), fake, testNodeID, "/repo", "standard", 2, map[string]any{}) + if err == nil || err.Error() != "run_cloud_connector failed: connector down" { + t.Fatalf("err = %v, want the connector error", err) + } +} + +// TestReconPhase_DepthIsIgnored pins the Python quirk that recon_phase accepts +// `depth` and never reads it: no depth kwarg reaches any child, and the output +// does not vary with it. +func TestReconPhase_DepthIsIgnored(t *testing.T) { + path := writeInventory(t, map[string]any{"resources": []any{map[string]any{"provider": "aws"}}}) + for _, depth := range []string{"quick", "standard", "thorough", "bogus"} { + fake := reconFake(t, path, nil) + out, err := ReconPhase(context.Background(), fake, testNodeID, "/repo", depth, 1, nil) + if err != nil { + t.Fatalf("ReconPhase(%q): %v", depth, err) + } + for _, c := range fake.Calls { + if _, present := c.Input["depth"]; present { + t.Fatalf("depth leaked into %s kwargs", c.Target) + } + } + if got := pm(out)["providers_detected"].([]string); !equalStrings(got, []string{"aws"}) { + t.Fatalf("providers = %v", got) + } + } +} diff --git a/go/internal/phases/remediate.go b/go/internal/phases/remediate.go new file mode 100644 index 0000000..a890d25 --- /dev/null +++ b/go/internal/phases/remediate.go @@ -0,0 +1,142 @@ +package phases + +import ( + "context" + "sync" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/afx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// RemediationPhase ports src/cloudsecurity_af/reasoners/phases.py +// remediation_phase. +// +// Python: +// +// @router.reasoner() +// async def remediation_phase(repo_path: str, verified_findings: list[dict[str, Any]], +// max_concurrent_remediations: int = 3) -> dict[str, Any]: +// +// DAG shape: one run_fix_generator child per CONFIRMED/LIKELY finding that does +// not already carry a remediation, under a semaphore of +// max(1, min(max_concurrent_remediations, len(needs_remediation))). +// +// Python parity notes: +// +// - The early return when nothing needs remediation still re-dumps every +// finding with exclude_none, so the reasoner's output shape is identical on +// both branches. +// - A failed generator call is swallowed (payload None) and a payload that +// will not bind as a RemediationSuggestion is swallowed too; the finding +// simply keeps remediation=None. No note, no error. +// - Python counts the successes into a local `generated` that it never +// returns. The counter is kept here for fidelity and explicitly discarded. +// - The orchestrator hands this phase model_dump() (NOT exclude_none) output, +// while the phase hands BACK exclude_none output. The asymmetry is Python's. +func RemediationPhase( + ctx context.Context, + app appx.Caller, + nodeID string, + repoPath string, + verifiedFindings []map[string]any, + maxConcurrentRemediations int, +) (afx.Payload, error) { + findings := make([]schemas.VerifiedFinding, 0, len(verifiedFindings)) + for _, raw := range verifiedFindings { + bound, err := afx.Bind[schemas.VerifiedFinding](raw) + if err != nil { + return nil, err + } + findings = append(findings, bound) + } + + needsRemediation := make([]int, 0, len(findings)) + for idx, f := range findings { + if (f.Verdict == schemas.VerdictConfirmed || f.Verdict == schemas.VerdictLikely) && f.Remediation == nil { + needsRemediation = append(needsRemediation, idx) + } + } + + if len(needsRemediation) == 0 { + return verifiedEnvelope(findings) + } + + concurrencyLimit := maxConcurrentRemediations + if len(needsRemediation) < concurrencyLimit { + concurrencyLimit = len(needsRemediation) + } + if concurrencyLimit < 1 { + concurrencyLimit = 1 + } + sem := newSemaphore(concurrencyLimit) + + // payloads[i] mirrors the (idx, payload | None) tuple Python's gather + // returns for needs_remediation[i]. + payloads := make([]map[string]any, len(needsRemediation)) + + var wg sync.WaitGroup + for slot, findingIdx := range needsRemediation { + wg.Add(1) + go func(slot, findingIdx int) { + defer wg.Done() + sem.acquire() + defer sem.release() + + dumped, dumpErr := afx.ToMap(findings[findingIdx]) + if dumpErr != nil { + return + } + raw, callErr := app.Call(ctx, nodeID+".run_fix_generator", map[string]any{ + "repo_path": repoPath, + "finding": dumped, + }) + if callErr != nil { + // Python parity: `except Exception: return (idx, None)`. + return + } + payload, unwrapErr := afx.UnwrapStrict(raw, "run_fix_generator") + if unwrapErr != nil { + return + } + asMap, mapErr := afx.AsMap(payload, "run_fix_generator") + if mapErr != nil { + return + } + payloads[slot] = asMap + }(slot, findingIdx) + } + wg.Wait() + + generated := 0 + for slot, payload := range payloads { + if payload == nil { + continue + } + suggestion, err := afx.Bind[schemas.RemediationSuggestion](payload) + if err != nil { + // Python parity: `except Exception: pass`. + continue + } + findings[needsRemediation[slot]].Remediation = &suggestion + generated++ + } + // Python parity: `generated` is computed and never used. + _ = generated + + return verifiedEnvelope(findings) +} + +// verifiedEnvelope ports the two identical return statements of +// remediation_phase: `{"verified": [f.model_dump(exclude_none=True) for f in findings]}`. +func verifiedEnvelope(findings []schemas.VerifiedFinding) (afx.Payload, error) { + dumps := make([]afx.Payload, 0, len(findings)) + for _, f := range findings { + dumped, err := afx.DumpExcludeNone(f) + if err != nil { + return nil, err + } + dumps = append(dumps, dumped) + } + return afx.Payload{{K: "verified", V: dumps}}, nil +} diff --git a/go/internal/phases/remediate_test.go b/go/internal/phases/remediate_test.go new file mode 100644 index 0000000..1c63094 --- /dev/null +++ b/go/internal/phases/remediate_test.go @@ -0,0 +1,219 @@ +package phases + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// remediationSuggestionReply is the payload the probe's fake fix generator +// returned. +func remediationSuggestionReply() map[string]any { + return map[string]any{ + "finding_id": "v1", + "description": "fix it", + "diffs": []any{}, + "breaking_change": false, + "effort": "trivial", + "alternative_approaches": []any{}, + } +} + +// TestRemediationPhase_ProbeParity reproduces the probe run: two findings, one +// CONFIRMED (gets a fix) and one NOT_EXPLOITABLE (skipped). +// +// REM_CALLS [('cloudsecurity.run_fix_generator', ['finding', 'repo_path'])] +// REM_REMEDIATION [True, False] +func TestRemediationPhase_ProbeParity(t *testing.T) { + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { + return remediationSuggestionReply(), nil + }} + + in := []map[string]any{ + jsonMap(t, verifiedFinding("v1", schemas.VerdictConfirmed, scoring.SeverityHigh)), + jsonMap(t, verifiedFinding("v2", schemas.VerdictNotExploitable, scoring.SeverityHigh)), + } + out, err := RemediationPhase(context.Background(), fake, testNodeID, "/repo", in, 3) + if err != nil { + t.Fatalf("RemediationPhase: %v", err) + } + + if got := fake.CallTargets(); !equalStrings(got, []string{testNodeID + ".run_fix_generator"}) { + t.Fatalf("targets = %v", got) + } + if got := keysOf(fake.Calls[0].Input); !equalStrings(got, []string{"finding", "repo_path"}) { + t.Fatalf("kwargs = %v", got) + } + if fake.Calls[0].Input["repo_path"] != "/repo" { + t.Fatalf("repo_path = %v", fake.Calls[0].Input["repo_path"]) + } + + verified := verifiedList(t, out) + if len(verified) != 2 { + t.Fatalf("verified = %d", len(verified)) + } + if _, present := verified[0]["remediation"]; !present { + t.Errorf("confirmed finding should have gained a remediation: %#v", verified[0]) + } + if _, present := verified[1]["remediation"]; present { + t.Errorf("not_exploitable finding must be left alone: %#v", verified[1]) + } + // Order is preserved: the fix is written back at the finding's own index. + if verified[0]["id"] != "v1" || verified[1]["id"] != "v2" { + t.Fatalf("order changed: %v, %v", verified[0]["id"], verified[1]["id"]) + } +} + +// TestRemediationPhase_SelectsConfirmedAndLikelyWithoutRemediation pins the +// `f.verdict in {CONFIRMED, LIKELY} and f.remediation is None` filter. +func TestRemediationPhase_SelectsConfirmedAndLikelyWithoutRemediation(t *testing.T) { + withFix := verifiedFinding("has-fix", schemas.VerdictConfirmed, scoring.SeverityHigh) + suggestion := schemas.NewRemediationSuggestion() + suggestion.FindingID = "has-fix" + suggestion.Description = "already fixed" + withFix.Remediation = &suggestion + + in := []map[string]any{ + jsonMap(t, verifiedFinding("confirmed", schemas.VerdictConfirmed, scoring.SeverityHigh)), + jsonMap(t, verifiedFinding("likely", schemas.VerdictLikely, scoring.SeverityHigh)), + jsonMap(t, verifiedFinding("inconclusive", schemas.VerdictInconclusive, scoring.SeverityHigh)), + jsonMap(t, verifiedFinding("not-exploitable", schemas.VerdictNotExploitable, scoring.SeverityHigh)), + jsonMap(t, withFix), + } + + // The generators run concurrently, so the recording map needs its own lock. + var mu sync.Mutex + seen := map[string]bool{} + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, kwargs map[string]any) (map[string]any, error) { + finding := jsonMap(t, kwargs["finding"]) + mu.Lock() + seen[finding["id"].(string)] = true + mu.Unlock() + return remediationSuggestionReply(), nil + }} + if _, err := RemediationPhase(context.Background(), fake, testNodeID, "/repo", in, 3); err != nil { + t.Fatalf("RemediationPhase: %v", err) + } + if len(seen) != 2 || !seen["confirmed"] || !seen["likely"] { + t.Fatalf("remediated %v, want confirmed + likely only", seen) + } +} + +// TestRemediationPhase_NoCandidatesShortCircuits: zero calls, and the reply +// still carries every finding in exclude_none form. +func TestRemediationPhase_NoCandidatesShortCircuits(t *testing.T) { + fake := &appx.Fake{} + in := []map[string]any{ + jsonMap(t, verifiedFinding("a", schemas.VerdictInconclusive, scoring.SeverityLow)), + jsonMap(t, verifiedFinding("b", schemas.VerdictNotExploitable, scoring.SeverityLow)), + } + out, err := RemediationPhase(context.Background(), fake, testNodeID, "/repo", in, 3) + if err != nil { + t.Fatalf("RemediationPhase: %v", err) + } + if len(fake.Calls) != 0 { + t.Fatalf("calls = %v", fake.CallTargets()) + } + verified := verifiedList(t, out) + if len(verified) != 2 { + t.Fatalf("verified = %d", len(verified)) + } + for _, v := range verified { + for _, key := range []string{"attack_path", "drift", "remediation"} { + if _, present := v[key]; present { + t.Errorf("exclude_none should have dropped %q: %#v", key, v) + } + } + } +} + +// TestRemediationPhase_EmptyInput covers verified_findings=[]. +func TestRemediationPhase_EmptyInput(t *testing.T) { + fake := &appx.Fake{} + out, err := RemediationPhase(context.Background(), fake, testNodeID, "/repo", nil, 3) + if err != nil { + t.Fatalf("RemediationPhase: %v", err) + } + if len(fake.Calls) != 0 { + t.Fatalf("calls = %v", fake.CallTargets()) + } + if got := verifiedList(t, out); len(got) != 0 { + t.Fatalf("verified = %v", got) + } +} + +// TestRemediationPhase_FailuresAreSwallowed: a failing generator, a +// non-dict payload and a payload that will not bind all leave remediation unset +// and produce no error and no note. +func TestRemediationPhase_FailuresAreSwallowed(t *testing.T) { + cases := []struct { + name string + reply map[string]any + err error + }{ + {name: "transport error", err: errors.New("generator down")}, + {name: "strict unwrap failure", reply: map[string]any{"error_message": "no repo"}}, + {name: "non dict payload", reply: map[string]any{"output": []any{1, 2}}}, + {name: "unbindable payload", reply: map[string]any{"diffs": "not a list"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { + return tc.reply, tc.err + }} + in := []map[string]any{jsonMap(t, verifiedFinding("v1", schemas.VerdictConfirmed, scoring.SeverityHigh))} + out, err := RemediationPhase(context.Background(), fake, testNodeID, "/repo", in, 3) + if err != nil { + t.Fatalf("RemediationPhase must swallow failures, got %v", err) + } + verified := verifiedList(t, out) + if _, present := verified[0]["remediation"]; present { + t.Fatalf("remediation should be absent: %#v", verified[0]) + } + if len(fake.Notes) != 0 { + t.Fatalf("no notes expected, got %v", fake.NoteMessages()) + } + }) + } +} + +// TestRemediationPhase_SemaphoreBoundsConcurrency asserts +// max(1, min(max_concurrent_remediations, len(needs_remediation))). +func TestRemediationPhase_SemaphoreBoundsConcurrency(t *testing.T) { + cases := []struct { + name string + candidates int + limit int + wantPeak int + }{ + {name: "limit below candidates", candidates: 6, limit: 2, wantPeak: 2}, + {name: "default limit", candidates: 6, limit: 3, wantPeak: 3}, + {name: "limit above candidates", candidates: 2, limit: 9, wantPeak: 2}, + {name: "zero limit clamps to one", candidates: 3, limit: 0, wantPeak: 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + in := make([]map[string]any, 0, tc.candidates) + for i := 0; i < tc.candidates; i++ { + in = append(in, jsonMap(t, verifiedFinding(fmt.Sprintf("v%d", i), schemas.VerdictConfirmed, scoring.SeverityHigh))) + } + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { + time.Sleep(25 * time.Millisecond) + return remediationSuggestionReply(), nil + }} + if _, err := RemediationPhase(context.Background(), fake, testNodeID, "/repo", in, tc.limit); err != nil { + t.Fatalf("RemediationPhase: %v", err) + } + if peak := fake.MaxConcurrentCalls(); peak != tc.wantPeak { + t.Fatalf("peak concurrency = %d, want %d", peak, tc.wantPeak) + } + }) + } +} From 3b7085400426030f43241ca177b538b99dabb10e Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 20 Aug 2026 02:11:58 -0400 Subject: [PATCH 4/5] feat(go): register the 22-reasoner surface and the cloudsecurity node internal/reasoners registers scan/prove plus the 20 router reasoners under their exact Python names, tags and signature-derived input schemas (captured from a live Python node and embedded as the parity fixture). internal/node ports app.py: agent construction from the environment, the scan/prove handlers building CloudSecurityInput, the workspaces resolver with the writability fallback, and error mapping. The packaging parity test pins manifests, compose and CI against the Python sources. Co-Authored-By: Claude Fable 5 --- go/cmd/cloudsecurity-af/main.go | 50 + go/internal/node/inputs.go | 309 ++++++ go/internal/node/inputschema_test.go | 143 +++ go/internal/node/node.go | 418 ++++++++ go/internal/node/node_test.go | 982 ++++++++++++++++++ go/internal/node/nodeid_test.go | 97 ++ go/internal/node/payload_order_test.go | 189 ++++ go/internal/node/resolve.go | 284 +++++ go/internal/node/resolve_test.go | 394 +++++++ go/internal/reasoners/inputs.go | 173 +++ go/internal/reasoners/inputschema.go | 145 +++ go/internal/reasoners/inputschema_test.go | 319 ++++++ go/internal/reasoners/inputvalidation_test.go | 360 +++++++ go/internal/reasoners/names.go | 115 ++ go/internal/reasoners/reasoners.go | 302 ++++++ go/internal/reasoners/reasoners_test.go | 764 ++++++++++++++ .../testdata/python_input_schemas.json | 549 ++++++++++ go/packaging_test.go | 522 ++++++++++ 18 files changed, 6115 insertions(+) create mode 100644 go/cmd/cloudsecurity-af/main.go create mode 100644 go/internal/node/inputs.go create mode 100644 go/internal/node/inputschema_test.go create mode 100644 go/internal/node/node.go create mode 100644 go/internal/node/node_test.go create mode 100644 go/internal/node/nodeid_test.go create mode 100644 go/internal/node/payload_order_test.go create mode 100644 go/internal/node/resolve.go create mode 100644 go/internal/node/resolve_test.go create mode 100644 go/internal/reasoners/inputs.go create mode 100644 go/internal/reasoners/inputschema.go create mode 100644 go/internal/reasoners/inputschema_test.go create mode 100644 go/internal/reasoners/inputvalidation_test.go create mode 100644 go/internal/reasoners/names.go create mode 100644 go/internal/reasoners/reasoners.go create mode 100644 go/internal/reasoners/reasoners_test.go create mode 100644 go/internal/reasoners/testdata/python_input_schemas.json create mode 100644 go/packaging_test.go diff --git a/go/cmd/cloudsecurity-af/main.go b/go/cmd/cloudsecurity-af/main.go new file mode 100644 index 0000000..47ec049 --- /dev/null +++ b/go/cmd/cloudsecurity-af/main.go @@ -0,0 +1,50 @@ +// Command cloudsecurity-af is the Go CloudSecurity-AF node — the port of +// src/cloudsecurity_af/app.py's main(). It builds the agent from the +// environment, registers the 22-reasoner surface (scan + prove + the 20 router +// reasoners), and serves it until SIGINT/SIGTERM. +// +// Defaults: NODE_ID "cloudsecurity", PORT 8015. Both env vars override; +// docker-compose.go.yml sets NODE_ID=cloudsecurity-go so the Go and Python +// nodes can register against one control plane at the same time. +// +// Boot env (see go/README.md for the full table): +// +// AGENTFIELD_SERVER control-plane base URL (default http://localhost:8080) +// AGENTFIELD_API_KEY control-plane bearer token +// AGENT_CALLBACK_URL base URL the CP uses to reach this node +// (unset -> the SDK's http://localhost:) +// NODE_ID node id (default cloudsecurity) +// PORT listen port (default 8015) +// CLOUDSECURITY_PROVIDER harness provider (or HARNESS_PROVIDER; default aforge) +// CLOUDSECURITY_MODEL harness model (or HARNESS_MODEL) +// CLOUDSECURITY_AI_MODEL model for direct .ai() calls (or AI_MODEL) +// CLOUDSECURITY_MAX_TURNS harness turn cap (default 50); malformed -> boot failure +// OPENROUTER_API_KEY LLM key; AIConfig is attached only when it is set +// SEC_AF_WORKSPACES_DIR clone root for remote repo_url values (default /workspaces) +// CLOUDSECURITY_REPO_PATH repo path used when repo_url is neither a directory nor a URL +// AWS_*/GOOGLE_*/AZURE_* read-only cloud credentials forwarded to the harness +package main + +import ( + "context" + "log" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/node" +) + +func main() { + n, err := node.BuildAgent( + "cloudsecurity", + "8015", + "AI-Native Cloud Infrastructure Security Scanner", + ) + if err != nil { + log.Fatalf("cloudsecurity-af: build agent: %v", err) + } + + n.RegisterAll() + + if err := n.Serve(context.Background()); err != nil { + log.Fatalf("cloudsecurity-af: serve: %v", err) + } +} diff --git a/go/internal/node/inputs.go b/go/internal/node/inputs.go new file mode 100644 index 0000000..af50e28 --- /dev/null +++ b/go/internal/node/inputs.go @@ -0,0 +1,309 @@ +package node + +// inputs.go transcribes the two @app.reasoner() signatures in +// src/cloudsecurity_af/app.py — `scan` and `prove` — into bindable input +// structs and the CloudSecurityInput construction each performs. The input +// schemas those two reasoners PUBLISH are not written here; they come from the +// Python-captured fixture (see the note at the bottom of this file and +// internal/reasoners/inputschema.go). + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/afx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// The literal defaults shared by both signatures. +const ( + // DefaultDepth is `depth: str = "standard"`. + DefaultDepth = "standard" + // DefaultBranch is `branch: str = "main"`. + DefaultBranch = "main" + // DefaultSeverityThreshold is `severity_threshold: str = "low"`. + DefaultSeverityThreshold = "low" + // DefaultCloudProvider is prove()'s `cloud_provider: str = "aws"`. + DefaultCloudProvider = "aws" +) + +// defaultOutputFormats is the `output_formats or ["json"]` fallback. +func defaultOutputFormats() []string { return []string{"json"} } + +// defaultExcludePaths is the +// `exclude_paths or ["tests/", ".git/", "examples/", ".terraform/"]` fallback. +// +// It is spelled out here rather than reused from config.DefaultExcludePaths() +// because app.py owns this list independently of the ScanConfig one; they +// happen to agree today and a test pins that. +func defaultExcludePaths() []string { + return []string{"tests/", ".git/", "examples/", ".terraform/"} +} + +// defaultCloudRegions is prove()'s `cloud_regions or ["us-east-1"]` fallback. +func defaultCloudRegions() []string { return []string{"us-east-1"} } + +// ScanInput is app.py::scan's signature. +type ScanInput 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"` + OutputFormats []string `json:"output_formats"` + ComplianceFrameworks []string `json:"compliance_frameworks"` + MaxCostUSD *float64 `json:"max_cost_usd"` + MaxDurationSeconds *int `json:"max_duration_seconds"` + MaxConcurrentHunters *int `json:"max_concurrent_hunters"` + MaxConcurrentProvers *int `json:"max_concurrent_provers"` + IncludePaths []string `json:"include_paths"` + ExcludePaths []string `json:"exclude_paths"` + IsPR bool `json:"is_pr"` + PRID *string `json:"pr_id"` + FailOnFindings bool `json:"fail_on_findings"` +} + +// NewScanInput returns scan()'s default arguments. +func NewScanInput() ScanInput { + return ScanInput{ + Depth: DefaultDepth, + Branch: DefaultBranch, + SeverityThreshold: DefaultSeverityThreshold, + } +} + +// UnmarshalJSON seeds scan()'s defaults before decoding, which is what FastAPI +// does for a parameter absent from the request body. +func (in *ScanInput) UnmarshalJSON(b []byte) error { + *in = NewScanInput() + type alias ScanInput + return json.Unmarshal(b, (*alias)(in)) +} + +// CloudSecurityInput ports scan()'s CloudSecurityInput(...) construction. +// +// Python parity: `output_formats or ["json"]` and +// `exclude_paths or [...]` are TRUTHINESS fallbacks, so an explicitly EMPTY +// list also takes the default — only a non-empty list is honored. Likewise +// `compliance_frameworks or []` collapses None and [] to []. include_paths is +// passed through untouched, so None stays None. +// +// Python parity: cloud is None — `scan` is always a Tier 1 (static-only) run. +func (in ScanInput) CloudSecurityInput() schemas.CloudSecurityInput { + out := schemas.NewCloudSecurityInput() + out.RepoURL = in.RepoURL + out.Depth = in.Depth + out.Branch = in.Branch + out.CommitSHA = in.CommitSHA + out.BaseCommitSHA = in.BaseCommitSHA + out.SeverityThreshold = in.SeverityThreshold + out.OutputFormats = orDefault(in.OutputFormats, defaultOutputFormats()) + out.ComplianceFrameworks = orDefault(in.ComplianceFrameworks, []string{}) + out.Cloud = nil + out.MaxCostUSD = in.MaxCostUSD + out.MaxDurationSeconds = in.MaxDurationSeconds + out.MaxConcurrentHunters = in.MaxConcurrentHunters + out.MaxConcurrentProvers = in.MaxConcurrentProvers + out.IncludePaths = in.IncludePaths + out.ExcludePaths = orDefault(in.ExcludePaths, defaultExcludePaths()) + out.IsPR = in.IsPR + out.PRID = in.PRID + out.FailOnFindings = in.FailOnFindings + return out +} + +// HandlerInputFields transcribes scan()'s signature for +// afx.ValidateHandlerInput — the port of the Python SDK's +// _validate_handler_input, which runs on the request body BEFORE the coroutine +// is entered and answers 422 for a missing required parameter. `repo_url: str` +// has no default, so `cloudsecurity.scan {}` is a 422 in Python; without this +// the Go node bound repo_url to "" and resolveRepo("") fell through to the +// CLOUDSECURITY_REPO_PATH-or-cwd branch, scanning the node's own working +// directory and returning 200. +func (ScanInput) HandlerInputFields() []afx.Field { + return []afx.Field{ + {Name: "repo_url", Type: afx.TypeStr, Required: true}, + {Name: "depth", Type: afx.TypeStr}, + {Name: "branch", Type: afx.TypeStr}, + {Name: "commit_sha", Type: afx.TypeStr, Optional: true}, + {Name: "base_commit_sha", Type: afx.TypeStr, Optional: true}, + {Name: "severity_threshold", Type: afx.TypeStr}, + {Name: "output_formats", Type: afx.TypeList, Optional: true}, + {Name: "compliance_frameworks", Type: afx.TypeList, Optional: true}, + {Name: "max_cost_usd", Type: afx.TypeFloat, Optional: true}, + {Name: "max_duration_seconds", Type: afx.TypeInt, Optional: true}, + {Name: "max_concurrent_hunters", Type: afx.TypeInt, Optional: true}, + {Name: "max_concurrent_provers", Type: afx.TypeInt, Optional: true}, + {Name: "include_paths", Type: afx.TypeList, Optional: true}, + {Name: "exclude_paths", Type: afx.TypeList, Optional: true}, + {Name: "is_pr", Type: afx.TypeBool}, + {Name: "pr_id", Type: afx.TypeStr, Optional: true}, + {Name: "fail_on_findings", Type: afx.TypeBool}, + } +} + +// ProveInput is app.py::prove's signature. +// +// Python parity: prove() takes NEITHER base_commit_sha, max_concurrent_hunters, +// max_concurrent_provers NOR pr_id — those four keep CloudSecurityInput's own +// pydantic defaults (None). Adding them here would widen the API surface past +// Python's. +type ProveInput struct { + RepoURL string `json:"repo_url"` + CloudProvider string `json:"cloud_provider"` + CloudRegions []string `json:"cloud_regions"` + AssumeRoleARN *string `json:"assume_role_arn"` + Depth string `json:"depth"` + Branch string `json:"branch"` + CommitSHA *string `json:"commit_sha"` + SeverityThreshold string `json:"severity_threshold"` + OutputFormats []string `json:"output_formats"` + ComplianceFrameworks []string `json:"compliance_frameworks"` + MaxCostUSD *float64 `json:"max_cost_usd"` + MaxDurationSeconds *int `json:"max_duration_seconds"` + IncludePaths []string `json:"include_paths"` + ExcludePaths []string `json:"exclude_paths"` + IsPR bool `json:"is_pr"` + FailOnFindings bool `json:"fail_on_findings"` +} + +// NewProveInput returns prove()'s default arguments. +func NewProveInput() ProveInput { + return ProveInput{ + CloudProvider: DefaultCloudProvider, + Depth: DefaultDepth, + Branch: DefaultBranch, + SeverityThreshold: DefaultSeverityThreshold, + } +} + +// UnmarshalJSON seeds prove()'s defaults before decoding. +func (in *ProveInput) UnmarshalJSON(b []byte) error { + *in = NewProveInput() + type alias ProveInput + return json.Unmarshal(b, (*alias)(in)) +} + +// CloudSecurityInput ports prove()'s CloudSecurityInput(...) construction, +// including the CloudConfig that makes it a Tier 2 (live cloud) run. +func (in ProveInput) CloudSecurityInput() schemas.CloudSecurityInput { + out := schemas.NewCloudSecurityInput() + out.RepoURL = in.RepoURL + out.Depth = in.Depth + out.Branch = in.Branch + out.CommitSHA = in.CommitSHA + out.SeverityThreshold = in.SeverityThreshold + out.OutputFormats = orDefault(in.OutputFormats, defaultOutputFormats()) + out.ComplianceFrameworks = orDefault(in.ComplianceFrameworks, []string{}) + out.Cloud = &schemas.CloudConfig{ + Provider: in.CloudProvider, + Regions: orDefault(in.CloudRegions, defaultCloudRegions()), + // Python parity: CloudConfig(provider=…, regions=…, assume_role_arn=…) + // leaves account_id at its None default. + AccountID: nil, + AssumeRoleARN: in.AssumeRoleARN, + } + out.MaxCostUSD = in.MaxCostUSD + out.MaxDurationSeconds = in.MaxDurationSeconds + out.IncludePaths = in.IncludePaths + out.ExcludePaths = orDefault(in.ExcludePaths, defaultExcludePaths()) + out.IsPR = in.IsPR + out.FailOnFindings = in.FailOnFindings + return out +} + +// HandlerInputFields transcribes prove()'s signature. Python parity: prove() +// declares NEITHER base_commit_sha, max_concurrent_hunters, +// max_concurrent_provers NOR pr_id, and _validate_handler_input DROPS every +// undeclared body key, so sending one to `prove` has no effect in either node. +func (ProveInput) HandlerInputFields() []afx.Field { + return []afx.Field{ + {Name: "repo_url", Type: afx.TypeStr, Required: true}, + {Name: "cloud_provider", Type: afx.TypeStr}, + {Name: "cloud_regions", Type: afx.TypeList, Optional: true}, + {Name: "assume_role_arn", Type: afx.TypeStr, Optional: true}, + {Name: "depth", Type: afx.TypeStr}, + {Name: "branch", Type: afx.TypeStr}, + {Name: "commit_sha", Type: afx.TypeStr, Optional: true}, + {Name: "severity_threshold", Type: afx.TypeStr}, + {Name: "output_formats", Type: afx.TypeList, Optional: true}, + {Name: "compliance_frameworks", Type: afx.TypeList, Optional: true}, + {Name: "max_cost_usd", Type: afx.TypeFloat, Optional: true}, + {Name: "max_duration_seconds", Type: afx.TypeInt, Optional: true}, + {Name: "include_paths", Type: afx.TypeList, Optional: true}, + {Name: "exclude_paths", Type: afx.TypeList, Optional: true}, + {Name: "is_pr", Type: afx.TypeBool}, + {Name: "fail_on_findings", Type: afx.TypeBool}, + } +} + +// orDefault ports Python's `value or fallback` for a list: an EMPTY list is +// falsy, so both None and [] take the fallback. +func orDefault(value, fallback []string) []string { + if len(value) == 0 { + return fallback + } + return value +} + +// scanHandler ports app.py::scan. +func (n *Node) scanHandler(ctx context.Context, input map[string]any) (any, error) { + in, err := afx.BindHandlerInput[ScanInput](input) + if err != nil { + return nil, badInput(err) + } + return n.runPipeline(ctx, in.CloudSecurityInput()) +} + +// proveHandler ports app.py::prove. +func (n *Node) proveHandler(ctx context.Context, input map[string]any) (any, error) { + in, err := afx.BindHandlerInput[ProveInput](input) + if err != nil { + return nil, badInput(err) + } + return n.runPipeline(ctx, in.CloudSecurityInput()) +} + +// badInput maps a request-body failure onto the status the Python endpoint +// answers with. +// +// A signature violation — a missing required parameter, an uncoercible scalar — +// is what _validate_handler_input raises, and the Python SDK renders it as +// HTTP 422 (agent.py:2120-2128). Anything else that trips the bind is a decode +// failure of an already-validated body, which Python cannot reach; it keeps the +// pre-existing 400. +func badInput(err error) error { + var inputErr *afx.InputError + if errors.As(err, &inputErr) { + return inputErr.ExecuteError() + } + return &agent.ExecuteError{StatusCode: http.StatusBadRequest, Message: err.Error()} +} + +// INPUT SCHEMAS for `scan` and `prove` are NOT declared here. +// +// They used to be hand transcriptions of the app.py signatures, richer than +// what Python actually publishes: typed `default`s, `additionalProperties:true`, +// and nullable parameters typed by their non-null base type +// (`"commit_sha":{"type":"string"}`). Every one of those three embellishments +// diverged from the live Python node, which derives the schema from the +// signature via Agent._types_to_json_schema and emits only +// {type, properties, required} — no defaults, no additionalProperties, and +// PEP-604 optionals collapsed to `{"type":"object"}`. +// +// The property NAME set and `required` agreed; 16 of scan's 17 property +// SCHEMAS and 15 of prove's 17 did not, so a caller reading the contract off +// discovery saw a different shape depending on which node answered. Both +// reasoners now publish reasoners.MustInputSchema(name) — the bytes captured +// from the live Python node. See internal/reasoners/inputschema.go for the +// fixture's provenance, its regeneration recipe and the mapping quirks. +// +// The richer typing survives where it does real work: ScanInput / ProveInput +// above still bind `commit_sha` to a *string and `output_formats` to a +// []string, so the port's DECODING is unchanged — only the published +// description of the contract moved into line with Python. diff --git a/go/internal/node/inputschema_test.go b/go/internal/node/inputschema_test.go new file mode 100644 index 0000000..3acf89d --- /dev/null +++ b/go/internal/node/inputschema_test.go @@ -0,0 +1,143 @@ +package node + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "testing" +) + +// inputschema_test.go pins the input schemas the two @app.reasoner() entry +// points publish. They used to be hand transcriptions declared in inputs.go — +// richer than what Python emits and therefore wrong (see the note at the bottom +// of inputs.go). Both now republish the schema captured from the live Python +// node, and these tests assert that end to end: the bytes are read back off the +// agent's own GET /discover payload, i.e. what the control plane records and +// `af ls` renders. + +func TestRegisterAll_TopLevelReasonersPublishThePythonInputSchema(t *testing.T) { + n := newTestNode(t) + n.RegisterAll() + + published := discoveredInputSchemas(t, n) + fixture := readReasonerSchemaFixture(t) + + for _, name := range []string{"scan", "prove"} { + got, ok := published[name] + if !ok { + t.Errorf("%s: not present in the discovery payload", name) + continue + } + if want := fixture[name]; !reflect.DeepEqual(got, want) { + t.Errorf("%s: published schema\n%s\nwant (Python)\n%s", name, indentJSON(t, got), indentJSON(t, want)) + } + } +} + +// TestRegisterAll_ScanSchemaMatchesTheAppPySignature transcribes app.py::scan +// by hand instead of reading the fixture, so a corrupted fixture cannot make +// the test above vacuously pass. Note the two quirks it locks in, both +// properties of the Python SDK's derivation: +// +// - Not one `default` is published, even though 5 parameters have one — only +// `repo_url` (the sole parameter WITHOUT a default) reaches `required`. +// - Every `X | None = None` parameter is typed `{"type": "object"}`, not by +// its base type: types.UnionType has no __origin__, so +// Agent._type_to_json_schema's Union branch never runs and the fallback +// wins. `commit_sha` is a str in the signature and an "object" on the wire. +func TestRegisterAll_ScanSchemaMatchesTheAppPySignature(t *testing.T) { + n := newTestNode(t) + n.RegisterAll() + + want := `{ + "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"}, + "output_formats": {"type": "object"}, + "compliance_frameworks": {"type": "object"}, + "max_cost_usd": {"type": "object"}, + "max_duration_seconds": {"type": "object"}, + "max_concurrent_hunters": {"type": "object"}, + "max_concurrent_provers": {"type": "object"}, + "include_paths": {"type": "object"}, + "exclude_paths": {"type": "object"}, + "is_pr": {"type": "boolean"}, + "pr_id": {"type": "object"}, + "fail_on_findings": {"type": "boolean"} + }, + "required": ["repo_url"] + }` + + var expected any + if err := json.Unmarshal([]byte(want), &expected); err != nil { + t.Fatalf("decode expectation: %v", err) + } + got := discoveredInputSchemas(t, n)["scan"] + if !reflect.DeepEqual(got, expected) { + t.Fatalf("scan schema\n%s\nwant\n%s", indentJSON(t, got), indentJSON(t, expected)) + } +} + +// --- helpers ------------------------------------------------------------------ + +// discoveredInputSchemas returns the input schema the node publishes per +// reasoner id, decoded into plain Go values (so JSON key order is irrelevant; +// array order, e.g. `required`, still counts — Python publishes signature order). +func discoveredInputSchemas(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("GET /discover = %d, body %s", rec.Code, rec.Body.String()) + } + + 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 discovery payload: %v", err) + } + + out := make(map[string]any, len(payload.Reasoners)) + for _, r := range payload.Reasoners { + out[r.ID] = r.InputSchema + } + return out +} + +// readReasonerSchemaFixture reads the captured Python schemas off disk rather +// than through internal/reasoners' embedded copy, so the comparison has two +// independent sides. +func readReasonerSchemaFixture(t *testing.T) map[string]any { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "reasoners", "testdata", "python_input_schemas.json")) + if err != nil { + t.Fatalf("read schema fixture: %v", err) + } + var fixture map[string]any + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatalf("decode schema fixture: %v", err) + } + return fixture +} + +func indentJSON(t *testing.T, v any) string { + t.Helper() + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + t.Fatalf("marshal %T: %v", v, err) + } + return string(b) +} diff --git a/go/internal/node/node.go b/go/internal/node/node.go new file mode 100644 index 0000000..e34e2c7 --- /dev/null +++ b/go/internal/node/node.go @@ -0,0 +1,418 @@ +// Package node is the cloudsecurity-af wiring layer: it constructs the shared +// *agent.Agent from the environment (the port of src/cloudsecurity_af/app.py's +// module body), registers the exact Python reasoner surface — `scan` and +// `prove` at the top level plus the 20 router reasoners from +// internal/reasoners — and serves it through the SDK. +// +// node.go owns agent construction, registration and Serve. resolve.go owns the +// repo-resolution helpers (_workspaces_root / _resolve_repo). inputs.go owns +// the two top-level reasoner signatures and their control-plane input schemas. +package node + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + "github.com/Agent-Field/agentfield/sdk/go/ai" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/afx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/config" + "github.com/Agent-Field/cloudsecurity-af/go/internal/orch" + "github.com/Agent-Field/cloudsecurity-af/go/internal/reasoners" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// scanErrorOut is where the pipeline-failure diagnostic goes. Python: +// +// print(f"SCAN ERROR: {exc}\n{tb}", flush=True) +// +// It is a variable only so tests can capture the bytes; production code must +// never reassign it. +var scanErrorOut io.Writer = os.Stdout + +// Node bundles the constructed agent with the resolved environment settings and +// the seams the two top-level reasoner handlers thread through. +type Node struct { + // App is the SDK agent. It satisfies appx.App (Harness/AI/Note/Call) + // directly, so it is both the orchestrator's capability seam and the + // object every router reasoner closes over. + App *agent.Agent + + // NodeID is the resolved node id (NODE_ID env, or the cloudsecurity default). + NodeID string + // AgentFieldServer is the control-plane base URL (AGENTFIELD_SERVER). + AgentFieldServer string + // ListenAddress is the ":port" the SDK server binds (":"+PORT). + ListenAddress string + + // pipelineApp is the capability seam handed to the orchestrator. It + // defaults to App; tests override it with an appx.Fake. + pipelineApp appx.App + + // newOrchestrator is `ScanOrchestrator(app=app, input=scan_input)`. + newOrchestrator func(appx.App, schemas.CloudSecurityInput) (*orch.ScanOrchestrator, error) + // runOrchestrator is `await orchestrator.run()` — the ONLY statement inside + // app.py's try/except, and therefore the only source of the 400/500 mapping. + runOrchestrator func(context.Context, *orch.ScanOrchestrator) (schemas.CloudSecurityScanResult, error) + // resolveRepo is `_resolve_repo(scan_input.repo_url)`. + resolveRepo func(context.Context, string) (string, error) + + // registered records every reasoner name passed through the registration + // path, in order; tags records the tags registered per name (scan/prove -> + // none; the 20 router reasoners -> the AgentRouter's three domain tags). + registered []string + tags map[string][]string +} + +// RegisteredNames returns a copy of the reasoner names registered on this node, +// in registration order — the parity source of truth. +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]...) +} + +// BuildAgent constructs the cloudsecurity-af agent from the environment exactly +// as src/cloudsecurity_af/app.py's module body does: +// +// app = Agent( +// node_id=os.getenv("NODE_ID", "cloudsecurity"), +// version="0.1.0", +// description="AI-Native Cloud Infrastructure Security Scanner", +// agentfield_server=os.getenv("AGENTFIELD_SERVER", "http://localhost:8080"), +// callback_url=os.getenv("AGENT_CALLBACK_URL", "http://host.docker.internal:8020"), +// api_key=os.getenv("AGENTFIELD_API_KEY"), +// harness_config=HarnessConfig(provider, model, max_turns, env, opencode_bin, aforge_bin, permission_mode="auto"), +// ai_config=AIConfig(provider=..., model=...), +// ) +// +// and `port = int(os.getenv("PORT", "8005"))` from main(). +// +// DIVERGENCE 1 — callback URL. Python hardcodes the FALLBACK +// `http://host.docker.internal:8020`, which is (a) a docker-desktop-only host +// name that does not resolve on bare metal or in Linux containers, and (b) the +// wrong port — the node listens on 8005. A bare-metal `python -m +// cloudsecurity_af.app` therefore registers a callback URL the control plane +// cannot reach. Go leaves PublicURL EMPTY when AGENT_CALLBACK_URL is unset, so +// the SDK falls back to `http://localhost:` — correct on bare +// metal, and every container deployment (docker-compose, the Go compose add-on, +// `af run`) sets AGENT_CALLBACK_URL explicitly anyway, so the reachable-in- +// docker case is unchanged. +// +// DIVERGENCE 2 — AIConfig. The Go SDK's ai.Config rejects an empty API key at +// construction while Python's AIConfig accepts a missing OPENROUTER_API_KEY. So +// AIConfig is attached ONLY when OPENROUTER_API_KEY is set: construction +// succeeds without a key (matching Python) and the AI call fails at call time +// either way. Python's AIConfig(provider=…, model=…) passes no api_base and +// relies on LiteLLM's routing prefix; Go posts the model verbatim to BaseURL, +// hence the explicit OpenRouter base URL and the prefix strip in aiModelForAPI. +// +// A malformed CLOUDSECURITY_MAX_TURNS returns an error rather than a *Node: +// Python builds AIIntegrationConfig at import time, so the same input makes the +// node fail to boot. +func BuildAgent(defaultNodeID, defaultPort, description string) (*Node, error) { + cfg, err := buildConfig(defaultNodeID, defaultPort, description) + if err != nil { + return nil, err + } + + app, err := agent.New(cfg) + if err != nil { + return nil, fmt.Errorf("create agent %q: %w", cfg.NodeID, err) + } + + n := &Node{ + App: app, + NodeID: cfg.NodeID, + AgentFieldServer: cfg.AgentFieldURL, + ListenAddress: cfg.ListenAddress, + pipelineApp: app, + newOrchestrator: orch.New, + runOrchestrator: func(ctx context.Context, o *orch.ScanOrchestrator) (schemas.CloudSecurityScanResult, error) { + return o.Run(ctx) + }, + resolveRepo: resolveRepo, + tags: map[string][]string{}, + } + return n, nil +} + +// buildConfig is BuildAgent's pure half: environment -> agent.Config. It is +// split out so the env mapping (which the SDK hides behind an unexported field +// once the Agent is constructed) is directly assertable. +func buildConfig(defaultNodeID, defaultPort, description string) (agent.Config, error) { + aiConf, err := config.AIConfigFromEnv() + if err != nil { + return agent.Config{}, err + } + + // Python builds HarnessConfig(env=_ai_config.provider_env()) in app.py's + // module body, so a provider_env() failure (an unwritable XDG_DATA_HOME) is + // an import-time crash: the node never registers. Propagating the error + // here keeps the Go node failing at the same point. + harness, err := harnessConfig(aiConf) + if err != nil { + return agent.Config{}, err + } + + port := envOr("PORT", defaultPort) + cfg := agent.Config{ + // config.NodeIDOr is the SINGLE NODE_ID resolution rule of the port: + // internal/phases and internal/orch build every Call target with the + // same helper, so the id the agent registers under and the prefix of + // every DAG edge cannot diverge (see config.NodeID's doc comment). + NodeID: config.NodeIDOr(defaultNodeID), + Version: "0.1.0", + AgentFieldURL: envOr("AGENTFIELD_SERVER", "http://localhost:8080"), + Token: os.Getenv("AGENTFIELD_API_KEY"), + ListenAddress: ":" + port, + PublicURL: os.Getenv("AGENT_CALLBACK_URL"), + CLIConfig: &agent.CLIConfig{AppDescription: description}, + HarnessConfig: harness, + } + if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" { + cfg.AIConfig = &ai.Config{ + Model: aiModelForAPI(aiConf.AIModel), + APIKey: apiKey, + BaseURL: "https://openrouter.ai/api/v1", + } + } + return cfg, nil +} + +// harnessConfig maps the resolved AI integration configuration onto the SDK +// harness configuration. +// +// Python passes BOTH opencode_bin and aforge_bin to HarnessConfig and lets the +// SDK pick by provider; the Go HarnessConfig has ONE BinPath, so the selection +// happens here. An empty BinPath lets the SDK use the provider's own default +// executable — which is what every other provider (claude-code, codex, gemini) +// gets, exactly as in Python. +func harnessConfig(c config.AIIntegrationConfig) (*agent.HarnessConfig, error) { + env, err := c.ProviderEnv() + if err != nil { + return nil, err + } + return &agent.HarnessConfig{ + Provider: c.Provider, + Model: c.HarnessModel, + MaxTurns: c.MaxTurns, + PermissionMode: "auto", + Env: env, + BinPath: resolvedHarnessBin(c), + }, nil +} + +// resolvedHarnessBin picks the executable for the configured provider. +func resolvedHarnessBin(c config.AIIntegrationConfig) string { + switch c.Provider { + case "aforge": + return c.AforgeBin + case "opencode": + return c.OpencodeBin + default: + return "" + } +} + +// 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. +// Stripping the routing prefix reaches the same model Python does. The HARNESS +// model is untouched (opencode's config expects the prefixed form, and the +// Docker entrypoint derives its model key by stripping the prefix there). +func aiModelForAPI(model string) string { + return strings.TrimPrefix(model, "openrouter/") +} + +// RegisterAll registers the full cloudsecurity-af surface: the two top-level +// reasoners `scan` and `prove` (app.py's @app.reasoner()), then the 20 router +// reasoners mounted through IncludeRouter (app.include_router(reasoner_router)). +// +// Registration ORDER matches app.py: scan and prove are defined before +// include_router runs. +// +// The router carries the AgentRouter's three domain tags. They are SEMANTIC +// tags, not node-identity tags — node identity is node_id=cloudsecurity, so +// callers reach cloudsecurity.scan. +func (n *Node) RegisterAll() { + // Both top-level reasoners publish the schema the LIVE Python node + // published for them (reasoners.MustInputSchema reads the captured + // fixture and panics on a name it does not cover, so this cannot silently + // fall back to the SDK's contentless default). The 20 router reasoners get + // theirs the same way, inside reasoners.RegisterAll. + n.record(reasoners.NameScan, nil) + n.App.RegisterReasoner(reasoners.NameScan, n.scanHandler, + agent.WithInputSchema(reasoners.MustInputSchema(reasoners.NameScan))) + + n.record(reasoners.NameProve, nil) + n.App.RegisterReasoner(reasoners.NameProve, n.proveHandler, + agent.WithInputSchema(reasoners.MustInputSchema(reasoners.NameProve))) + + router := agent.NewRouter() + tags := reasoners.Tags() + for _, name := range reasoners.RegisterAll(router, n.pipelineApp) { + n.record(name, tags) + } + n.App.IncludeRouter(router, agent.RouterOptions{Tags: tags}) +} + +// record appends name (and its tags) to the node's registration bookkeeping. +// tags==nil records an empty slice so TagsFor("scan") returns no tags. +func (n *Node) record(name string, tags []string) { + n.registered = append(n.registered, name) + n.tags[name] = append([]string(nil), tags...) +} + +// runPipeline ports app.py::_run_pipeline. +// +// Python: +// +// orchestrator = ScanOrchestrator(app=app, input=scan_input) +// repo_path = _resolve_repo(scan_input.repo_url) +// orchestrator.repo_path = Path(repo_path) +// orchestrator.checkpoint_dir = orchestrator.repo_path / ".cloudsecurity" +// try: +// result = await orchestrator.run() +// except ValueError as exc: +// raise HTTPException(400, detail={"error": str(exc)}) +// except Exception as exc: +// print(f"SCAN ERROR: {exc}\n{traceback.format_exc()}", flush=True) +// raise HTTPException(500, detail={"error": f"scan execution failed: {exc}"}) +// return result.model_dump() +// +// The first four statements are OUTSIDE the try, so their exceptions escape +// uncaught and FastAPI renders a generic 500 with the message hidden. Go +// reports them at 500 WITH the message (a documented, deliberate divergence — +// same status, more debuggable) and without the "scan execution failed: " +// prefix, which belongs to the orchestrator branch only. +// +// Note the ordering quirk, reproduced verbatim: the orchestrator is built +// BEFORE the repo is resolved, so ScanConfig.from_input's strict depth parse +// (which raises a ValueError for an unknown depth) fires on the UNCAUGHT path +// and yields a 500, not the 400 a bad `depth` looks like it should get. +// +// Python emits NO note here (unlike sec-af's audit handler) — do not add one. +func (n *Node) runPipeline(ctx context.Context, in schemas.CloudSecurityInput) (any, error) { + orchestrator, err := n.newOrchestrator(n.pipelineApp, in) + if err != nil { + return nil, uncaught(err) + } + + repoPath, err := n.resolveRepo(ctx, in.RepoURL) + if err != nil { + return nil, uncaught(err) + } + orchestrator.RepoPath = repoPath + orchestrator.SetCheckpointDirFromRepoPath() + + result, err := n.runOrchestrator(ctx, orchestrator) + if err != nil { + if isValueErrorClass(err) { + // ValueError-class -> 400 with the RAW message, so the body is + // byte-identical to Python's str(exc). + return nil, &agent.ExecuteError{StatusCode: http.StatusBadRequest, Message: err.Error()} + } + // DIVERGENCE: Python also prints a full traceback here. Go has no + // equivalent for an error value, so only the message line is emitted. + _, _ = fmt.Fprintf(scanErrorOut, "SCAN ERROR: %v\n", err) + return nil, &agent.ExecuteError{ + StatusCode: http.StatusInternalServerError, + Message: "scan execution failed: " + err.Error(), + } + } + + // Python: return result.model_dump(). + // + // afx.Dump, not afx.ToMap: FastAPI serialises the returned dict with + // json.dumps, which preserves pydantic's field-declaration order + // (repository, commit_sha, branch, timestamp, …) and spells every float + // the Python way ("cost_usd": 0.0). A Go map would put the same keys on + // the wire alphabetically and render 0.0 as 0. + return afx.Dump(result) +} + +// uncaught renders a failure from the statements app.py runs OUTSIDE its +// try/except. See runPipeline for the divergence note. +func uncaught(err error) error { + return &agent.ExecuteError{StatusCode: http.StatusInternalServerError, Message: err.Error()} +} + +// afxBindErrorMarker is the prefix afx.Bind stamps on EVERY validation failure — +// a decode failure ("afx.Bind: unmarshal into ...") and a missing REQUIRED field +// ("afx.Bind: 1 validation error for ResourceInventory: ..."). Every +// `Model.model_validate(...)` in the ported pipeline goes through afx.Bind, so +// this is the text that reaches the 400 body. It is asserted in node_test.go +// against the real afx.Bind; the CLASSIFICATION itself does not use it, see +// isValueErrorClass. +const afxBindErrorMarker = "afx.Bind: " + +// isValueErrorClass reports whether err is what Python's `except ValueError` +// in _run_pipeline would catch. +// +// Inside ScanOrchestrator.run() the ONLY ValueError-class exceptions Python can +// raise are pydantic ValidationErrors — every `Model.model_validate(payload)` +// of a phase reply, plus the strict enum coercions inside those models. In the +// Go port those all surface as an *afx.ValidationError. The other failure modes +// are NOT ValueError-class and take the 500 branch, matching Python: +// +// - _unwrap / _as_dict raise RuntimeError +// - the missing "verified" key is a KeyError +// - a control-plane transport failure is an httpx/RuntimeError +// +// The test is errors.As, NOT a substring of the message, and that is +// load-bearing. A bind failure inside a CHILD reasoner is recorded by the +// control plane as that child's error_message and relayed to this process as an +// *agent.ExecuteError whose text still begins "afx.Bind: " — but in Python that +// is an SDK/transport exception in the parent, caught by `except Exception` and +// answered 500 with the "scan execution failed: " prefix. Matching on text +// would answer 400 with the raw child message instead, inverting the +// retryable/client-error class every caller branches on. +func isValueErrorClass(err error) bool { + var validation *afx.ValidationError + return errors.As(err, &validation) +} + +// Serve registers with the control plane and serves the SDK handler until +// SIGINT/SIGTERM or ctx cancellation. +// +// Unlike pr-af this node adds NO custom HTTP route: app.py grafts a `/health` +// route onto the SDK app, but the Go SDK already serves `/health` itself +// (agent.healthHandler). The two payloads differ — Python's returns +// {"status":"healthy","version":"0.1.0"} and the SDK's returns {"status":"ok"} +// (verified against a booted node) — but every consumer (the Dockerfile +// HEALTHCHECK, the compose healthcheck, the manifest's `healthcheck: /health`) +// only checks for a 2xx, so the SDK route is used as-is rather than shadowed by +// a custom mux. +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 uses os.getenv(key, default), which substitutes only +// when the key is ABSENT. Treating "" as absent is the deliberate difference — +// an empty PORT/AGENTFIELD_SERVER cannot produce a working node, and +// `af run`/compose export empty strings for unset optional variables. +// +// NODE_ID does NOT go through here: it is resolved by config.NodeIDOr, which +// applies this same rule but is shared with internal/phases and internal/orch +// so the registered id and every Call target cannot be resolved differently. +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/go/internal/node/node_test.go b/go/internal/node/node_test.go new file mode 100644 index 0000000..6ff9488 --- /dev/null +++ b/go/internal/node/node_test.go @@ -0,0 +1,982 @@ +package node + +import ( + "context" + "errors" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/afx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/config" + "github.com/Agent-Field/cloudsecurity-af/go/internal/orch" + "github.com/Agent-Field/cloudsecurity-af/go/internal/reasoners" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// clearNodeEnv unsets every variable BuildAgent reads, so a test starts from +// the code defaults regardless of the developer's shell. +// +// It UNSETS rather than blanks: config.AIConfigFromEnv ports +// `int(os.getenv("CLOUDSECURITY_MAX_TURNS", "50"))`, where a key PRESENT with an +// empty value is int("") — a boot failure, not the default. t.Setenv is called +// first only to register the restore of the developer's original value. +func clearNodeEnv(t *testing.T) { + t.Helper() + for _, key := range []string{ + "NODE_ID", "PORT", "AGENTFIELD_SERVER", "AGENTFIELD_API_KEY", "AGENT_CALLBACK_URL", + "OPENROUTER_API_KEY", + "CLOUDSECURITY_PROVIDER", "HARNESS_PROVIDER", + "CLOUDSECURITY_MODEL", "HARNESS_MODEL", + "CLOUDSECURITY_AI_MODEL", "AI_MODEL", + "CLOUDSECURITY_MAX_TURNS", + "CLOUDSECURITY_OPENCODE_BIN", "CLOUDSECURITY_AFORGE_BIN", "AFORGE_BIN", + } { + t.Setenv(key, "") + _ = os.Unsetenv(key) + } +} + +// --- BuildAgent / buildConfig ------------------------------------------------ + +func TestBuildConfig_Defaults(t *testing.T) { + clearNodeEnv(t) + + cfg, err := buildConfig("cloudsecurity", "8015", "AI-Native Cloud Infrastructure Security Scanner") + if err != nil { + t.Fatalf("buildConfig: %v", err) + } + if cfg.NodeID != "cloudsecurity" { + t.Errorf("NodeID = %q, want cloudsecurity", cfg.NodeID) + } + if cfg.Version != "0.1.0" { + t.Errorf("Version = %q, want 0.1.0 (app.py version=\"0.1.0\")", cfg.Version) + } + if cfg.AgentFieldURL != "http://localhost:8080" { + t.Errorf("AgentFieldURL = %q", cfg.AgentFieldURL) + } + if cfg.ListenAddress != ":8015" { + t.Errorf("ListenAddress = %q, want :8015", cfg.ListenAddress) + } + if cfg.CLIConfig == nil || cfg.CLIConfig.AppDescription != "AI-Native Cloud Infrastructure Security Scanner" { + t.Errorf("CLIConfig = %#v", cfg.CLIConfig) + } + // DIVERGENCE pinned: Python's callback_url default is the docker-only + // http://host.docker.internal:8020 (wrong host AND wrong port). Go leaves + // PublicURL empty so the SDK uses http://localhost:. + if cfg.PublicURL != "" { + t.Errorf("PublicURL = %q, want empty so the SDK derives http://localhost:8015", cfg.PublicURL) + } +} + +func TestBuildConfig_EnvOverrides(t *testing.T) { + clearNodeEnv(t) + t.Setenv("NODE_ID", "cloudsecurity-go") + t.Setenv("PORT", "9100") + t.Setenv("AGENTFIELD_SERVER", "http://agentfield:8080") + t.Setenv("AGENTFIELD_API_KEY", "cp-token") + t.Setenv("AGENT_CALLBACK_URL", "http://cloudsecurity-go:9100") + + cfg, err := buildConfig("cloudsecurity", "8015", "d") + if err != nil { + t.Fatalf("buildConfig: %v", err) + } + if cfg.NodeID != "cloudsecurity-go" { + t.Errorf("NodeID = %q", cfg.NodeID) + } + if cfg.ListenAddress != ":9100" { + t.Errorf("ListenAddress = %q", cfg.ListenAddress) + } + if cfg.AgentFieldURL != "http://agentfield:8080" { + t.Errorf("AgentFieldURL = %q", cfg.AgentFieldURL) + } + if cfg.Token != "cp-token" { + t.Errorf("Token = %q", cfg.Token) + } + if cfg.PublicURL != "http://cloudsecurity-go:9100" { + t.Errorf("PublicURL = %q", cfg.PublicURL) + } +} + +// TestBuildConfig_AIConfigOnlyWhenTheKeyIsSet pins divergence 2: the Go ai +// client rejects an empty key at construction, so AIConfig is attached only +// when OPENROUTER_API_KEY is set. +func TestBuildConfig_AIConfigOnlyWhenTheKeyIsSet(t *testing.T) { + clearNodeEnv(t) + + cfg, err := buildConfig("cloudsecurity", "8015", "d") + if err != nil { + t.Fatalf("buildConfig: %v", err) + } + if cfg.AIConfig != nil { + t.Fatal("AIConfig must be nil without OPENROUTER_API_KEY") + } + + t.Setenv("OPENROUTER_API_KEY", "sk-test") + t.Setenv("AI_MODEL", "openrouter/moonshotai/kimi-k2.5") + cfg, err = buildConfig("cloudsecurity", "8015", "d") + if err != nil { + t.Fatalf("buildConfig: %v", err) + } + if cfg.AIConfig == nil { + t.Fatal("AIConfig must be attached when OPENROUTER_API_KEY is set") + } + if cfg.AIConfig.APIKey != "sk-test" { + t.Errorf("APIKey = %q", cfg.AIConfig.APIKey) + } + if cfg.AIConfig.BaseURL != "https://openrouter.ai/api/v1" { + t.Errorf("BaseURL = %q", cfg.AIConfig.BaseURL) + } + // The LiteLLM routing prefix must be stripped for the direct API call. + if cfg.AIConfig.Model != "moonshotai/kimi-k2.5" { + t.Errorf("Model = %q, want the openrouter/ prefix stripped", cfg.AIConfig.Model) + } +} + +func TestAIModelForAPI(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 consumed. + "x/openrouter/y": "x/openrouter/y", + } + for in, want := range cases { + if got := aiModelForAPI(in); got != want { + t.Errorf("aiModelForAPI(%q) = %q, want %q", in, got, want) + } + } +} + +// TestHarnessConfig_MapsPythonHarnessConfig pins the HarnessConfig mapping, +// including the one-BinPath-per-provider selection Python performs inside the +// SDK (it passes both opencode_bin and aforge_bin). +func TestHarnessConfig_MapsPythonHarnessConfig(t *testing.T) { + clearNodeEnv(t) + + c := config.AIIntegrationConfig{ + Provider: "aforge", + HarnessModel: "openrouter/minimax/minimax-m2.5", + AIModel: "openrouter/minimax/minimax-m2.5", + MaxTurns: 50, + OpencodeBin: "opencode", + AforgeBin: "aforge", + } + hc, err := harnessConfig(c) + if err != nil { + t.Fatalf("harnessConfig: %v", err) + } + if hc.Provider != "aforge" || hc.Model != "openrouter/minimax/minimax-m2.5" || hc.MaxTurns != 50 { + t.Fatalf("harnessConfig = %#v", hc) + } + if hc.PermissionMode != "auto" { + t.Errorf("PermissionMode = %q, want auto (app.py permission_mode=\"auto\")", hc.PermissionMode) + } + if hc.BinPath != "aforge" { + t.Errorf("BinPath = %q, want the aforge bin", hc.BinPath) + } + + c.Provider = "opencode" + if got := mustHarnessConfig(t, c).BinPath; got != "opencode" { + t.Errorf("opencode BinPath = %q", got) + } + + c.Provider = "claude-code" + if got := mustHarnessConfig(t, c).BinPath; got != "" { + t.Errorf("claude-code BinPath = %q, want empty (SDK default executable)", got) + } +} + +// TestHarnessConfig_ForwardsProviderEnv proves the cloud/LLM credential +// forwarding survives the mapping — provider_env() is what gives the harness +// subprocess its AWS keys. +func TestHarnessConfig_ForwardsProviderEnv(t *testing.T) { + clearNodeEnv(t) + t.Setenv("OPENROUTER_API_KEY", "sk-test") + t.Setenv("AWS_ACCESS_KEY_ID", "AKIA-test") + + aiConf, err := config.AIConfigFromEnv() + if err != nil { + t.Fatalf("AIConfigFromEnv: %v", err) + } + env := mustHarnessConfig(t, aiConf).Env + if env["OPENROUTER_API_KEY"] != "sk-test" || env["AWS_ACCESS_KEY_ID"] != "AKIA-test" { + t.Fatalf("harness env is missing forwarded credentials: %v", env) + } +} + +// TestBuildAgent_FailsOnMalformedMaxTurns: Python builds AIIntegrationConfig at +// import time, so the same value makes the node fail to boot. +func TestBuildAgent_FailsOnMalformedMaxTurns(t *testing.T) { + clearNodeEnv(t) + t.Setenv("CLOUDSECURITY_MAX_TURNS", "not-a-number") + + if _, err := BuildAgent("cloudsecurity", "8015", "d"); err == nil { + t.Fatal("expected BuildAgent to fail on a malformed CLOUDSECURITY_MAX_TURNS") + } +} + +// --- registration parity ----------------------------------------------------- + +// pythonSurface is the ordered reasoner surface app.py registers: the two +// @app.reasoner() functions, then app.include_router(reasoner_router). +var pythonSurface = append([]string{"scan", "prove"}, reasoners.RouterNames()...) + +func TestRegisterAll_SurfaceOrderAndTags(t *testing.T) { + n := newTestNode(t) + n.RegisterAll() + + if got := n.RegisteredNames(); !reflect.DeepEqual(got, pythonSurface) { + t.Fatalf("registered surface =\n%v\nwant\n%v", got, pythonSurface) + } + if len(pythonSurface) != 22 { + t.Fatalf("expected 22 reasoners (2 top-level + 20 router), have %d", len(pythonSurface)) + } + + // scan/prove are @app.reasoner() — no router tags. + for _, name := range []string{"scan", "prove"} { + if tags := n.TagsFor(name); len(tags) != 0 { + t.Errorf("%s tags = %v, want none", name, tags) + } + } + // The 20 router reasoners carry the AgentRouter's domain tags. + want := []string{"cloud", "security", "infrastructure"} + for _, name := range reasoners.RouterNames() { + if tags := n.TagsFor(name); !reflect.DeepEqual(tags, want) { + t.Errorf("%s tags = %v, want %v", name, tags, want) + } + } +} + +// TestRegisterAll_EveryNameDispatches proves the surface is live on the agent, +// not merely recorded. +func TestRegisterAll_EveryNameDispatches(t *testing.T) { + n := newTestNode(t) + n.RegisterAll() + + for _, name := range n.RegisteredNames() { + _, err := n.App.Execute(context.Background(), name, map[string]any{}) + if err != nil && strings.Contains(err.Error(), "unknown reasoner or skill") { + t.Errorf("%s: not registered on the agent (%v)", name, err) + } + } +} + +// --- scan()/prove() input binding ------------------------------------------- + +func TestScanInput_SignatureDefaults(t *testing.T) { + in, err := afx.Bind[ScanInput](map[string]any{"repo_url": "/repo"}) + if err != nil { + t.Fatalf("Bind: %v", err) + } + if in.Depth != "standard" || in.Branch != "main" || in.SeverityThreshold != "low" { + t.Fatalf("defaults = %+v", in) + } + if in.IsPR || in.FailOnFindings { + t.Fatal("is_pr / fail_on_findings must default to false") + } + for _, p := range []any{in.CommitSHA, in.BaseCommitSHA, in.MaxCostUSD, in.MaxDurationSeconds, + in.MaxConcurrentHunters, in.MaxConcurrentProvers, in.PRID} { + if !reflect.ValueOf(p).IsNil() { + t.Fatalf("optional parameter %#v must default to None", p) + } + } + if in.OutputFormats != nil || in.ComplianceFrameworks != nil || in.IncludePaths != nil || in.ExcludePaths != nil { + t.Fatal("list parameters must default to None at the signature level") + } +} + +func TestProveInput_SignatureDefaults(t *testing.T) { + in, err := afx.Bind[ProveInput](map[string]any{"repo_url": "/repo"}) + if err != nil { + t.Fatalf("Bind: %v", err) + } + if in.CloudProvider != "aws" { + t.Fatalf("cloud_provider = %q, want aws", in.CloudProvider) + } + if in.Depth != "standard" || in.Branch != "main" || in.SeverityThreshold != "low" { + t.Fatalf("defaults = %+v", in) + } +} + +// TestScanInput_BuildsATierOneCloudSecurityInput ports the assertions app.py's +// scan() construction implies. +func TestScanInput_BuildsATierOneCloudSecurityInput(t *testing.T) { + in, err := afx.Bind[ScanInput](map[string]any{"repo_url": "/repo"}) + if err != nil { + t.Fatalf("Bind: %v", err) + } + got := in.CloudSecurityInput() + + if got.RepoURL != "/repo" { + t.Errorf("repo_url = %q", got.RepoURL) + } + if got.Cloud != nil { + t.Error("scan() must build cloud=None (Tier 1)") + } + if got.Tier() != 1 { + t.Errorf("tier = %d, want 1", got.Tier()) + } + if !reflect.DeepEqual(got.OutputFormats, []string{"json"}) { + t.Errorf("output_formats = %v, want [json]", got.OutputFormats) + } + if !reflect.DeepEqual(got.ComplianceFrameworks, []string{}) { + t.Errorf("compliance_frameworks = %v, want []", got.ComplianceFrameworks) + } + wantExclude := []string{"tests/", ".git/", "examples/", ".terraform/"} + if !reflect.DeepEqual(got.ExcludePaths, wantExclude) { + t.Errorf("exclude_paths = %v, want %v", got.ExcludePaths, wantExclude) + } + if got.IncludePaths != nil { + t.Errorf("include_paths = %v, want nil (passed through untouched)", got.IncludePaths) + } +} + +// TestScanInput_EmptyListsTakeTheOrDefault pins Python's TRUTHINESS fallbacks: +// `output_formats or ["json"]` replaces an explicitly empty list too. +func TestScanInput_EmptyListsTakeTheOrDefault(t *testing.T) { + in, err := afx.Bind[ScanInput](map[string]any{ + "repo_url": "/repo", + "output_formats": []any{}, + "exclude_paths": []any{}, + "include_paths": []any{}, + }) + if err != nil { + t.Fatalf("Bind: %v", err) + } + got := in.CloudSecurityInput() + + if !reflect.DeepEqual(got.OutputFormats, []string{"json"}) { + t.Errorf("output_formats = %v, want the [json] fallback", got.OutputFormats) + } + if len(got.ExcludePaths) != 4 { + t.Errorf("exclude_paths = %v, want the four-entry fallback", got.ExcludePaths) + } + // include_paths is NOT an `or` fallback — the empty list survives. + if got.IncludePaths == nil || len(got.IncludePaths) != 0 { + t.Errorf("include_paths = %v, want the explicit empty list", got.IncludePaths) + } +} + +func TestScanInput_ExplicitValuesWin(t *testing.T) { + in, err := afx.Bind[ScanInput](map[string]any{ + "repo_url": "https://github.com/o/r", + "depth": "thorough", + "branch": "release", + "severity_threshold": "high", + "output_formats": []any{"sarif"}, + "compliance_frameworks": []any{"cis_aws"}, + "max_cost_usd": 2.5, + "max_duration_seconds": 900, + "max_concurrent_hunters": 2, + "max_concurrent_provers": 5, + "exclude_paths": []any{"vendor/"}, + "is_pr": true, + "pr_id": "42", + "fail_on_findings": true, + }) + if err != nil { + t.Fatalf("Bind: %v", err) + } + got := in.CloudSecurityInput() + + if got.Depth != "thorough" || got.Branch != "release" || got.SeverityThreshold != "high" { + t.Fatalf("scalars = %+v", got) + } + if !reflect.DeepEqual(got.OutputFormats, []string{"sarif"}) || + !reflect.DeepEqual(got.ComplianceFrameworks, []string{"cis_aws"}) || + !reflect.DeepEqual(got.ExcludePaths, []string{"vendor/"}) { + t.Fatalf("lists = %+v", got) + } + if got.MaxCostUSD == nil || *got.MaxCostUSD != 2.5 { + t.Fatalf("max_cost_usd = %v", got.MaxCostUSD) + } + if got.MaxDurationSeconds == nil || *got.MaxDurationSeconds != 900 { + t.Fatalf("max_duration_seconds = %v", got.MaxDurationSeconds) + } + if got.MaxConcurrentHunters == nil || *got.MaxConcurrentHunters != 2 { + t.Fatalf("max_concurrent_hunters = %v", got.MaxConcurrentHunters) + } + if got.MaxConcurrentProvers == nil || *got.MaxConcurrentProvers != 5 { + t.Fatalf("max_concurrent_provers = %v", got.MaxConcurrentProvers) + } + if !got.IsPR || !got.FailOnFindings || got.PRID == nil || *got.PRID != "42" { + t.Fatalf("ci flags = %+v", got) + } +} + +// TestProveInput_BuildsATierTwoCloudSecurityInput pins the CloudConfig prove() +// constructs and the four parameters prove() deliberately does NOT accept. +func TestProveInput_BuildsATierTwoCloudSecurityInput(t *testing.T) { + in, err := afx.Bind[ProveInput](map[string]any{"repo_url": "/repo"}) + if err != nil { + t.Fatalf("Bind: %v", err) + } + got := in.CloudSecurityInput() + + if got.Cloud == nil { + t.Fatal("prove() must build a CloudConfig") + } + if got.Cloud.Provider != "aws" { + t.Errorf("provider = %q, want aws", got.Cloud.Provider) + } + if !reflect.DeepEqual(got.Cloud.Regions, []string{"us-east-1"}) { + t.Errorf("regions = %v, want [us-east-1]", got.Cloud.Regions) + } + if got.Cloud.AccountID != nil || got.Cloud.AssumeRoleARN != nil { + t.Errorf("account_id/assume_role_arn = %v/%v, want nil", got.Cloud.AccountID, got.Cloud.AssumeRoleARN) + } + if got.Tier() != 2 { + t.Errorf("tier = %d, want 2", got.Tier()) + } + // prove()'s signature omits these four; they keep CloudSecurityInput's own + // None defaults. + if got.BaseCommitSHA != nil || got.MaxConcurrentHunters != nil || + got.MaxConcurrentProvers != nil || got.PRID != nil { + t.Errorf("prove() must not populate base_commit_sha/max_concurrent_*/pr_id: %+v", got) + } +} + +func TestProveInput_CloudOverrides(t *testing.T) { + in, err := afx.Bind[ProveInput](map[string]any{ + "repo_url": "/repo", + "cloud_provider": "gcp", + "cloud_regions": []any{"europe-west1", "us-central1"}, + "assume_role_arn": "arn:aws:iam::1:role/scan", + }) + if err != nil { + t.Fatalf("Bind: %v", err) + } + got := in.CloudSecurityInput() + if got.Cloud.Provider != "gcp" { + t.Errorf("provider = %q", got.Cloud.Provider) + } + if !reflect.DeepEqual(got.Cloud.Regions, []string{"europe-west1", "us-central1"}) { + t.Errorf("regions = %v", got.Cloud.Regions) + } + if got.Cloud.AssumeRoleARN == nil || *got.Cloud.AssumeRoleARN != "arn:aws:iam::1:role/scan" { + t.Errorf("assume_role_arn = %v", got.Cloud.AssumeRoleARN) + } +} + +// TestAppExcludePathsMatchScanConfigDefaults documents that app.py's literal and +// config.py's DEFAULT_EXCLUDE_PATHS agree today. If they ever diverge in Python, +// this test tells the porter which copy changed. +func TestAppExcludePathsMatchScanConfigDefaults(t *testing.T) { + if got, want := defaultExcludePaths(), config.DefaultExcludePaths(); !reflect.DeepEqual(got, want) { + t.Fatalf("app.py exclude paths %v != config defaults %v", got, want) + } +} + +// --- _run_pipeline error mapping -------------------------------------------- + +func TestRunPipeline_SetsRepoPathAndCheckpointDirBeforeRunning(t *testing.T) { + n := newTestNode(t) + repo := t.TempDir() + n.resolveRepo = func(context.Context, string) (string, error) { return repo, nil } + + var seen *orch.ScanOrchestrator + n.runOrchestrator = func(_ context.Context, o *orch.ScanOrchestrator) (schemas.CloudSecurityScanResult, error) { + seen = o + return schemas.NewCloudSecurityScanResult(), nil + } + + if _, err := n.runPipeline(context.Background(), scanInputFor("/repo")); err != nil { + t.Fatalf("runPipeline: %v", err) + } + if seen == nil { + t.Fatal("the orchestrator was never run") + } + if seen.RepoPath != repo { + t.Fatalf("RepoPath = %q, want %q", seen.RepoPath, repo) + } + if want := filepath.Join(repo, ".cloudsecurity"); seen.CheckpointDir != want { + t.Fatalf("CheckpointDir = %q, want %q", seen.CheckpointDir, want) + } +} + +// TestRunPipeline_ReturnsTheModelDump pins `return result.model_dump()`. +func TestRunPipeline_ReturnsTheModelDump(t *testing.T) { + n := newTestNode(t) + n.resolveRepo = func(context.Context, string) (string, error) { return t.TempDir(), nil } + n.runOrchestrator = func(context.Context, *orch.ScanOrchestrator) (schemas.CloudSecurityScanResult, error) { + result := schemas.NewCloudSecurityScanResult() + result.Repository = "/repo" + return result, nil + } + + out, err := n.runPipeline(context.Background(), scanInputFor("/repo")) + if err != nil { + t.Fatalf("runPipeline: %v", err) + } + payload, ok := out.(afx.Payload) + if !ok { + t.Fatalf("runPipeline returned %T, want afx.Payload", out) + } + m := payload.Map() + if m["repository"] != "/repo" { + t.Fatalf("repository = %#v", m["repository"]) + } + for _, key := range []string{"findings", "attack_paths", "by_severity", "cost_breakdown", "timestamp"} { + if _, present := m[key]; !present { + t.Fatalf("model_dump is missing %q", key) + } + } +} + +// TestRunPipeline_ValueErrorClassIsA400 pins `except ValueError -> 400` with the +// RAW message (no prefix). +func TestRunPipeline_ValueErrorClassIsA400(t *testing.T) { + n := newTestNode(t) + n.resolveRepo = func(context.Context, string) (string, error) { return t.TempDir(), nil } + + // A real afx.Bind failure — the Go form of a pydantic ValidationError, the + // only ValueError subclass ScanOrchestrator.run() can raise. + _, bindErr := afx.Bind[schemas.VerifiedFinding](map[string]any{"verdict": "maybe"}) + if bindErr == nil { + t.Fatal("expected a bind failure to build the fixture") + } + n.runOrchestrator = func(context.Context, *orch.ScanOrchestrator) (schemas.CloudSecurityScanResult, error) { + return schemas.CloudSecurityScanResult{}, bindErr + } + + _, err := n.runPipeline(context.Background(), scanInputFor("/repo")) + execErr := asExecuteError(t, err) + if execErr.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", execErr.StatusCode) + } + if execErr.Message != bindErr.Error() { + t.Fatalf("message = %q, want the raw %q", execErr.Message, bindErr.Error()) + } + if strings.Contains(execErr.Message, "scan execution failed") { + t.Fatal("the 400 branch must not carry the 500 prefix") + } +} + +// TestRunPipeline_OtherFailuresAre500WithThePrefix pins +// `except Exception -> 500 {"error": f"scan execution failed: {exc}"}`. +func TestRunPipeline_OtherFailuresAre500WithThePrefix(t *testing.T) { + n := newTestNode(t) + n.resolveRepo = func(context.Context, string) (string, error) { return t.TempDir(), nil } + n.runOrchestrator = func(context.Context, *orch.ScanOrchestrator) (schemas.CloudSecurityScanResult, error) { + // The RuntimeError shape _unwrap raises. + return schemas.CloudSecurityScanResult{}, errors.New("hunt_phase failed: boom") + } + + var out strings.Builder + previous := scanErrorOut + scanErrorOut = &out + t.Cleanup(func() { scanErrorOut = previous }) + + _, err := n.runPipeline(context.Background(), scanInputFor("/repo")) + execErr := asExecuteError(t, err) + if execErr.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", execErr.StatusCode) + } + if execErr.Message != "scan execution failed: hunt_phase failed: boom" { + t.Fatalf("message = %q", execErr.Message) + } + // Python prints `SCAN ERROR: {exc}` before raising. + if !strings.HasPrefix(out.String(), "SCAN ERROR: hunt_phase failed: boom") { + t.Fatalf("diagnostic = %q", out.String()) + } +} + +// TestRunPipeline_PreTryFailuresAre500WithoutThePrefix covers the statements +// app.py runs OUTSIDE its try/except: the orchestrator constructor and +// _resolve_repo. Python leaves both uncaught (FastAPI renders a generic 500); +// Go reports 500 with the message and without the prefix. +func TestRunPipeline_PreTryFailuresAre500WithoutThePrefix(t *testing.T) { + t.Run("orchestrator construction", func(t *testing.T) { + n := newTestNode(t) + n.resolveRepo = func(context.Context, string) (string, error) { + t.Fatal("_resolve_repo must not run after the constructor failed") + return "", nil + } + n.newOrchestrator = func(appx.App, schemas.CloudSecurityInput) (*orch.ScanOrchestrator, error) { + // What ScanConfig.from_input raises for an unknown depth. + return nil, errors.New("'bogus' is not a valid DepthProfile") + } + + _, err := n.runPipeline(context.Background(), scanInputFor("/repo")) + execErr := asExecuteError(t, err) + if execErr.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500 (the constructor runs outside the try)", execErr.StatusCode) + } + if execErr.Message != "'bogus' is not a valid DepthProfile" { + t.Fatalf("message = %q", execErr.Message) + } + }) + + t.Run("repo resolution", func(t *testing.T) { + n := newTestNode(t) + n.resolveRepo = func(context.Context, string) (string, error) { + return "", errors.New("git clone failed: fatal: repository not found") + } + n.runOrchestrator = func(context.Context, *orch.ScanOrchestrator) (schemas.CloudSecurityScanResult, error) { + t.Fatal("the orchestrator must not run when the repo cannot be resolved") + return schemas.CloudSecurityScanResult{}, nil + } + + _, err := n.runPipeline(context.Background(), scanInputFor("/repo")) + execErr := asExecuteError(t, err) + if execErr.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", execErr.StatusCode) + } + if execErr.Message != "git clone failed: fatal: repository not found" { + t.Fatalf("message = %q", execErr.Message) + } + }) +} + +// TestUnknownDepthReaches500ThroughTheRealOrchestrator is the end-to-end form of +// the constructor quirk: a bad `depth` looks like a 400 but Python builds the +// orchestrator before entering the try, so it is a 500. +func TestUnknownDepthReaches500ThroughTheRealOrchestrator(t *testing.T) { + n := newTestNode(t) + n.resolveRepo = func(context.Context, string) (string, error) { return t.TempDir(), nil } + + in := scanInputFor("/repo") + in.Depth = "bogus" + + _, err := n.runPipeline(context.Background(), in) + execErr := asExecuteError(t, err) + if execErr.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", execErr.StatusCode) + } + if !strings.Contains(execErr.Message, "DepthProfile") { + t.Fatalf("message = %q, want the DepthProfile ValueError", execErr.Message) + } +} + +// TestScanHandler_RejectsAMalformedBody: a body the ScanInput cannot bind is a +// client error, not a pipeline failure. +// +// The expectations come from running the real +// `Agent._validate_handler_input(body, fields)` (agentfield Python SDK) against +// cloudsecurity's own `scan` signature: +// +// {} -> "Missing required field: repo_url" +// {"depth": 17} -> "Missing required field: repo_url" +// {"repo_url": None} -> "Field 'repo_url' cannot be None" +// +// all rendered as HTTP 422 by agent.py:2120-2128. +func TestScanHandler_RejectsAMalformedBody(t *testing.T) { + cases := []struct { + name string + body map[string]any + want string + }{ + {"empty body", map[string]any{}, "Missing required field: repo_url"}, + {"only an optional parameter", map[string]any{"depth": 17}, "Missing required field: repo_url"}, + {"explicit null for a required parameter", map[string]any{"repo_url": nil}, "Field 'repo_url' cannot be None"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + n := newTestNode(t) + n.resolveRepo = func(context.Context, string) (string, error) { + t.Fatal("repo resolution must not run for an invalid body") + return "", nil + } + n.runOrchestrator = func(context.Context, *orch.ScanOrchestrator) (schemas.CloudSecurityScanResult, error) { + t.Fatal("the pipeline must not run for an invalid body") + return schemas.CloudSecurityScanResult{}, nil + } + + _, err := n.scanHandler(context.Background(), tc.body) + execErr := asExecuteError(t, err) + if execErr.StatusCode != http.StatusUnprocessableEntity { + t.Fatalf("status = %d, want 422 (the Python endpoint's validation status)", execErr.StatusCode) + } + if execErr.Message != tc.want { + t.Fatalf("message = %q, want %q", execErr.Message, tc.want) + } + }) + } +} + +// TestScanHandler_CoercesScalarsThePythonSDKCoerces is the other direction of +// the same contract: the Python SDK's `str(value)` / `int(value)` / +// `float(value)` / bool whitelist run BEFORE the handler, so these bodies are +// accepted by the Python node and must be accepted here. Ground truth, from the +// real _validate_handler_input on scan(): +// +// {"repo_url": 123} -> repo_url == "123" +// {"repo_url": "/r", "is_pr": "yes"} -> is_pr is True +// +// and, for the nullable ints/floats the SDK leaves alone, from pydantic's lax +// validation of CloudSecurityInput: +// +// max_concurrent_hunters="4" -> 4 +// max_cost_usd="2.5" -> 2.5 +func TestScanHandler_CoercesScalarsThePythonSDKCoerces(t *testing.T) { + var got schemas.CloudSecurityInput + n := newTestNode(t) + n.resolveRepo = func(_ context.Context, url string) (string, error) { return url, nil } + n.newOrchestrator = func(_ appx.App, in schemas.CloudSecurityInput) (*orch.ScanOrchestrator, error) { + got = in + return &orch.ScanOrchestrator{}, nil + } + n.runOrchestrator = func(context.Context, *orch.ScanOrchestrator) (schemas.CloudSecurityScanResult, error) { + return schemas.CloudSecurityScanResult{}, nil + } + + if _, err := n.scanHandler(context.Background(), map[string]any{ + "repo_url": 123, + "is_pr": "yes", + "fail_on_findings": "true", + "max_concurrent_hunters": "4", + "max_cost_usd": "2.5", + "depth": "quick", + }); err != nil { + t.Fatalf("scan: %v", err) + } + + if got.RepoURL != "123" { + t.Errorf("repo_url = %q, want \"123\" (Python str(123))", got.RepoURL) + } + if !got.IsPR { + t.Error("is_pr = false, want true (the \"yes\" whitelist)") + } + if !got.FailOnFindings { + t.Error("fail_on_findings = false, want true") + } + if got.MaxConcurrentHunters == nil || *got.MaxConcurrentHunters != 4 { + t.Errorf("max_concurrent_hunters = %v, want 4", got.MaxConcurrentHunters) + } + if got.MaxCostUSD == nil || *got.MaxCostUSD != 2.5 { + t.Errorf("max_cost_usd = %v, want 2.5", got.MaxCostUSD) + } +} + +// TestScanHandler_DropsUndeclaredKeys pins `result = {}` in +// _validate_handler_input: the handler is called with the validated map, not +// the raw body, so an undeclared key can never reach the model. +func TestScanHandler_DropsUndeclaredKeys(t *testing.T) { + n := newTestNode(t) + n.resolveRepo = func(_ context.Context, url string) (string, error) { return url, nil } + n.runOrchestrator = func(context.Context, *orch.ScanOrchestrator) (schemas.CloudSecurityScanResult, error) { + return schemas.CloudSecurityScanResult{}, nil + } + if _, err := n.scanHandler(context.Background(), map[string]any{ + "repo_url": "/repo", + "unknown_key": []any{"whatever"}, + }); err != nil { + t.Fatalf("scan: %v", err) + } +} + +// TestScanAndProveHandlersShareThePipeline proves both top-level reasoners run +// the same _run_pipeline, differing only in the CloudSecurityInput they build. +func TestScanAndProveHandlersShareThePipeline(t *testing.T) { + n := newTestNode(t) + n.resolveRepo = func(context.Context, string) (string, error) { return t.TempDir(), nil } + + var tiers []int + n.runOrchestrator = func(_ context.Context, o *orch.ScanOrchestrator) (schemas.CloudSecurityScanResult, error) { + tiers = append(tiers, o.Input.Tier()) + return schemas.NewCloudSecurityScanResult(), nil + } + + if _, err := n.scanHandler(context.Background(), map[string]any{"repo_url": "/repo"}); err != nil { + t.Fatalf("scan: %v", err) + } + if _, err := n.proveHandler(context.Background(), map[string]any{"repo_url": "/repo"}); err != nil { + t.Fatalf("prove: %v", err) + } + if !reflect.DeepEqual(tiers, []int{1, 2}) { + t.Fatalf("tiers = %v, want [1 2] (scan is static-only, prove is live)", tiers) + } +} + +// --- the ValueError-class marker -------------------------------------------- + +// TestIsValueErrorClass_PinsTheAfxBindMarker asserts the marker against a REAL +// afx.Bind failure, so a change to afx's message text fails here loudly instead +// of silently downgrading every 400 to a 500. +func TestIsValueErrorClass_PinsTheAfxBindMarker(t *testing.T) { + // pydantic raises ValidationError for BOTH of these, and both are + // ValueError subclasses, so both must reach app.py's `except ValueError` + // branch: a bad enum value and a MISSING REQUIRED field. + full := map[string]any{ + "title": "t", "verdict": "confirmed", "severity": "high", "category": "c", + } + badEnum := map[string]any{} + for k, v := range full { + badEnum[k] = v + } + badEnum["severity"] = "catastrophic" + + for name, payload := range map[string]map[string]any{ + "invalid enum": badEnum, + "missing required field": {"verdict": "confirmed"}, + } { + _, err := afx.Bind[schemas.VerifiedFinding](payload) + if err == nil { + t.Fatalf("%s: expected a bind failure", name) + } + if !strings.Contains(err.Error(), afxBindErrorMarker) { + t.Fatalf("%s: afx.Bind message %q no longer contains %q — update afxBindErrorMarker", name, err, afxBindErrorMarker) + } + if !isValueErrorClass(err) { + t.Fatalf("%s: a pydantic-equivalent validation failure must map to the 400 branch", name) + } + } +} + +// TestIsValueErrorClass_RelayedChildBindFailureIsNotValueErrorClass pins the +// half text-matching gets wrong. +// +// A bind failure inside a CHILD reasoner is published by that child's SDK +// handler as `{"error": "afx.Bind: ..."}`, recorded by the control plane as the +// execution's error_message, and relayed to this process as an +// *agent.ExecuteError whose text still starts with the marker. In Python that +// arrives as agentfield.exceptions.ExecutionFailedError, whose MRO is +// (ExecutionFailedError, AgentFieldClientError, AgentFieldError, Exception) — +// NOT a ValueError — so app.py:222-232 takes the `except Exception` branch and +// answers 500 with the "scan execution failed: " prefix. Classifying it 400 +// because the relayed text contains "afx.Bind: " inverts the +// client-error/retryable class every caller branches on. +func TestIsValueErrorClass_RelayedChildBindFailureIsNotValueErrorClass(t *testing.T) { + relayed := &agent.ExecuteError{ + StatusCode: 502, + Message: `afx.Bind: 1 validation error for VerifiedFinding: verdict: Field required`, + } + if !strings.Contains(relayed.Error(), afxBindErrorMarker) { + t.Fatalf("premise broken: the relayed message no longer carries %q", afxBindErrorMarker) + } + if isValueErrorClass(relayed) { + t.Fatal("a relayed child failure must take the 500 branch, as Python's `except Exception` does") + } + + n := newTestNode(t) + n.resolveRepo = func(context.Context, string) (string, error) { return t.TempDir(), nil } + n.runOrchestrator = func(context.Context, *orch.ScanOrchestrator) (schemas.CloudSecurityScanResult, error) { + return schemas.CloudSecurityScanResult{}, relayed + } + _, err := n.runPipeline(context.Background(), scanInputFor("/repo")) + execErr := asExecuteError(t, err) + if execErr.StatusCode != http.StatusInternalServerError { + t.Errorf("status = %d, want 500", execErr.StatusCode) + } + if want := "scan execution failed: " + relayed.Error(); execErr.Message != want { + t.Errorf("message = %q, want %q", execErr.Message, want) + } +} + +func TestIsValueErrorClass_NonValueErrors(t *testing.T) { + for _, err := range []error{ + nil, + errors.New("run_iac_reader failed: boom"), // RuntimeError (_unwrap) + errors.New("'verified'"), // KeyError + errors.New("post /api/v1/execute: timeout"), // transport + } { + if isValueErrorClass(err) { + t.Errorf("isValueErrorClass(%v) = true, want false", err) + } + } +} + +// --- helpers ----------------------------------------------------------------- + +// mustHarnessConfig is harnessConfig with the boot-failure error asserted away. +func mustHarnessConfig(t *testing.T, c config.AIIntegrationConfig) *agent.HarnessConfig { + t.Helper() + hc, err := harnessConfig(c) + if err != nil { + t.Fatalf("harnessConfig: %v", err) + } + return hc +} + +// newTestNode builds a Node whose agent is real (so registration is real) but +// whose capability seam is a recording fake, and whose repo resolution and +// orchestrator run are stubbed by the caller. +func newTestNode(t *testing.T) *Node { + t.Helper() + clearNodeEnv(t) + n, err := BuildAgent("cloudsecurity", "8015", "AI-Native Cloud Infrastructure Security Scanner") + if err != nil { + t.Fatalf("BuildAgent: %v", err) + } + n.pipelineApp = &appx.Fake{} + return n +} + +func scanInputFor(repoURL string) schemas.CloudSecurityInput { + in := NewScanInput() + in.RepoURL = repoURL + return in.CloudSecurityInput() +} + +func asExecuteError(t *testing.T, err error) *agent.ExecuteError { + t.Helper() + if err == nil { + t.Fatal("expected an error") + } + var execErr *agent.ExecuteError + if !errors.As(err, &execErr) { + t.Fatalf("error %v (%T) is not an *agent.ExecuteError", err, err) + } + return execErr +} + +// TestBuildAgent_FailsOnUnwritableXDGDataHome: Python calls +// `_ai_config.provider_env()` inside app.py's module-level Agent(...) literal, +// so an unwritable XDG_DATA_HOME (a read-only volume, a path component that is +// a regular file) is an import-time crash and the node never registers. +// Verified against the repo venv: provider_env() raises NotADirectoryError. +func TestBuildAgent_FailsOnUnwritableXDGDataHome(t *testing.T) { + clearNodeEnv(t) + blocker := filepath.Join(t.TempDir(), "notadir") + if err := os.WriteFile(blocker, nil, 0o600); err != nil { + t.Fatalf("write blocker: %v", err) + } + t.Setenv("XDG_DATA_HOME", filepath.Join(blocker, "sub")) + + if _, err := BuildAgent("cloudsecurity", "8015", "d"); err == nil { + t.Fatal("BuildAgent succeeded; Python's provider_env() crashes the import") + } +} + +// TestScanInput_NonFiniteMaxCostIsARegularRejection pins the shape of the +// failure for a non-finite `max_cost_usd`. +// +// Python accepts it — `CloudSecurityInput(repo_url="/tmp", max_cost_usd=v)` in +// the repo venv yields nan for "NaN"/"nan", inf for "Infinity"/"1e999" and +// -inf for "-inf" — and the scan then RUNS, because every budget comparison +// against a non-finite number is False. Go cannot represent that value in the +// JSON round trip afx.Bind performs, so it rejects; what this test pins is that +// the rejection is the ORDINARY uncoercible-optional decode error and not the +// Go-internals "marshal input: json: unsupported value: NaN" that the pyFloat +// coercion used to produce. See afx.pyFloat's comment and go/README.md's +// divergence list. +func TestScanInput_NonFiniteMaxCostIsARegularRejection(t *testing.T) { + for _, spelling := range []string{"NaN", "nan", "Infinity", "-inf", "1e999"} { + _, err := afx.BindHandlerInput[ScanInput](map[string]any{ + "repo_url": "/r", + "max_cost_usd": spelling, + }) + if err == nil { + t.Errorf("max_cost_usd=%q bound; Go cannot carry a non-finite float", spelling) + continue + } + if got := err.Error(); strings.Contains(got, "marshal input") { + t.Errorf("max_cost_usd=%q -> %q, want the ordinary decode error", spelling, got) + } + } + // The finite control still coerces, exactly as pydantic does. + in, err := afx.BindHandlerInput[ScanInput](map[string]any{"repo_url": "/r", "max_cost_usd": "2.5"}) + if err != nil { + t.Fatalf("max_cost_usd=\"2.5\": %v", err) + } + if in.MaxCostUSD == nil || *in.MaxCostUSD != 2.5 { + t.Fatalf("max_cost_usd = %v, want 2.5", in.MaxCostUSD) + } +} diff --git a/go/internal/node/nodeid_test.go b/go/internal/node/nodeid_test.go new file mode 100644 index 0000000..311b996 --- /dev/null +++ b/go/internal/node/nodeid_test.go @@ -0,0 +1,97 @@ +package node + +import ( + "os" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/config" + "github.com/Agent-Field/cloudsecurity-af/go/internal/orch" + "github.com/Agent-Field/cloudsecurity-af/go/internal/phases" +) + +// TestNodeID_RegistrationAndCallTargetsAgree is the cross-package invariant the +// Python node gets for free. +// +// src/cloudsecurity_af/app.py:31, reasoners/phases.py:22 and +// orchestrator.py:73 each read the IDENTICAL +// `os.getenv("NODE_ID", "cloudsecurity")`, so the id the node registers under +// and the prefix of every `app.call(f"{NODE_ID}.")` DAG edge are the +// same string for every possible environment. The contract this test pins is +// therefore stated in Python terms, not Go terms: +// +// for any NODE_ID environment, registered id == phase call prefix == +// orchestrator call prefix, and none of them is empty. +// +// The Go port satisfies it by routing all three through config.NodeID. When it +// did not — buildConfig used an empty-means-absent reader while phases/orch +// used os.LookupEnv — an exported-empty NODE_ID registered the node as +// "cloudsecurity" and then made it call ".recon_phase" / ".run_iac_reader". +// The SDK does not repair those: agent.Call only prefixes a target that +// contains no dot, and a leading-dot target already contains one, so every scan +// died at its first phase call after a clean registration. +func TestNodeID_RegistrationAndCallTargetsAgree(t *testing.T) { + cases := []struct { + name string + nodeID string + unset bool + wantID string + comment string + }{ + {name: "unset", unset: true, wantID: config.DefaultNodeID}, + {name: "explicit", nodeID: "cloudsecurity-go", wantID: "cloudsecurity-go"}, + {name: "exported empty", nodeID: "", wantID: config.DefaultNodeID}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + clearNodeEnv(t) + if tc.unset { + _ = os.Unsetenv("NODE_ID") + } else { + t.Setenv("NODE_ID", tc.nodeID) + } + + cfg, err := buildConfig(config.DefaultNodeID, "8015", "d") + if err != nil { + t.Fatalf("buildConfig: %v", err) + } + registered := cfg.NodeID + phasePrefix := phases.NodeID() + orchPrefix := orch.NodeID() + + if registered != tc.wantID { + t.Errorf("registered NodeID = %q, want %q", registered, tc.wantID) + } + if phasePrefix != registered { + t.Errorf("phases.NodeID() = %q, registered = %q: DAG targets would be %q.run_iac_reader against a node registered as %q", + phasePrefix, registered, phasePrefix, registered) + } + if orchPrefix != registered { + t.Errorf("orch.NodeID() = %q, registered = %q: DAG targets would be %q.recon_phase against a node registered as %q", + orchPrefix, registered, orchPrefix, registered) + } + if registered == "" { + t.Errorf("registered NodeID is empty; every Call target would start with a bare dot") + } + }) + } +} + +// TestNodeID_DefaultsAreOneConstant pins the second half of the same finding: +// the literal "cloudsecurity" must not exist independently in three packages, +// or the registered id and the call-target prefix can drift while every +// individual package's tests stay green. +func TestNodeID_DefaultsAreOneConstant(t *testing.T) { + if phases.DefaultNodeID != config.DefaultNodeID { + t.Errorf("phases.DefaultNodeID = %q, config.DefaultNodeID = %q", phases.DefaultNodeID, config.DefaultNodeID) + } + if orch.DefaultNodeID != config.DefaultNodeID { + t.Errorf("orch.DefaultNodeID = %q, config.DefaultNodeID = %q", orch.DefaultNodeID, config.DefaultNodeID) + } + // cmd/cloudsecurity-af/main.go passes this same value into BuildAgent; the + // default-path test above proves the value it passes and the value the + // call-target resolvers use are the one constant. + if config.DefaultNodeID != "cloudsecurity" { + t.Errorf("config.DefaultNodeID = %q, want cloudsecurity (os.getenv(\"NODE_ID\", \"cloudsecurity\"))", config.DefaultNodeID) + } +} diff --git a/go/internal/node/payload_order_test.go b/go/internal/node/payload_order_test.go new file mode 100644 index 0000000..a2bc80e --- /dev/null +++ b/go/internal/node/payload_order_test.go @@ -0,0 +1,189 @@ +package node + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/orch" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// VALIDATION CONTRACT — the `scan` / `prove` reply BYTES. +// +// app.py:216 returns `result.model_dump()` and FastAPI serialises it with +// json.dumps, which preserves the dict's insertion order — pydantic's FIELD +// DECLARATION order. Ground truth, read off the live model under the repo venv: +// +// list(CloudSecurityScanResult.model_fields) +// -> repository, commit_sha, branch, timestamp, depth_profile, tier, +// providers_detected, findings, attack_paths, total_resources_scanned, +// total_raw_findings, confirmed, likely, inconclusive, not_exploitable, +// noise_reduction_pct, by_severity, drift_resources, shadow_it_resources, +// compliance_frameworks_checked, compliance_gaps, strategies_used, +// duration_seconds, agent_invocations, cost_usd, cost_breakdown, +// metadata, sarif +// +// and the body it produces starts +// +// {"repository": "/repo", "commit_sha": "c", "branch": null, ... +// +// Returning a Go map instead put the same keys on the wire alphabetically +// (agent_invocations, attack_paths, branch, by_severity, commit_sha, …) and +// rendered `"cost_usd": 0.0` as `0`. +var pythonScanResultFieldOrder = []string{ + "repository", "commit_sha", "branch", "timestamp", "depth_profile", "tier", + "providers_detected", "findings", "attack_paths", "total_resources_scanned", + "total_raw_findings", "confirmed", "likely", "inconclusive", "not_exploitable", + "noise_reduction_pct", "by_severity", "drift_resources", "shadow_it_resources", + "compliance_frameworks_checked", "compliance_gaps", "strategies_used", + "duration_seconds", "agent_invocations", "cost_usd", "cost_breakdown", + "metadata", "sarif", +} + +func TestRunPipeline_ReplyKeepsPydanticFieldOrder(t *testing.T) { + n := newTestNode(t) + n.resolveRepo = func(context.Context, string) (string, error) { return t.TempDir(), nil } + n.runOrchestrator = func(context.Context, *orch.ScanOrchestrator) (schemas.CloudSecurityScanResult, error) { + result := schemas.NewCloudSecurityScanResult() + result.Repository = "/repo" + result.CommitSHA = "c" + return result, nil + } + + out, err := n.runPipeline(context.Background(), scanInputFor("/repo")) + if err != nil { + t.Fatalf("runPipeline: %v", err) + } + body, err := json.Marshal(out) + if err != nil { + t.Fatalf("marshal reply: %v", err) + } + + got := topLevelKeys(t, body) + if len(got) != len(pythonScanResultFieldOrder) { + t.Fatalf("keys = %v\nwant %v", got, pythonScanResultFieldOrder) + } + for i, want := range pythonScanResultFieldOrder { + if got[i] != want { + t.Fatalf("key %d = %q, want %q\ngot %v\nwant %v", i, got[i], want, got, pythonScanResultFieldOrder) + } + } + + // The float fields keep Python's spelling. encoding/json compacts a + // Marshaler's bytes, dropping the ", " / ": " separators — the same + // separators FastAPI's JSONResponse uses. + for _, want := range []string{`"noise_reduction_pct":0.0`, `"cost_usd":0.0`, `"duration_seconds":0.0`} { + if !strings.Contains(string(body), want) { + t.Errorf("reply does not contain %s\n%s", want, body) + } + } +} + +// topLevelKeys returns a JSON object's keys in document order. +func topLevelKeys(t *testing.T, body []byte) []string { + t.Helper() + dec := json.NewDecoder(strings.NewReader(string(body))) + tok, err := dec.Token() + if err != nil || tok != json.Delim('{') { + t.Fatalf("body is not a JSON object: %s", body) + } + var keys []string + for dec.More() { + tok, err := dec.Token() + if err != nil { + t.Fatalf("scan key: %v", err) + } + key, ok := tok.(string) + if !ok { + t.Fatalf("expected a key, got %v", tok) + } + keys = append(keys, key) + var raw json.RawMessage + if err := dec.Decode(&raw); err != nil { + t.Fatalf("decode value of %q: %v", key, err) + } + } + return keys +} + +// VALIDATION CONTRACT — the reply's two SEEDED DICTS keep Python's insertion +// order, not the alphabetical order a Go map renders with. +// +// orchestrator.py seeds both from a fixed sequence and never adds a key: +// +// severity_counts = {s.value: 0 for s in Severity} (:165) +// _PHASE_ORDER = ("recon", "hunt", "chain", "prove", "remediate") (:54) +// self.cost_breakdown = {phase: 0.0 for phase in self._PHASE_ORDER} (:67) +// +// Ground truth from the repo venv — CloudSecurityScanResult(...).model_dump() +// with critical=1 and a freshly seeded cost_breakdown, rendered with +// json.dumps: +// +// {"critical": 1, "high": 0, "medium": 0, "low": 0, "info": 0} +// {"recon": 0.0, "hunt": 0.0, "chain": 0.0, "prove": 0.0, "remediate": 0.0} +// +// Sorting them instead puts "info" second and "chain" first, and spells every +// cost as `0` rather than `0.0`. +func TestRunPipeline_ReplySeededDictsKeepPythonInsertionOrder(t *testing.T) { + n := newTestNode(t) + n.resolveRepo = func(context.Context, string) (string, error) { return t.TempDir(), nil } + n.runOrchestrator = func(context.Context, *orch.ScanOrchestrator) (schemas.CloudSecurityScanResult, error) { + result := schemas.NewCloudSecurityScanResult() + result.Repository = "/repo" + result.BySeverity = map[string]int{"critical": 1, "high": 0, "medium": 0, "low": 0, "info": 0} + result.CostBreakdown = map[string]float64{} + for _, phase := range schemas.CostBreakdownOrder { + result.CostBreakdown[phase] = 0.0 + } + return result, nil + } + + out, err := n.runPipeline(context.Background(), scanInputFor("/repo")) + if err != nil { + t.Fatalf("runPipeline: %v", err) + } + body, err := json.Marshal(out) + if err != nil { + t.Fatalf("marshal reply: %v", err) + } + + // encoding/json compacts the Marshaler's bytes, so the wire spelling drops + // json.dumps' ", " / ": " separators — as FastAPI's JSONResponse does. + for _, want := range []string{ + `"by_severity":{"critical":1,"high":0,"medium":0,"low":0,"info":0}`, + `"cost_breakdown":{"recon":0.0,"hunt":0.0,"chain":0.0,"prove":0.0,"remediate":0.0}`, + } { + if !strings.Contains(string(body), want) { + t.Errorf("reply does not contain %s\ngot %s", want, body) + } + } +} + +// A key the seeded order does not name still appears — sorted, after the known +// ones — rather than being dropped. Python cannot reach this state +// (_register_cost only mutates existing entries), so the tail is purely +// defensive; what it must never do is lose data. +func TestRunPipeline_UnknownSeededDictKeyIsKeptAtTheEnd(t *testing.T) { + n := newTestNode(t) + n.resolveRepo = func(context.Context, string) (string, error) { return t.TempDir(), nil } + n.runOrchestrator = func(context.Context, *orch.ScanOrchestrator) (schemas.CloudSecurityScanResult, error) { + result := schemas.NewCloudSecurityScanResult() + result.CostBreakdown = map[string]float64{"remediate": 1.0, "recon": 2.0, "zzz": 3.0, "aaa": 4.0} + return result, nil + } + + out, err := n.runPipeline(context.Background(), scanInputFor("/repo")) + if err != nil { + t.Fatalf("runPipeline: %v", err) + } + body, err := json.Marshal(out) + if err != nil { + t.Fatalf("marshal reply: %v", err) + } + const want = `"cost_breakdown":{"recon":2.0,"remediate":1.0,"aaa":4.0,"zzz":3.0}` + if !strings.Contains(string(body), want) { + t.Errorf("reply does not contain %s\ngot %s", want, body) + } +} diff --git a/go/internal/node/resolve.go b/go/internal/node/resolve.go new file mode 100644 index 0000000..1aebb49 --- /dev/null +++ b/go/internal/node/resolve.go @@ -0,0 +1,284 @@ +package node + +// resolve.go ports the two repo-resolution helpers in src/cloudsecurity_af/app.py +// (_workspaces_root and _resolve_repo). They run BEFORE the orchestrator's +// try/except in _run_pipeline, so every failure here is an uncaught exception in +// Python — see runPipeline for how that maps to a status code. + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/agents/util" +) + +// git subprocess timeouts, matching app.py's subprocess.run(..., timeout=…). +const ( + gitPullTimeout = 60 * time.Second + gitCloneTimeout = 120 * time.Second +) + +// remotePrefixes is the tuple _resolve_repo tests with str.startswith, IN ORDER. +var remotePrefixes = []string{"https://", "http://", "git@"} + +// defaultWorkspacesDir is the `default = "/workspaces"` probe target. It is a +// var ONLY so the test suite can redirect the probe at a temp path instead of +// creating (or refusing to create) a real /workspaces on the developer's +// machine. Production code must never reassign it. +var defaultWorkspacesDir = "/workspaces" + +// workspacesFallbackSegments is Python's +// `os.path.join(Path.home(), ".sec-af", "workspaces")`. +// +// Python parity (deliberate oddity, NOT a typo on our side): cloudsecurity-af +// reuses sec-af's fallback directory name — and its SEC_AF_WORKSPACES_DIR env +// var below. Both are copied verbatim from app.py; renaming them would silently +// relocate every already-cloned workspace on an upgraded node. +var workspacesFallbackSegments = []string{".sec-af", "workspaces"} + +// workspacesRoot ports app.py::_workspaces_root. +// +// Python: +// +// explicit = os.environ.get("SEC_AF_WORKSPACES_DIR") +// if explicit: +// os.makedirs(explicit, exist_ok=True) +// return explicit +// default = "/workspaces" +// try: +// os.makedirs(default, exist_ok=True) +// test_file = os.path.join(default, ".write_test") +// with open(test_file, "w") as f: f.write("") +// os.remove(test_file) +// return default +// except OSError: +// fallback = os.path.join(Path.home(), ".sec-af", "workspaces") +// os.makedirs(fallback, exist_ok=True) +// return fallback +// +// Python parity: the explicit branch is a TRUTHINESS test, so +// SEC_AF_WORKSPACES_DIR="" falls through to the /workspaces probe. +// +// Python parity: an mkdir failure in the EXPLICIT branch and in the FALLBACK +// branch propagates (neither is inside the try); only the /workspaces probe is +// guarded, and only against OSError. Both propagating cases return an error here. +// +// Python parity: the probe writes and deletes "/workspaces/.write_test" — +// mkdir succeeding is not enough, the directory must be writable by this user. +func workspacesRoot() (string, error) { + if explicit := os.Getenv("SEC_AF_WORKSPACES_DIR"); explicit != "" { + if err := os.MkdirAll(explicit, 0o777); err != nil { + return "", err + } + return explicit, nil + } + + if err := probeWritableDir(defaultWorkspacesDir); err == nil { + return defaultWorkspacesDir, nil + } + + fallback, err := workspacesFallbackDir() + if err != nil { + return "", err + } + if err := os.MkdirAll(fallback, 0o777); err != nil { + return "", err + } + return fallback, nil +} + +// workspacesFallbackDir builds `os.path.join(Path.home(), ".sec-af", "workspaces")`. +func workspacesFallbackDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cloudsecurity node: resolve home directory: %w", err) + } + return filepath.Join(append([]string{home}, workspacesFallbackSegments...)...), nil +} + +// probeWritableDir is the guarded body of _workspaces_root's try block: create +// the directory, then prove it is writable by creating and removing +// "/.write_test". +func probeWritableDir(dir string) error { + if err := os.MkdirAll(dir, 0o777); err != nil { + return err + } + testFile := filepath.Join(dir, ".write_test") + f, err := os.Create(testFile) + if err != nil { + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Remove(testFile) +} + +// resolveRepo ports app.py::_resolve_repo. +// +// Python: +// +// 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", "") +// ws_root = _workspaces_root() +// target_dir = os.path.join(ws_root, repo_name) +// os.makedirs(ws_root, exist_ok=True) +// 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("CLOUDSECURITY_REPO_PATH", os.getcwd())).resolve()) +// +// Python parity: the `git pull` RESULT is DISCARDED — `subprocess.run` without +// check=True returns a CompletedProcess for a non-zero exit, so a failed +// refresh (offline, diverged branch, dirty tree) silently reuses the stale +// checkout. That is true ONLY for a non-zero exit: `timeout=60` RAISES +// subprocess.TimeoutExpired and a missing git binary raises FileNotFoundError, +// and both propagate out of _resolve_repo — which app.py:219 calls one line +// ABOVE _run_pipeline's try — so the Python node answers 500 and scans nothing. +// Discarding those in Go would audit a stale checkout and answer 200 with a +// full CloudSecurityScanResult, i.e. present an N-days-old audit as a current +// one. See the pull branch below for how the two are told apart. +// +// Python parity: repo_name strips a trailing "/" then takes the last path +// segment and removes EVERY occurrence of ".git" in it, not just a suffix — so +// "https://host/.gitfoo.git" becomes "foo". Reproduced with ReplaceAll. +// +// DIVERGENCE (message text only): for the CLONE, Python's subprocess timeout +// raises TimeoutExpired while Go's context deadline surfaces as a killed child, +// which this function reports as the "git clone failed: …" ValueError string. +// Both are uncaught in app.py (this helper runs before _run_pipeline's try), so +// both render as a 500 — only the text differs. The clone branch folds the +// underlying error into that text whenever git wrote nothing to stderr, so a +// missing binary or a fired deadline still names its cause instead of +// producing an empty "git clone failed: ". +func resolveRepo(ctx context.Context, repoURL string) (string, error) { + if isDir(repoURL) { + return util.ResolvePath(repoURL), nil + } + + if hasRemotePrefix(repoURL) { + repoName := repoNameFromURL(repoURL) + wsRoot, err := workspacesRoot() + if err != nil { + return "", err + } + targetDir := filepath.Join(wsRoot, repoName) + if err := os.MkdirAll(wsRoot, 0o777); err != nil { + return "", err + } + + 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: + // Python's `timeout=60` raises subprocess.TimeoutExpired, which + // propagates. The deadline has to be read off the CONTEXT, not + // off err — CommandContext reports the kill as an *exec.ExitError + // ("signal: killed"), indistinguishable from an ordinary + // non-zero exit. + return "", ctxErr + } + if err != nil { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + // git missing or 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 — the CompletedProcess is discarded and + // 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 { + // Python parity: the message is `git clone failed: {result.stderr.strip()}`, + // so an ordinary auth/404 failure keeps git's own text byte for byte. + // When git wrote NOTHING — the binary is missing (`exec: "git": + // executable file not found in $PATH`, where Python raises + // FileNotFoundError) or the 120s deadline killed it before any + // output — the Python message still names the cause and an empty + // stderr would not, so err fills in. + detail := strings.TrimSpace(stderr.String()) + if detail == "" { + detail = err.Error() + } + return "", fmt.Errorf("git clone failed: %s", detail) + } + return targetDir, nil + } + + // Python parity: os.getenv substitutes the cwd only when the key is ABSENT, + // so CLOUDSECURITY_REPO_PATH="" yields Path("") == Path(".") -> the cwd + // anyway. util.ResolvePath("") does the same. + raw, present := os.LookupEnv("CLOUDSECURITY_REPO_PATH") + if !present { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("cloudsecurity node: resolve repo path: %w", err) + } + raw = cwd + } + return util.ResolvePath(raw), nil +} + +// gitEnv reproduces `{**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "echo"}`: +// the process environment plus the two settings that make a missing credential +// fail fast instead of blocking on an interactive prompt. +func gitEnv() []string { + return append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=echo") +} + +// isDir ports os.path.isdir: true only for an existing DIRECTORY (symlinks +// followed), false for a regular file and for anything that does not exist. +func isDir(path string) bool { + if path == "" { + return false + } + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +// hasRemotePrefix ports `repo_url.startswith(("https://", "http://", "git@"))`. +func hasRemotePrefix(repoURL string) bool { + for _, prefix := range remotePrefixes { + if strings.HasPrefix(repoURL, prefix) { + return true + } + } + return false +} + +// repoNameFromURL ports `repo_url.rstrip("/").split("/")[-1].replace(".git", "")`. +// +// Python parity: str.rstrip("/") strips EVERY trailing slash, not just one. +func repoNameFromURL(repoURL string) string { + trimmed := strings.TrimRight(repoURL, "/") + last := trimmed + if i := strings.LastIndex(trimmed, "/"); i >= 0 { + last = trimmed[i+1:] + } + return strings.ReplaceAll(last, ".git", "") +} diff --git a/go/internal/node/resolve_test.go b/go/internal/node/resolve_test.go new file mode 100644 index 0000000..4090e69 --- /dev/null +++ b/go/internal/node/resolve_test.go @@ -0,0 +1,394 @@ +package node + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/agents/util" +) + +// TestRepoNameFromURL_MatchesPython pins +// `repo_url.rstrip("/").split("/")[-1].replace(".git", "")`. Every expectation +// was produced by running that expression under the repo's own interpreter +// (~/.agentfield/packages/cloudsecurity-af/venv/bin/python). +func TestRepoNameFromURL_MatchesPython(t *testing.T) { + cases := []struct{ url, want string }{ + {"https://github.com/owner/repo.git", "repo"}, + {"https://github.com/owner/repo", "repo"}, + {"https://github.com/owner/repo///", "repo"}, + {"git@github.com:owner/repo.git", "repo"}, + // replace() is not a suffix strip: EVERY ".git" in the last segment goes. + {"http://host/a/b/.gitfoo.git", "foo"}, + {"https://github.com/owner/my.github.repo.git", "myhub.repo"}, + } + for _, tc := range cases { + if got := repoNameFromURL(tc.url); got != tc.want { + t.Errorf("repoNameFromURL(%q) = %q, want %q", tc.url, got, tc.want) + } + } +} + +func TestHasRemotePrefix(t *testing.T) { + for _, url := range []string{"https://x/y", "http://x/y", "git@host:o/r.git"} { + if !hasRemotePrefix(url) { + t.Errorf("hasRemotePrefix(%q) = false", url) + } + } + for _, url := range []string{"", "/abs/path", "ssh://host/x", "ftp://x"} { + if hasRemotePrefix(url) { + t.Errorf("hasRemotePrefix(%q) = true", url) + } + } +} + +// --- _workspaces_root -------------------------------------------------------- + +func TestWorkspacesRoot_ExplicitEnvIsCreatedAndReturned(t *testing.T) { + explicit := filepath.Join(t.TempDir(), "nested", "ws") + t.Setenv("SEC_AF_WORKSPACES_DIR", explicit) + + got, err := workspacesRoot() + if err != nil { + t.Fatalf("workspacesRoot: %v", err) + } + if got != explicit { + t.Fatalf("workspacesRoot() = %q, want %q", got, explicit) + } + if !isDir(explicit) { + t.Fatal("explicit workspaces dir was not created") + } +} + +// TestWorkspacesRoot_EmptyEnvFallsThrough pins Python's TRUTHINESS test: +// SEC_AF_WORKSPACES_DIR="" is not an explicit override, so the /workspaces +// probe runs (redirected here so the test never touches the real /workspaces). +func TestWorkspacesRoot_EmptyEnvFallsThrough(t *testing.T) { + t.Setenv("SEC_AF_WORKSPACES_DIR", "") + probe := filepath.Join(t.TempDir(), "workspaces") + withDefaultWorkspacesDir(t, probe) + + got, err := workspacesRoot() + if err != nil { + t.Fatalf("workspacesRoot: %v", err) + } + if got != probe { + t.Fatalf("workspacesRoot() = %q, want the probe target %q", got, probe) + } + if !isDir(probe) { + t.Fatal("the probe target was not created") + } +} + +// TestWorkspacesRoot_FallbackWhenDefaultIsUnwritable exercises the +// except-OSError branch: the probe target cannot be created (a regular file is +// in the way), so the root becomes $HOME/.sec-af/workspaces — sec-af, verbatim +// from app.py. +func TestWorkspacesRoot_FallbackWhenDefaultIsUnwritable(t *testing.T) { + root := t.TempDir() + blocker := filepath.Join(root, "blocked") + if err := os.WriteFile(blocker, nil, 0o644); err != nil { + t.Fatalf("write blocker: %v", err) + } + withDefaultWorkspacesDir(t, filepath.Join(blocker, "workspaces")) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("SEC_AF_WORKSPACES_DIR", "") + + got, err := workspacesRoot() + if err != nil { + t.Fatalf("workspacesRoot: %v", err) + } + want := filepath.Join(home, ".sec-af", "workspaces") + if got != want { + t.Fatalf("workspacesRoot() = %q, want %q", got, want) + } + if !isDir(want) { + t.Fatal("fallback workspaces dir was not created") + } +} + +// TestWorkspacesRoot_ProbeIsWritabilityNotExistence pins the .write_test probe: +// a directory that exists but cannot be written to must NOT be accepted. +func TestWorkspacesRoot_ProbeIsWritabilityNotExistence(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("running as root: a 0500 directory is still writable") + } + dir := filepath.Join(t.TempDir(), "ro") + if err := os.Mkdir(dir, 0o500); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := probeWritableDir(dir); err == nil { + t.Fatal("probeWritableDir accepted a non-writable directory") + } + if _, err := os.Stat(filepath.Join(dir, ".write_test")); !os.IsNotExist(err) { + t.Fatal("the probe left .write_test behind") + } +} + +// --- _resolve_repo ----------------------------------------------------------- + +func TestResolveRepo_ExistingDirectoryIsResolvedInPlace(t *testing.T) { + dir := t.TempDir() + got, err := resolveRepo(context.Background(), dir) + if err != nil { + t.Fatalf("resolveRepo: %v", err) + } + if got != util.ResolvePath(dir) { + t.Fatalf("resolveRepo(%q) = %q, want %q", dir, got, util.ResolvePath(dir)) + } +} + +// TestResolveRepo_NonURLNonDirUsesRepoPathEnv pins the final fallback branch. +func TestResolveRepo_NonURLNonDirUsesRepoPathEnv(t *testing.T) { + repo := t.TempDir() + t.Setenv("CLOUDSECURITY_REPO_PATH", repo) + + got, err := resolveRepo(context.Background(), "not-a-path-and-not-a-url") + if err != nil { + t.Fatalf("resolveRepo: %v", err) + } + if got != util.ResolvePath(repo) { + t.Fatalf("resolveRepo() = %q, want %q", got, util.ResolvePath(repo)) + } +} + +// TestResolveRepo_NonURLNonDirFallsBackToCwd pins `os.getenv(..., os.getcwd())`. +func TestResolveRepo_NonURLNonDirFallsBackToCwd(t *testing.T) { + _ = os.Unsetenv("CLOUDSECURITY_REPO_PATH") + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + got, err := resolveRepo(context.Background(), "definitely-not-here") + if err != nil { + t.Fatalf("resolveRepo: %v", err) + } + if got != util.ResolvePath(cwd) { + t.Fatalf("resolveRepo() = %q, want %q", got, util.ResolvePath(cwd)) + } +} + +// TestResolveRepo_ClonesIntoTheWorkspacesRoot drives the http(s)/git@ branch +// against a real local git repository served over a file path URL. It proves +// the target directory layout (/) and that a second call +// reuses the checkout instead of re-cloning. +func TestResolveRepo_ClonesIntoTheWorkspacesRootAndReusesIt(t *testing.T) { + requireGit(t) + + origin := filepath.Join(t.TempDir(), "origin") + initGitRepo(t, origin) + + ws := t.TempDir() + t.Setenv("SEC_AF_WORKSPACES_DIR", ws) + + // resolveRepo only clones for https://, http:// or git@ URLs; a file path + // would take the isdir branch. Use an http:// URL shape that git can + // resolve through the insteadOf rewrite below. + url := "https://example.invalid/owner/origin.git" + rewriteRemote(t, url, origin) + + got, err := resolveRepo(context.Background(), url) + if err != nil { + t.Fatalf("resolveRepo: %v", err) + } + want := filepath.Join(ws, "origin") + if got != want { + t.Fatalf("resolveRepo() = %q, want %q", got, want) + } + if !isDir(filepath.Join(want, ".git")) { + t.Fatal("clone did not produce a git checkout") + } + + // Second call: the target exists, so Python runs `git pull --ff-only` and + // returns the same directory — no re-clone, and a pull failure is ignored. + marker := filepath.Join(want, "marker") + if err := os.WriteFile(marker, []byte("kept"), 0o644); err != nil { + t.Fatalf("write marker: %v", err) + } + again, err := resolveRepo(context.Background(), url) + if err != nil { + t.Fatalf("resolveRepo (reuse): %v", err) + } + if again != want { + t.Fatalf("resolveRepo (reuse) = %q, want %q", again, want) + } + if _, err := os.Stat(marker); err != nil { + t.Fatal("the existing checkout was replaced instead of reused") + } +} + +// TestResolveRepo_CloneFailureIsAValueErrorString pins the message app.py +// raises: `git clone failed: {result.stderr.strip()}`. The remote is rewritten +// (git insteadOf) onto a local path that does not exist, so the failure is +// deterministic and offline. +func TestResolveRepo_CloneFailureIsAValueErrorString(t *testing.T) { + requireGit(t) + + ws := t.TempDir() + t.Setenv("SEC_AF_WORKSPACES_DIR", ws) + + url := "https://example.invalid/owner/nope.git" + rewriteRemote(t, url, filepath.Join(t.TempDir(), "does-not-exist")) + + _, err := resolveRepo(context.Background(), url) + if err == nil { + t.Fatal("expected a clone failure") + } + if !strings.HasPrefix(err.Error(), "git clone failed: ") { + t.Fatalf("error = %q, want the \"git clone failed: \" prefix", err) + } + detail := strings.TrimPrefix(err.Error(), "git clone failed: ") + if detail == "" || detail != strings.TrimSpace(detail) { + t.Fatalf("stderr was not stripped: %q", err) + } +} + +// --- helpers ----------------------------------------------------------------- + +// withDefaultWorkspacesDir redirects the /workspaces probe for one test. +func withDefaultWorkspacesDir(t *testing.T, dir string) { + t.Helper() + previous := defaultWorkspacesDir + defaultWorkspacesDir = dir + t.Cleanup(func() { defaultWorkspacesDir = previous }) +} + +// rewriteRemote makes git resolve url to a local path, so the clone branch of +// _resolve_repo can be exercised without a network. +func rewriteRemote(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") + } +} + +func initGitRepo(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + if err := os.WriteFile(filepath.Join(dir, "main.tf"), []byte("# empty\n"), 0o644); err != nil { + t.Fatalf("write main.tf: %v", err) + } + for _, args := range [][]string{ + {"init", "--initial-branch=main"}, + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "test"}, + {"add", "."}, + {"commit", "-m", "init"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + if out, err := cmd.CombinedOutput(); err != nil { + t.Skipf("git %v failed (%v): %s", args, err, out) + } + } +} + +// TestResolveRepo_PullDeadlineIsNotSwallowed pins the ONE `git pull` outcome +// _resolve_repo does not discard. +// +// `subprocess.run(["git","pull","--ff-only"], …, timeout=60)` returns a +// CompletedProcess for a non-zero exit (check defaults to False, so the stale +// checkout is silently reused) but RAISES subprocess.TimeoutExpired when the +// deadline fires — verified on the repo's own interpreter: +// +// subprocess.run(["sleep","3"], timeout=0.5) -> TimeoutExpired +// +// _resolve_repo is called at app.py:219, one line ABOVE _run_pipeline's `try`, +// so that exception is uncaught and the Python node answers 500. Swallowing it +// in Go audits a checkout as it was N days ago and answers 200 with a full +// CloudSecurityScanResult — a stale audit presented as a current one. +// +// The cancelled context stands in for the fired 60s deadline: both make +// pullCtx.Err() non-nil and both make CommandContext kill the child, which +// Run() reports as an indistinguishable *exec.ExitError. +func TestResolveRepo_PullDeadlineIsNotSwallowed(t *testing.T) { + requireGit(t) + + ws := t.TempDir() + t.Setenv("SEC_AF_WORKSPACES_DIR", ws) + // The pull branch is taken when the target directory already exists. + if err := os.MkdirAll(filepath.Join(ws, "myrepo"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + got, err := resolveRepo(ctx, "https://example.invalid/owner/myrepo.git") + if err == nil { + t.Fatalf("resolveRepo returned %q with no error; a killed pull must "+ + "propagate the way subprocess.TimeoutExpired does", got) + } + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("err = %v, want the context error", err) + } +} + +// TestResolveRepo_PullExitCodeIsStillDiscarded is the other half of the +// contract: `check` defaults to False, so an ordinary FAILED pull (no remote +// configured here, so `git pull` exits non-zero) must still return the existing +// checkout, unchanged. +func TestResolveRepo_PullExitCodeIsStillDiscarded(t *testing.T) { + requireGit(t) + + ws := t.TempDir() + t.Setenv("SEC_AF_WORKSPACES_DIR", ws) + target := filepath.Join(ws, "myrepo") + initGitRepo(t, target) + + got, err := resolveRepo(context.Background(), "https://example.invalid/owner/myrepo.git") + if err != nil { + t.Fatalf("resolveRepo: %v — a non-zero pull exit is discarded in Python", err) + } + if got != target { + t.Fatalf("resolveRepo() = %q, want %q", got, target) + } +} + +// TestResolveRepo_CloneFailureNamesItsCauseWhenGitIsSilent pins the second half +// of `git clone failed: {result.stderr.strip()}`. +// +// Python's subprocess.run raises FileNotFoundError("[Errno 2] No such file or +// directory: 'git'") when the binary is missing, so the 500 body names the +// cause. Go's exec fails BEFORE the child starts, leaving stderr empty — the +// message used to be the content-free "git clone failed: ". The underlying +// error fills in exactly when git wrote nothing, so an ordinary auth/404 +// failure keeps Python's byte-for-byte stderr text (pinned by +// TestResolveRepo_CloneFailureIsAValueErrorString above). +func TestResolveRepo_CloneFailureNamesItsCauseWhenGitIsSilent(t *testing.T) { + ws := t.TempDir() + t.Setenv("SEC_AF_WORKSPACES_DIR", ws) + // An empty PATH makes exec.Command fail to find git at all. + t.Setenv("PATH", filepath.Join(t.TempDir(), "empty")) + + _, err := resolveRepo(context.Background(), "https://example.invalid/owner/nope.git") + if err == nil { + t.Fatal("expected a clone failure") + } + if !strings.HasPrefix(err.Error(), "git clone failed: ") { + t.Fatalf("error = %q, want the \"git clone failed: \" prefix", err) + } + detail := strings.TrimPrefix(err.Error(), "git clone failed: ") + if detail == "" { + t.Fatalf("error = %q — the cause was dropped; Python names the missing binary", err) + } + if !strings.Contains(detail, "git") { + t.Errorf("detail = %q, want it to name the executable", detail) + } +} diff --git a/go/internal/reasoners/inputs.go b/go/internal/reasoners/inputs.go new file mode 100644 index 0000000..8e8404f --- /dev/null +++ b/go/internal/reasoners/inputs.go @@ -0,0 +1,173 @@ +package reasoners + +import "github.com/Agent-Field/cloudsecurity-af/go/internal/afx" + +// inputs.go transcribes the Python router-reasoner signatures — parameter names +// AND default values — into bindable input structs, one per distinct signature. +// FastAPI/agentfield derive each reasoner's request contract from the signature, +// so these structs ARE the wire contract; keeping them here (rather than +// unpacking a map inside every handler) means the parity test can assert the +// key sets and defaults directly. +// +// None of these signatures has a non-zero scalar default, so none of them needs +// the default-seeding UnmarshalJSON that internal/phases' input structs carry. +// The only defaulted parameters are the two `| None = None` ones +// (`drift_report`, `attack_path`), whose Python default is None == a nil Go map. + +// IaCReaderInput is run_iac_reader's signature (reasoners/recon.py): +// +// async def run_iac_reader(repo_path: str) -> dict[str, Any] +type IaCReaderInput struct { + RepoPath string `json:"repo_path"` +} + +// ResourceGraphBuilderInput is run_resource_graph_builder's signature: +// +// async def run_resource_graph_builder(repo_path: str, inventory_path: str) +type ResourceGraphBuilderInput struct { + RepoPath string `json:"repo_path"` + InventoryPath string `json:"inventory_path"` +} + +// CloudConnectorInput is run_cloud_connector's signature: +// +// async def run_cloud_connector(cloud_config: dict[str, Any]) +type CloudConnectorInput struct { + CloudConfig map[string]any `json:"cloud_config"` +} + +// DriftDetectorInput is run_drift_detector's signature: +// +// async def run_drift_detector(iac_graph_path: str, cloud_config: dict[str, Any]) +type DriftDetectorInput struct { + IaCGraphPath string `json:"iac_graph_path"` + CloudConfig map[string]any `json:"cloud_config"` +} + +// HunterInput is the signature all SEVEN hunters share (reasoners/hunt.py): +// +// async def run__hunter(repo_path: str, resource_graph_path: str, +// inventory_path: str, depth: str) +// +// Python parity: `depth` carries NO default here (unlike the phase reasoners), +// so an omitted depth binds to "" and is passed through to the hunter verbatim. +type HunterInput struct { + RepoPath string `json:"repo_path"` + ResourceGraphPath string `json:"resource_graph_path"` + InventoryPath string `json:"inventory_path"` + Depth string `json:"depth"` +} + +// PathConstructorInput is run_path_constructor's signature (reasoners/chain.py): +// +// async def run_path_constructor(findings: list[dict[str, Any]], +// resource_graph_path: str, max_paths: int, +// max_children: int, +// drift_report: dict[str, Any] | None = None) +type PathConstructorInput struct { + Findings []map[string]any `json:"findings"` + ResourceGraphPath string `json:"resource_graph_path"` + MaxPaths int `json:"max_paths"` + MaxChildren int `json:"max_children"` + DriftReport map[string]any `json:"drift_report"` +} + +// ProverInput is the signature run_static_prover and run_live_prover share +// (reasoners/prove.py): +// +// async def run__prover(repo_path: str, finding: dict[str, Any], tier: int, +// attack_path: dict[str, Any] | None = None) +type ProverInput struct { + RepoPath string `json:"repo_path"` + Finding map[string]any `json:"finding"` + Tier int `json:"tier"` + AttackPath map[string]any `json:"attack_path"` +} + +// FixGeneratorInput is run_fix_generator's signature (reasoners/remediate.py): +// +// async def run_fix_generator(repo_path: str, finding: dict[str, Any]) +type FixGeneratorInput struct { + RepoPath string `json:"repo_path"` + Finding map[string]any `json:"finding"` +} + +// --- Python signature transcriptions for the SDK input validation ------------ +// +// HandlerInputFields is the (name, annotation, has-default) triple of each +// parameter, which the Python SDK's _validate_handler_input consumes before the +// coroutine is entered. See internal/afx/handlerinput.go for the full contract. +// +// Required == the Python signature has NO default; Optional == the annotation +// admits None. Only `drift_report` and `attack_path` are `| None = None` here; +// every other parameter of every router reasoner is required and non-nullable. + +// HandlerInputFields is `run_iac_reader(repo_path: str)`. +func (IaCReaderInput) HandlerInputFields() []afx.Field { + return []afx.Field{ + {Name: "repo_path", Type: afx.TypeStr, Required: true}, + } +} + +// HandlerInputFields is `run_resource_graph_builder(repo_path: str, inventory_path: str)`. +func (ResourceGraphBuilderInput) HandlerInputFields() []afx.Field { + return []afx.Field{ + {Name: "repo_path", Type: afx.TypeStr, Required: true}, + {Name: "inventory_path", Type: afx.TypeStr, Required: true}, + } +} + +// HandlerInputFields is `run_cloud_connector(cloud_config: dict[str, Any])`. +func (CloudConnectorInput) HandlerInputFields() []afx.Field { + return []afx.Field{ + {Name: "cloud_config", Type: afx.TypeDict, Required: true}, + } +} + +// HandlerInputFields is `run_drift_detector(iac_graph_path: str, cloud_config: dict[str, Any])`. +func (DriftDetectorInput) HandlerInputFields() []afx.Field { + return []afx.Field{ + {Name: "iac_graph_path", Type: afx.TypeStr, Required: true}, + {Name: "cloud_config", Type: afx.TypeDict, Required: true}, + } +} + +// HandlerInputFields is the signature all seven hunters share. NOTE `depth` +// carries no default here, unlike on the phase reasoners. +func (HunterInput) HandlerInputFields() []afx.Field { + return []afx.Field{ + {Name: "repo_path", Type: afx.TypeStr, Required: true}, + {Name: "resource_graph_path", Type: afx.TypeStr, Required: true}, + {Name: "inventory_path", Type: afx.TypeStr, Required: true}, + {Name: "depth", Type: afx.TypeStr, Required: true}, + } +} + +// HandlerInputFields is run_path_constructor's signature. +func (PathConstructorInput) HandlerInputFields() []afx.Field { + return []afx.Field{ + {Name: "findings", Type: afx.TypeList, Required: true}, + {Name: "resource_graph_path", Type: afx.TypeStr, Required: true}, + {Name: "max_paths", Type: afx.TypeInt, Required: true}, + {Name: "max_children", Type: afx.TypeInt, Required: true}, + {Name: "drift_report", Type: afx.TypeDict, Optional: true}, + } +} + +// HandlerInputFields is the signature run_static_prover and run_live_prover share. +func (ProverInput) HandlerInputFields() []afx.Field { + return []afx.Field{ + {Name: "repo_path", Type: afx.TypeStr, Required: true}, + {Name: "finding", Type: afx.TypeDict, Required: true}, + {Name: "tier", Type: afx.TypeInt, Required: true}, + {Name: "attack_path", Type: afx.TypeDict, Optional: true}, + } +} + +// HandlerInputFields is `run_fix_generator(repo_path: str, finding: dict[str, Any])`. +func (FixGeneratorInput) HandlerInputFields() []afx.Field { + return []afx.Field{ + {Name: "repo_path", Type: afx.TypeStr, Required: true}, + {Name: "finding", Type: afx.TypeDict, Required: true}, + } +} diff --git a/go/internal/reasoners/inputschema.go b/go/internal/reasoners/inputschema.go new file mode 100644 index 0000000..fd1a1a9 --- /dev/null +++ b/go/internal/reasoners/inputschema.go @@ -0,0 +1,145 @@ +package reasoners + +// inputschema.go carries the input schemas the control plane sees for every +// cloudsecurity-af reasoner — the 20 router reasoners registered here AND the +// two top-level ones (`scan`, `prove`) internal/node registers. +// +// WHY A FIXTURE AND NOT A TRANSCRIPTION +// +// The Python SDK derives each reasoner's input schema from the FUNCTION +// SIGNATURE at registration time (Agent._types_to_json_schema, sdk/python/ +// agentfield/agent.py) and publishes it through the control plane's discovery +// API, where `af ls`, the CP UI's run form and tool-calling all read it. The Go +// SDK has no signature to introspect: agent.RegisterReasoner defaults every +// handler to `{"type":"object","additionalProperties":true}` +// (sdk/go/agent/agent_register.go), which tells a caller nothing. Attaching +// agent.WithInputSchema is therefore mandatory for parity, and the only way to +// be sure the bytes match is to take them FROM the Python node rather than +// re-derive them by hand — the Python mapping has quirks a transcription +// reliably gets wrong (see "MAPPING QUIRKS" below). +// +// PROVENANCE +// +// testdata/python_input_schemas.json was captured from a LIVE Python +// cloudsecurity-af node running agentfield==0.1.131, read back off the control +// plane's discovery API with include_input_schema=true. It is keyed by reasoner +// id and holds all 22 entries. +// +// To regenerate it (after a Python signature changes, or on an SDK bump): +// +// # 1. Run the Python node so it registers with a control plane: +// # af run cloudsecurity-af +// # 2. Read the schemas back and reshape into {reasoner_id: input_schema}: +// curl -sG http://localhost:8080/api/v1/discovery/capabilities \ +// --data-urlencode 'agent=cloudsecurity' \ +// --data-urlencode 'include_input_schema=true' | +// python3 -c 'import json,sys +// caps = json.load(sys.stdin)["capabilities"] +// out = {r["id"]: r["input_schema"] for c in caps for r in c["reasoners"]} +// print(json.dumps(out, indent=1, sort_keys=True))' \ +// > internal/reasoners/testdata/python_input_schemas.json +// +// (`agent=` takes the node_id the Python node registers under. Object KEYS are +// sorted by the dump — `required` arrays keep their signature order, which is +// what Python publishes.) +// +// MAPPING QUIRKS worth knowing when reading the fixture, all of them properties +// of Agent._type_to_json_schema: +// +// - str→string, int→integer, float→number, bool→boolean; a bare `dict` +// annotation→object, `dict[str, Any]`→object + additionalProperties:true; +// `list[X]`→array + items:. +// - A pydantic model→its model_json_schema(). None of these signatures takes +// one; every model argument crosses the wire as `dict[str, Any]`. +// - `required` lists exactly the parameters with NO default, in signature +// order. A parameter WITH a default is omitted from `required` and the +// default itself is NOT published. +// - PEP-604 optionals (`str | None`, `list[str] | None`) fall through to the +// `{"type": "object"}` fallback — `types.UnionType` has no `__origin__`, so +// the Union branch that would unwrap to the base type never runs. That is +// why e.g. `scan.commit_sha` and `recon_phase.cloud_config` are typed +// "object" here rather than "string"/"object+additionalProperties". The +// port publishes what Python publishes, quirk included. +// - The schema carries no `additionalProperties` at the top level and no +// `default` keys — Python emits only {type, properties, required}. + +import ( + "bytes" + _ "embed" + "encoding/json" + "fmt" + "sort" +) + +//go:embed testdata/python_input_schemas.json +var pythonInputSchemasJSON []byte + +// pythonInputSchemas is the parsed fixture, one compacted JSON document per +// reasoner id. Parsing at package init means a corrupt fixture fails the +// binary's very first use rather than a later registration. +var pythonInputSchemas = parseInputSchemas(pythonInputSchemasJSON) + +// parseInputSchemas decodes the fixture and compacts each schema so the bytes +// handed to agent.WithInputSchema are wire-shaped rather than the fixture's +// pretty-printed form. Compacting is whitespace-only: the published document is +// semantically identical to the captured one. +func parseInputSchemas(raw []byte) map[string]json.RawMessage { + var parsed map[string]json.RawMessage + if err := json.Unmarshal(raw, &parsed); err != nil { + panic(fmt.Sprintf("reasoners: testdata/python_input_schemas.json is not a JSON object of schemas: %v", err)) + } + if len(parsed) == 0 { + panic("reasoners: testdata/python_input_schemas.json is empty") + } + out := make(map[string]json.RawMessage, len(parsed)) + for name, schema := range parsed { + 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 +} + +// MustInputSchema returns the Python-published input schema for the reasoner +// called name. +// +// It PANICS when the fixture has no entry for name. Every call site is a +// registration — reg() below and internal/node.RegisterAll — so the panic fires +// at process start, on the first `af run`/`go test`, and never mid-execution. +// That is deliberate: a reasoner added in Go without a matching fixture entry +// would otherwise silently ship the SDK's contentless default schema, and +// nothing downstream would complain. Loud beats invisible drift. +func MustInputSchema(name string) json.RawMessage { + schema, ok := LookupInputSchema(name) + if !ok { + panic(fmt.Sprintf("reasoners: no input schema for reasoner %q — regenerate "+ + "internal/reasoners/testdata/python_input_schemas.json from the Python node "+ + "(see inputschema.go)", name)) + } + return schema +} + +// LookupInputSchema returns a COPY of the schema published for name, and +// whether the fixture carries one. The copy keeps a caller (or the SDK, which +// stores the json.RawMessage as-is) from mutating the shared fixture bytes. +func LookupInputSchema(name string) (json.RawMessage, bool) { + schema, ok := pythonInputSchemas[name] + if !ok { + return nil, false + } + return json.RawMessage(append([]byte(nil), schema...)), true +} + +// InputSchemaNames returns every reasoner id the fixture covers, sorted. It is +// the "what Python publishes" side of the drift test in inputschema_test.go, +// which pins it against the registered surface in both directions. +func InputSchemaNames() []string { + out := make([]string, 0, len(pythonInputSchemas)) + for name := range pythonInputSchemas { + out = append(out, name) + } + sort.Strings(out) + return out +} diff --git a/go/internal/reasoners/inputschema_test.go b/go/internal/reasoners/inputschema_test.go new file mode 100644 index 0000000..8a7b43d --- /dev/null +++ b/go/internal/reasoners/inputschema_test.go @@ -0,0 +1,319 @@ +package reasoners_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + sdkagent "github.com/Agent-Field/agentfield/sdk/go/agent" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/reasoners" +) + +// inputschema_test.go is the drift guard on the control-plane input schemas. +// +// The contract, in caller-observable terms: +// +// (a) every reasoner this node registers publishes a schema derived from the +// Python signature, never the SDK's contentless +// {"type":"object","additionalProperties":true} default; +// (b) the fixture and the registered surface cover EXACTLY the same names, in +// both directions — a Go reasoner without a fixture entry and a fixture +// entry without a Go reasoner are both failures; +// (c) for a representative slice of signature shapes (plain scalars, a +// defaulted parameter, a list-of-dicts, a PEP-604 optional) the published +// document is byte-for-byte what the Python node published. +// +// The schemas are read back through GET /discover — the agent's own discovery +// payload, i.e. the same bytes the control plane stores and `af ls` renders — +// so these tests assert what a CALLER sees, not what the fixture file says. + +// pythonTopLevelNames is the surface app.py registers with @app.reasoner(). +// internal/node mounts these two; the router carries the other 20. +var pythonTopLevelNames = []string{"scan", "prove"} + +// --- (b) fixture ↔ registered surface, both directions ------------------------ + +func TestInputSchemas_FixtureCoversExactlyTheRegisteredSurface(t *testing.T) { + registered := append(reasoners.RouterNames(), pythonTopLevelNames...) + sort.Strings(registered) + + if got := reasoners.InputSchemaNames(); !reflect.DeepEqual(got, registered) { + t.Fatalf("fixture covers\n%v\nregistered surface is\n%v\n"+ + "(a name only in the first is a stale fixture entry; a name only in the "+ + "second would ship the SDK's default schema)", got, registered) + } +} + +func TestInputSchemas_EveryRegisteredReasonerHasAFixtureEntry(t *testing.T) { + // RegisterAll's return value is what was ACTUALLY mounted, so this walks the + // live registration sequence rather than the routerNames transcription. + mounted := reasoners.RegisterAll(sdkagent.NewRouter(), &appx.Fake{}) + for _, name := range append(mounted, pythonTopLevelNames...) { + if _, ok := reasoners.LookupInputSchema(name); !ok { + t.Errorf("reasoner %q is registered but has no fixture entry", name) + } + } +} + +func TestMustInputSchema_PanicsOnAReasonerTheFixtureDoesNotCover(t *testing.T) { + // Drift must be loud at registration time, not a silent fallback to the + // SDK's default schema. + defer func() { + if recover() == nil { + t.Fatal("MustInputSchema on an unknown reasoner returned instead of panicking") + } + }() + reasoners.MustInputSchema("run_no_such_reasoner") +} + +func TestLookupInputSchema_ReturnsACopy(t *testing.T) { + first, ok := reasoners.LookupInputSchema(reasoners.NameRunIaCReader) + if !ok { + t.Fatal("run_iac_reader has no fixture entry") + } + first[0] = 'X' + second, _ := reasoners.LookupInputSchema(reasoners.NameRunIaCReader) + if second[0] == 'X' { + t.Fatal("LookupInputSchema hands out the shared fixture bytes; the SDK stores the RawMessage as-is, so a caller could corrupt every later registration") + } +} + +// --- (a) + (c) what the node actually publishes ------------------------------- + +func TestInputSchemas_EveryRouterReasonerPublishesItsPythonSchema(t *testing.T) { + published := discoveredInputSchemas(t, mountRouter(t, &appx.Fake{})) + fixture := readSchemaFixture(t) + + for _, name := range reasoners.RouterNames() { + got, ok := published[name] + if !ok { + t.Errorf("%s: not present in the discovery payload", name) + continue + } + if isSDKDefaultSchema(got) { + t.Errorf("%s: publishes the SDK's default schema, so callers learn nothing about the signature", name) + continue + } + if want := fixture[name]; !reflect.DeepEqual(got, want) { + t.Errorf("%s: published schema\n%s\nwant (Python)\n%s", name, mustIndent(t, got), mustIndent(t, want)) + } + } +} + +// TestInputSchemas_RepresentativeSignaturesMatchPython spells four schemas out +// by hand, transcribed from the Python signatures rather than copied from the +// fixture, so a corrupted or half-regenerated fixture cannot make the test +// above vacuously pass. One reasoner per interesting mapping shape. +func TestInputSchemas_RepresentativeSignaturesMatchPython(t *testing.T) { + published := discoveredInputSchemas(t, mountRouter(t, &appx.Fake{})) + + cases := map[string]string{ + // reasoners/recon.py: async def run_iac_reader(repo_path: str) + // The minimal shape: one required scalar. + reasoners.NameRunIaCReader: `{ + "type": "object", + "properties": {"repo_path": {"type": "string"}}, + "required": ["repo_path"] + }`, + + // reasoners/hunt.py: async def run_compliance_hunter(repo_path: str, + // resource_graph_path: str, inventory_path: str, depth: str) + // `depth` carries NO default here, so it IS required — the difference + // from hunt_phase below is the whole reason both are pinned. + reasoners.NameRunComplianceHunter: `{ + "type": "object", + "properties": { + "repo_path": {"type": "string"}, + "resource_graph_path": {"type": "string"}, + "inventory_path": {"type": "string"}, + "depth": {"type": "string"} + }, + "required": ["repo_path", "resource_graph_path", "inventory_path", "depth"] + }`, + + // reasoners/chain.py: async def run_path_constructor( + // findings: list[dict[str, Any]], resource_graph_path: str, + // max_paths: int, max_children: int, + // drift_report: dict[str, Any] | None = None) + // list[dict[str, Any]] → array + items{object, additionalProperties}; + // the PEP-604 optional collapses to a bare object and drops out of + // `required` (its default is not published). + reasoners.NameRunPathConstructor: `{ + "type": "object", + "properties": { + "findings": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "resource_graph_path": {"type": "string"}, + "max_paths": {"type": "integer"}, + "max_children": {"type": "integer"}, + "drift_report": {"type": "object"} + }, + "required": ["findings", "resource_graph_path", "max_paths", "max_children"] + }`, + + // reasoners/phases.py: async def hunt_phase(repo_path: str, + // resource_graph_path: str, inventory_path: str, + // depth: str = "standard", max_concurrent_hunters: int = 3) + // Both defaulted parameters keep their declared TYPE but leave + // `required`, and neither default value is published. + reasoners.NameHuntPhase: `{ + "type": "object", + "properties": { + "repo_path": {"type": "string"}, + "resource_graph_path": {"type": "string"}, + "inventory_path": {"type": "string"}, + "depth": {"type": "string"}, + "max_concurrent_hunters": {"type": "integer"} + }, + "required": ["repo_path", "resource_graph_path", "inventory_path"] + }`, + } + + for name, want := range cases { + got, ok := published[name] + if !ok { + t.Errorf("%s: not present in the discovery payload", name) + continue + } + if !reflect.DeepEqual(got, mustUnmarshal(t, []byte(want))) { + t.Errorf("%s: published schema\n%s\nwant\n%s", name, mustIndent(t, got), want) + } + } +} + +// --- helpers ------------------------------------------------------------------ + +// discoveredInputSchemas reads the agent's own discovery payload — the bytes +// the control plane records for each reasoner — and returns the input schema +// per reasoner id, decoded into plain Go values so comparison ignores JSON key +// order (array order, e.g. `required`, still counts: Python publishes signature +// order and callers render it). +func discoveredInputSchemas(t *testing.T, app *sdkagent.Agent) map[string]any { + t.Helper() + + rec := httptest.NewRecorder() + app.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/discover", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("GET /discover = %d, body %s", rec.Code, rec.Body.String()) + } + + 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 discovery payload: %v", err) + } + + out := make(map[string]any, len(payload.Reasoners)) + for _, r := range payload.Reasoners { + out[r.ID] = r.InputSchema + } + return out +} + +// readSchemaFixture loads testdata/python_input_schemas.json straight off disk, +// deliberately NOT through the package's embedded copy, so the test compares +// the published bytes against the captured file rather than against itself. +func readSchemaFixture(t *testing.T) map[string]any { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", "python_input_schemas.json")) + if err != nil { + t.Fatalf("read schema fixture: %v", err) + } + var fixture map[string]any + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatalf("decode schema fixture: %v", err) + } + return fixture +} + +// isSDKDefaultSchema reports whether v is the Go SDK's placeholder schema, +// `{"type":"object","additionalProperties":true}` — the thing every reasoner +// published before the fixture was wired in. +func isSDKDefaultSchema(v any) bool { + obj, ok := v.(map[string]any) + if !ok || len(obj) != 2 { + return false + } + return obj["type"] == "object" && obj["additionalProperties"] == true +} + +func mustUnmarshal(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 +} + +func mustIndent(t *testing.T, v any) string { + t.Helper() + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + t.Fatalf("marshal %T: %v", v, err) + } + return string(b) +} + +// --- published contract == enforced contract --------------------------------- + +// TestInputSchemas_PublishedRequiredMatchesTheEnforcedRequired closes the loop +// between the two halves of the request contract, which are declared in +// DIFFERENT places and could drift apart silently: +// +// published — testdata/python_input_schemas.json's `required`, what the +// control plane shows a caller; +// enforced — each input struct's HandlerInputFields, what afx.BindHandlerInput +// rejects a body over (the port of _validate_handler_input). +// +// Python derives both from ONE signature, so they cannot disagree there. Here +// they can. Sending an EMPTY body must therefore be refused, and refused over +// the FIRST name in the published `required` list — Python reports the first +// missing parameter in signature order, and the fixture's `required` preserves +// that order. +func TestInputSchemas_PublishedRequiredMatchesTheEnforcedRequired(t *testing.T) { + fixture := readSchemaFixture(t) + + for _, name := range reasoners.RouterNames() { + t.Run(name, func(t *testing.T) { + schema, ok := fixture[name].(map[string]any) + if !ok { + t.Fatalf("fixture entry for %s is not an object", name) + } + required, _ := schema["required"].([]any) + if len(required) == 0 { + t.Fatalf("%s publishes no required parameters; every cloudsecurity-af "+ + "reasoner has at least one, so this is a broken fixture entry", name) + } + + app := mountRouter(t, &appx.Fake{ + CallFn: func(context.Context, string, map[string]any) (map[string]any, error) { + t.Fatal("the handler must not run for a body missing required parameters") + return nil, nil + }, + }) + _, err := app.Execute(context.Background(), name, map[string]any{}) + if err == nil { + t.Fatalf("%s accepted an empty body but publishes required=%v", name, required) + } + + want := "Missing required field: " + required[0].(string) + if !strings.Contains(err.Error(), want) { + t.Fatalf("%s rejected the empty body with %q, want %q — the published "+ + "schema and HandlerInputFields disagree about what is required", name, err, want) + } + }) + } +} diff --git a/go/internal/reasoners/inputvalidation_test.go b/go/internal/reasoners/inputvalidation_test.go new file mode 100644 index 0000000..208ccaf --- /dev/null +++ b/go/internal/reasoners/inputvalidation_test.go @@ -0,0 +1,360 @@ +package reasoners_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + sdkagent "github.com/Agent-Field/agentfield/sdk/go/agent" + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" +) + +// harnessReply is appx.HarnessJSON with a constant body. +func harnessReply(body string) 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(body), nil + }) +} + +// VALIDATION CONTRACT — the reasoner request body. +// +// The Python SDK validates every body against the reasoner's SIGNATURE before +// the coroutine runs and answers 422 on a violation +// (Agent._validate_handler_input, rendered at agent.py:2120-2128). The Go SDK +// does no such check, so internal/afx ports it and internal/reasoners' `reg` +// runs it. Every expectation below was produced by executing the REAL +// `Agent._validate_handler_input(body, fields)` from +// /home/abir/agentfield/sdk/python against the transcribed signatures: +// +// run_static_prover {"repo_path":"/r","finding":{},"tier":"2"} +// -> OK, tier == 2 (int("2")) +// run_static_prover {"repo_path":"/r","finding":{}} +// -> "Missing required field: tier" +// run_static_prover {"repo_path":"/r","finding":"nope","tier":1} +// -> "Field 'finding' must be a dict" +// hunt_phase {...,"max_concurrent_hunters":"4"} -> OK, 4 +// hunt_phase {...,"depth":3} -> OK, depth == "3" (str(3)) +// hunt_phase {"resource_graph_path":"/g"} -> "Missing required field: repo_path" +// run_iam_hunter {repo/graph/inventory paths} -> "Missing required field: depth" +// (depth carries NO default on the hunters, unlike on the phases) +// run_iam_hunter {"repo_path":123,...} -> OK, repo_path == "123" + +// executeReasoner runs one reasoner against a router mounted on a real agent. +func executeReasoner(t *testing.T, fake *appx.Fake, name string, body map[string]any) (any, error) { + t.Helper() + if fake == nil { + fake = &appx.Fake{} + } + app := mountRouter(t, fake) + return app.Execute(context.Background(), name, body) +} + +func TestReasonerInputs_MissingRequiredParameterIs422(t *testing.T) { + cases := []struct { + reasoner string + body map[string]any + want string + }{ + {"run_iac_reader", map[string]any{}, "Missing required field: repo_path"}, + {"run_resource_graph_builder", map[string]any{"repo_path": "/r"}, "Missing required field: inventory_path"}, + {"run_cloud_connector", map[string]any{}, "Missing required field: cloud_config"}, + {"run_drift_detector", map[string]any{"cloud_config": map[string]any{}}, "Missing required field: iac_graph_path"}, + { + "run_iam_hunter", + map[string]any{"repo_path": "/r", "resource_graph_path": "/g", "inventory_path": "/i"}, + "Missing required field: depth", + }, + { + "run_static_prover", + map[string]any{"repo_path": "/r", "finding": map[string]any{}}, + "Missing required field: tier", + }, + {"run_fix_generator", map[string]any{"repo_path": "/r"}, "Missing required field: finding"}, + { + "run_path_constructor", + map[string]any{"resource_graph_path": "/g", "max_paths": 5, "max_children": 3}, + "Missing required field: findings", + }, + {"recon_phase", map[string]any{"depth": "quick"}, "Missing required field: repo_path"}, + {"hunt_phase", map[string]any{"resource_graph_path": "/g"}, "Missing required field: repo_path"}, + {"chain_phase", map[string]any{"resource_graph_path": "/g"}, "Missing required field: findings"}, + { + "prove_phase", + map[string]any{"repo_path": "/r", "hunt_result": map[string]any{}}, + "Missing required field: chain_result", + }, + {"remediation_phase", map[string]any{"repo_path": "/r"}, "Missing required field: verified_findings"}, + } + + for _, tc := range cases { + t.Run(tc.reasoner, func(t *testing.T) { + fake := &appx.Fake{ + HarnessFn: func(context.Context, string, map[string]any, any, harness.Options) (*harness.Result, error) { + t.Fatal("the handler must not run for an invalid body") + return nil, nil + }, + CallFn: func(context.Context, string, map[string]any) (map[string]any, error) { + t.Fatal("the handler must not run for an invalid body") + return nil, nil + }, + } + _, err := executeReasoner(t, fake, tc.reasoner, tc.body) + if err == nil { + t.Fatalf("%s accepted a body Python answers 422 to", tc.reasoner) + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %q, want %q", err, tc.want) + } + var execErr *sdkagent.ExecuteError + if !errors.As(err, &execErr) { + t.Fatalf("error is %T, want *agent.ExecuteError so the status reaches the caller", err) + } + if execErr.StatusCode != http.StatusUnprocessableEntity { + t.Fatalf("status = %d, want 422", execErr.StatusCode) + } + }) + } +} + +func TestReasonerInputs_WrongContainerTypeIs422(t *testing.T) { + cases := []struct { + reasoner string + body map[string]any + want string + }{ + { + "run_static_prover", + map[string]any{"repo_path": "/r", "finding": "nope", "tier": 1}, + "Field 'finding' must be a dict", + }, + { + "run_cloud_connector", + map[string]any{"cloud_config": []any{"aws"}}, + "Field 'cloud_config' must be a dict", + }, + { + "chain_phase", + map[string]any{"findings": map[string]any{}, "resource_graph_path": "/g"}, + "Field 'findings' must be a list", + }, + { + "remediation_phase", + map[string]any{"repo_path": "/r", "verified_findings": "none"}, + "Field 'verified_findings' must be a list", + }, + } + for _, tc := range cases { + t.Run(tc.reasoner, func(t *testing.T) { + _, err := executeReasoner(t, nil, tc.reasoner, tc.body) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want %q", err, tc.want) + } + }) + } +} + +// The mirror image: bodies the Python node ACCEPTS because the SDK coerces them. +// Rejecting these turned working callers (a CP UI form field, a loosely typed +// agent) into failed executions. +func TestReasonerInputs_CoercesScalarsThePythonSDKCoerces(t *testing.T) { + t.Run("run_static_prover tier as a string", func(t *testing.T) { + fake := &appx.Fake{ + HarnessFn: harnessReply(`{"title":"t","verdict":"confirmed","severity":"high","category":"c"}`), + } + app := mountRouter(t, fake) + _, err := app.Execute(context.Background(), "run_static_prover", map[string]any{ + "repo_path": "/r", + "finding": map[string]any{ + "hunter_strategy": "iam", "title": "t", "description": "d", "category": "c", + }, + "tier": "2", + }) + if err != nil { + t.Fatalf("run_static_prover rejected tier \"2\", which Python coerces to 2: %v", err) + } + // The prompt is the only place tier is observable from outside; assert + // the call simply succeeded and the harness ran. + if len(fake.Harnesses) != 1 { + t.Fatalf("harness calls = %d, want 1", len(fake.Harnesses)) + } + }) + + t.Run("hunt_phase max_concurrent_hunters as a string", func(t *testing.T) { + fake := &appx.Fake{CallFn: func(context.Context, string, map[string]any) (map[string]any, error) { + return map[string]any{"findings": []any{}}, nil + }} + app := mountRouter(t, fake) + if _, err := app.Execute(context.Background(), "hunt_phase", map[string]any{ + "repo_path": "/r", + "resource_graph_path": "/g", + "inventory_path": "/i", + "max_concurrent_hunters": "4", + }); err != nil { + t.Fatalf("hunt_phase rejected max_concurrent_hunters \"4\", which Python coerces to 4: %v", err) + } + }) + + t.Run("a hunter path as a number", func(t *testing.T) { + fake := &appx.Fake{HarnessFn: harnessReply(`{"findings":[]}`)} + app := mountRouter(t, fake) + if _, err := app.Execute(context.Background(), "run_iam_hunter", map[string]any{ + "repo_path": 123, + "resource_graph_path": "/g", + "inventory_path": "/i", + "depth": "quick", + }); err != nil { + t.Fatalf("run_iam_hunter rejected repo_path 123, which Python renders as \"123\": %v", err) + } + if got := fake.Harnesses[0].Prompt; !strings.Contains(got, "123") { + t.Errorf("the coerced repo_path did not reach the prompt") + } + }) + + t.Run("recon_phase depth as a number", func(t *testing.T) { + // Python: str(3) == "3", which _normalize_depth then folds to the + // standard profile — an accepted request either way. + fake := &appx.Fake{CallFn: func(_ context.Context, target string, _ map[string]any) (map[string]any, error) { + switch { + case strings.HasSuffix(target, ".run_iac_reader"): + return map[string]any{"inventory_saved_path": "/tmp/inv.json"}, nil + default: + return map[string]any{"graph_saved_path": "/tmp/graph.json"}, nil + } + }} + app := mountRouter(t, fake) + if _, err := app.Execute(context.Background(), "recon_phase", map[string]any{ + "repo_path": "/r", + "depth": 3, + }); err != nil { + t.Fatalf("recon_phase rejected depth 3, which Python renders as \"3\": %v", err) + } + }) +} + +// Python's `result = {}` drops undeclared keys, so an extra key can never reach +// the handler in either node. +func TestReasonerInputs_UndeclaredKeysAreDropped(t *testing.T) { + fake := &appx.Fake{HarnessFn: harnessReply(`{"inventory_saved_path":"/tmp/inv.json"}`)} + app := mountRouter(t, fake) + if _, err := app.Execute(context.Background(), "run_iac_reader", map[string]any{ + "repo_path": "/r", + "unexpected": map[string]any{"deep": 1}, + "another_one": 5, + }); err != nil { + t.Fatalf("run_iac_reader rejected a body with extra keys: %v", err) + } +} + +// TestReasonerInputs_ChainPhaseForwardsNonObjectFindingsToTheChild pins the one +// list parameter Python does NOT bind. +// +// `chain_phase(findings: list[dict[str, Any]], ...)` hands `findings` straight +// to run_path_constructor (src/cloudsecurity_af/reasoners/phases.py:225-243), +// and the SDK's Agent._validate_handler_input only checks `isinstance(value, +// list)` for a `list[...]` annotation — never the element type. Verified in the +// repo venv with `_runtime_router` faked: +// +// await chain_phase(findings=[1, 2, "x"], resource_graph_path="/g") +// -> CALLS [('cloudsecurity.run_path_constructor', +// {'findings': [1, 2, 'x'], 'resource_graph_path': '/g', +// 'max_paths': 15, 'max_children': 3, 'drift_report': None})] +// -> RAISED RuntimeError run_path_constructor failed: boom +// +// So the parent must reach the child (one DAG edge, a FAILED child node) and +// surface the child's relayed error — not fail its own bind with a 422 and no +// child at all. +func TestReasonerInputs_ChainPhaseForwardsNonObjectFindingsToTheChild(t *testing.T) { + var captured map[string]any + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, in map[string]any) (map[string]any, error) { + captured = in + return map[string]any{"status": "error", "error_message": "boom"}, nil + }} + app := mountRouter(t, fake) + + _, err := app.Execute(context.Background(), "chain_phase", map[string]any{ + "findings": []any{1, 2, "x"}, + "resource_graph_path": "/g", + }) + if err == nil { + t.Fatal("chain_phase succeeded; Python relays the child's failure") + } + if got := err.Error(); !strings.Contains(got, "run_path_constructor failed: boom") { + t.Fatalf("err = %q, want the relayed child failure; a bind error here means\n"+ + "the parent rejected the list Python forwards verbatim", got) + } + if got := fake.CallTargets(); len(got) != 1 { + t.Fatalf("child calls = %v, want exactly one run_path_constructor edge", got) + } + b, mErr := json.Marshal(captured["findings"]) + if mErr != nil { + t.Fatalf("marshal forwarded findings: %v", mErr) + } + if string(b) != `[1,2,"x"]` { + t.Fatalf("findings forwarded as %s, want [1,2,\"x\"] verbatim", b) + } +} + +// TestReasonerInputs_ProverBindsTheFindingWithPydanticsLaxRules pins the +// `RawFinding.model_validate(finding)` at src/cloudsecurity_af/reasoners/ +// prove.py:22 — the boundary a caller-supplied finding dict crosses. +// +// pydantic v2 validates in LAX mode, so it coerces scalars encoding/json +// refuses and rejects an explicit null for a field that is not `X | None`. +// Measured in the repo venv (pydantic 2.13.4): +// +// RawFinding.model_validate({..., "iac_line": "12"}) -> OK, iac_line == 12 +// RawFinding.model_validate({..., "resources": None}) -> ValidationError +func TestReasonerInputs_ProverBindsTheFindingWithPydanticsLaxRules(t *testing.T) { + finding := func(extra map[string]any) map[string]any { + out := map[string]any{ + "hunter_strategy": "iam", "title": "t", "description": "d", "category": "c", + } + for k, v := range extra { + out[k] = v + } + return out + } + + t.Run("a stringified int is coerced, not rejected", func(t *testing.T) { + fake := &appx.Fake{ + HarnessFn: harnessReply(`{"title":"t","verdict":"confirmed","severity":"high","category":"c"}`), + } + app := mountRouter(t, fake) + if _, err := app.Execute(context.Background(), "run_static_prover", map[string]any{ + "repo_path": "/r", + "finding": finding(map[string]any{"iac_line": "12"}), + "tier": 1, + }); err != nil { + t.Fatalf("run_static_prover rejected iac_line \"12\", which pydantic coerces to 12: %v", err) + } + if len(fake.Harnesses) != 1 { + t.Fatalf("harness calls = %d, want 1 — the prover never ran", len(fake.Harnesses)) + } + }) + + t.Run("a null for a non-Optional field is rejected", func(t *testing.T) { + fake := &appx.Fake{ + HarnessFn: harnessReply(`{"title":"t","verdict":"confirmed","severity":"high","category":"c"}`), + } + app := mountRouter(t, fake) + _, err := app.Execute(context.Background(), "run_static_prover", map[string]any{ + "repo_path": "/r", + "finding": finding(map[string]any{"resources": nil}), + "tier": 1, + }) + if err == nil { + t.Fatal("run_static_prover accepted resources=null; pydantic raises list_type") + } + if got := err.Error(); !strings.Contains(got, "resources") { + t.Fatalf("err = %q, want the offending field named", got) + } + if len(fake.Harnesses) != 0 { + t.Fatalf("harness ran %d time(s); the bind must fail first", len(fake.Harnesses)) + } + }) +} diff --git a/go/internal/reasoners/names.go b/go/internal/reasoners/names.go new file mode 100644 index 0000000..dbb1a64 --- /dev/null +++ b/go/internal/reasoners/names.go @@ -0,0 +1,115 @@ +package reasoners + +// Control-plane registration names of the cloudsecurity-af reasoner surface. +// +// The 20 ROUTER reasoners are the ones src/cloudsecurity_af/reasoners/*.py +// decorate with @router.reasoner(); the two TOP-LEVEL reasoners (scan, prove) +// are decorated with @app.reasoner() in src/cloudsecurity_af/app.py and are +// registered by internal/node, not by this package. +// +// These constants are the single source of truth for the DAG's node names: +// internal/phases builds every `app.Call` target as `nodeID + "." + `, so +// a rename here that is not mirrored there breaks the graph. Two test families +// guard that: +// +// - reasoners_test.go pins the full ordered registration list +// (TestRouterNames_MatchPythonRegistrationOrder) and asserts the targets the +// phase handlers actually emit through appx.Fake.CallTargets(). +// - internal/phases/{recon,hunt,chain,prove,remediate}_test.go assert the same +// targets from the phase side. +// +// Both families spell the targets as string LITERALS (e.g. +// testNodeID+".run_iac_reader"), never as these constants, so they catch a +// change to a constant's VALUE — the thing that breaks the DAG — but not a +// rename of the Go identifier, which the compiler catches instead. +const ( + // --- reasoners/recon.py ------------------------------------------------- + NameRunIaCReader = "run_iac_reader" + NameRunResourceGraphBuilder = "run_resource_graph_builder" + NameRunCloudConnector = "run_cloud_connector" + NameRunDriftDetector = "run_drift_detector" + + // --- reasoners/hunt.py -------------------------------------------------- + NameRunIAMHunter = "run_iam_hunter" + NameRunNetworkHunter = "run_network_hunter" + NameRunDataHunter = "run_data_hunter" + NameRunSecretsHunter = "run_secrets_hunter" + NameRunComputeHunter = "run_compute_hunter" + NameRunLoggingHunter = "run_logging_hunter" + NameRunComplianceHunter = "run_compliance_hunter" + + // --- reasoners/chain.py ------------------------------------------------- + NameRunPathConstructor = "run_path_constructor" + + // --- reasoners/prove.py ------------------------------------------------- + NameRunStaticProver = "run_static_prover" + NameRunLiveProver = "run_live_prover" + + // --- reasoners/remediate.py --------------------------------------------- + NameRunFixGenerator = "run_fix_generator" + + // --- reasoners/phases.py ------------------------------------------------ + NameReconPhase = "recon_phase" + NameHuntPhase = "hunt_phase" + NameChainPhase = "chain_phase" + NameProvePhase = "prove_phase" + NameRemediationPhase = "remediation_phase" + + // --- app.py (registered by internal/node) -------------------------------- + NameScan = "scan" + NameProve = "prove" +) + +// routerTags ports `AgentRouter(tags=["cloud", "security", "infrastructure"])` +// in src/cloudsecurity_af/reasoners/__init__.py. +// +// They are SEMANTIC domain tags, not node-identity tags: node identity is +// carried by node_id=cloudsecurity, so callers reach cloudsecurity.scan. +var routerTags = []string{"cloud", "security", "infrastructure"} + +// Tags returns a fresh copy of the router's tag set, for +// agent.RouterOptions{Tags: reasoners.Tags()} at the IncludeRouter site (the Go +// SDK carries router tags on the mount, where Python carries them on the +// AgentRouter constructor). +func Tags() []string { + return append([]string(nil), routerTags...) +} + +// routerNames is the registration ORDER of the 20 router reasoners, which in +// Python is decided by the import order in reasoners/__init__.py +// +// from . import recon, hunt, chain, prove, remediate, phases +// +// combined with the top-to-bottom decorator order inside each module. +var routerNames = []string{ + NameRunIaCReader, + NameRunResourceGraphBuilder, + NameRunCloudConnector, + NameRunDriftDetector, + + NameRunIAMHunter, + NameRunNetworkHunter, + NameRunDataHunter, + NameRunSecretsHunter, + NameRunComputeHunter, + NameRunLoggingHunter, + NameRunComplianceHunter, + + NameRunPathConstructor, + + NameRunStaticProver, + NameRunLiveProver, + + NameRunFixGenerator, + + NameReconPhase, + NameHuntPhase, + NameChainPhase, + NameProvePhase, + NameRemediationPhase, +} + +// RouterNames returns a fresh copy of the ordered router-reasoner name list. +func RouterNames() []string { + return append([]string(nil), routerNames...) +} diff --git a/go/internal/reasoners/reasoners.go b/go/internal/reasoners/reasoners.go new file mode 100644 index 0000000..2bd23a7 --- /dev/null +++ b/go/internal/reasoners/reasoners.go @@ -0,0 +1,302 @@ +// Package reasoners ports src/cloudsecurity_af/reasoners/ — the AgentRouter and +// the 20 `@router.reasoner()` functions it carries (recon.py, hunt.py, chain.py, +// prove.py, remediate.py, phases.py). +// +// Every Python reasoner in that tree is a THIN adapter: it binds its dict +// arguments into pydantic models, calls one function in +// cloudsecurity_af.agents.* (or one phase body in phases.py), and returns +// `result.model_dump()`. This package is the same thin layer over +// internal/agents/* and internal/phases, so the control-plane surface and the +// business logic stay in separate packages exactly as they are in Python. +// +// RETURN SHAPE: every handler returns an afx.Payload, the Go stand-in for that +// model_dump() dict. It keeps pydantic's FIELD-DECLARATION order (which +// json.dumps preserves and a Go map does not) and renders floats the Python way +// (`"risk_score": 0.0`, not `0`). See internal/afx/payload.go. +// +// NOTES: none of the 20 router reasoners emits an app.note()/router.note() — +// verified by grep over src/cloudsecurity_af/reasoners/. Neither do the phase +// bodies. The only note-shaped signal in the Python node is +// ScanOrchestrator._emit_progress, which builds a ScanProgress and then does +// NOT emit it (see internal/orch). So no Go handler here calls App.Note either. +// +// ERRORS: Python lets every exception escape the reasoner (there is no +// try/except in reasoners/*.py outside hunt_phase's per-hunter guard and +// prove/remediation_phase's per-item guards, all of which live in +// internal/phases). A Go handler therefore returns its error unchanged and lets +// the SDK render it as a failed execution — which is precisely what the strict +// afx.UnwrapStrict on the calling side turns back into an error. +package reasoners + +import ( + "context" + "errors" + + "github.com/Agent-Field/agentfield/sdk/go/agent" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/afx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/agents/chain" + "github.com/Agent-Field/cloudsecurity-af/go/internal/agents/hunt" + "github.com/Agent-Field/cloudsecurity-af/go/internal/agents/prove" + "github.com/Agent-Field/cloudsecurity-af/go/internal/agents/recon" + "github.com/Agent-Field/cloudsecurity-af/go/internal/agents/remediate" + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/phases" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" +) + +// RegisterAll registers the 20 router reasoners on r in the order +// reasoners/__init__.py imports them (see routerNames), and returns that +// ordered name list so the caller can record the surface it just mounted. +// +// app is the single capability seam every handler closes over — Python's +// `_runtime_router`, the AgentRouter that proxies .harness()/.call() to the +// Agent. The harness-driven agent reasoners need only appx.Harnesser and the +// five phase reasoners need only appx.Caller; taking the full appx.App here +// keeps ONE parameter for the caller (node.BuildAgent passes the live +// *agent.Agent) while each handler still narrows to what it uses. +// +// Tags are NOT applied per reasoner: Python attaches them to the AgentRouter +// itself, and the Go SDK's equivalent is agent.RouterOptions{Tags: ...} at the +// IncludeRouter call site. Use reasoners.Tags() there. +func RegisterAll(r *agent.Router, app appx.App) []string { + // rg records what is ACTUALLY mounted, in call order, so the returned list + // cannot drift from the reg() sequence below; the parity test compares it + // against routerNames, the independent transcription of Python's order. + rg := ®istrar{router: r} + + // --- reasoners/recon.py ------------------------------------------------- + reg(rg, NameRunIaCReader, func(ctx context.Context, in IaCReaderInput) (any, error) { + // Python: result = await _run_iac_reader(runtime_router, repo_path) + // return result.model_dump() + result, err := recon.RunIaCReader(ctx, app, in.RepoPath) + if err != nil { + return nil, err + } + return afx.Dump(result) + }) + + reg(rg, NameRunResourceGraphBuilder, func(ctx context.Context, in ResourceGraphBuilderInput) (any, error) { + result, err := recon.RunResourceGraphBuilder(ctx, app, in.RepoPath, in.InventoryPath) + if err != nil { + return nil, err + } + return afx.Dump(result) + }) + + reg(rg, NameRunCloudConnector, func(ctx context.Context, in CloudConnectorInput) (any, error) { + result, err := recon.RunCloudConnector(ctx, app, in.CloudConfig) + if err != nil { + return nil, err + } + return afx.Dump(result) + }) + + reg(rg, NameRunDriftDetector, func(ctx context.Context, in DriftDetectorInput) (any, error) { + result, err := recon.RunDriftDetector(ctx, app, in.IaCGraphPath, in.CloudConfig) + if err != nil { + return nil, err + } + return afx.Dump(result) + }) + + // --- reasoners/hunt.py -------------------------------------------------- + // Python routes all seven through the shared `_run_hunter(runner, ...)` + // helper; hunterHandler is that helper, with the runner bound per reasoner. + reg(rg, NameRunIAMHunter, hunterHandler(app, hunt.RunIAMHunter)) + reg(rg, NameRunNetworkHunter, hunterHandler(app, hunt.RunNetworkHunter)) + reg(rg, NameRunDataHunter, hunterHandler(app, hunt.RunDataHunter)) + reg(rg, NameRunSecretsHunter, hunterHandler(app, hunt.RunSecretsHunter)) + reg(rg, NameRunComputeHunter, hunterHandler(app, hunt.RunComputeHunter)) + reg(rg, NameRunLoggingHunter, hunterHandler(app, hunt.RunLoggingHunter)) + reg(rg, NameRunComplianceHunter, hunterHandler(app, hunt.RunComplianceHunter)) + + // --- reasoners/chain.py ------------------------------------------------- + reg(rg, NameRunPathConstructor, func(ctx context.Context, in PathConstructorInput) (any, error) { + // Python: + // finding_models = [RawFinding.model_validate(f) for f in findings] + // drift_model = DriftReport.model_validate(drift_report) if drift_report is not None else None + findings, err := bindEach[schemas.RawFinding](in.Findings) + if err != nil { + return nil, err + } + drift, err := bindOptional[schemas.DriftReport](in.DriftReport) + if err != nil { + return nil, err + } + result, err := chain.RunPathConstructor(ctx, app, findings, in.ResourceGraphPath, in.MaxPaths, in.MaxChildren, drift) + if err != nil { + return nil, err + } + return afx.Dump(result) + }) + + // --- reasoners/prove.py ------------------------------------------------- + reg(rg, NameRunStaticProver, proverHandler(app, prove.RunStaticProver)) + reg(rg, NameRunLiveProver, proverHandler(app, prove.RunLiveProver)) + + // --- reasoners/remediate.py --------------------------------------------- + reg(rg, NameRunFixGenerator, func(ctx context.Context, in FixGeneratorInput) (any, error) { + // Python: finding_model = VerifiedFinding.model_validate(finding) + finding, err := afx.Bind[schemas.VerifiedFinding](in.Finding) + if err != nil { + return nil, err + } + result, err := remediate.RunFixGenerator(ctx, app, in.RepoPath, finding) + if err != nil { + return nil, err + } + return afx.Dump(result) + }) + + // --- reasoners/phases.py ------------------------------------------------ + // The five phase bodies already return the exact map Python's model_dump() + // (or literal dict) produces, so their handlers only bind + forward. NodeID + // is read per call, matching internal/phases' documented divergence from + // Python's import-time capture. + reg(rg, NameReconPhase, func(ctx context.Context, in phases.ReconPhaseInput) (any, error) { + return in.Run(ctx, app, phases.NodeID()) + }) + reg(rg, NameHuntPhase, func(ctx context.Context, in phases.HuntPhaseInput) (any, error) { + return in.Run(ctx, app, phases.NodeID()) + }) + reg(rg, NameChainPhase, func(ctx context.Context, in phases.ChainPhaseInput) (any, error) { + return in.Run(ctx, app, phases.NodeID()) + }) + reg(rg, NameProvePhase, func(ctx context.Context, in phases.ProvePhaseInput) (any, error) { + return in.Run(ctx, app, phases.NodeID()) + }) + reg(rg, NameRemediationPhase, func(ctx context.Context, in phases.RemediationPhaseInput) (any, error) { + return in.Run(ctx, app, phases.NodeID()) + }) + + return rg.names +} + +// registrar is the router plus the ordered record of what was registered on it. +// agent.Router keeps its entries unexported, so the bookkeeping lives here. +type registrar struct { + router *agent.Router + names []string +} + +// reg adapts a typed handler to the SDK's HandlerFunc by afx.BindHandlerInput-ing +// the request map into T, and registers it on rg.router, recording the name. +// +// BindHandlerInput, not plain Bind: the Python SDK validates every reasoner body +// against the signature before the coroutine runs +// (Agent._validate_handler_input, rendered as HTTP 422), and the Go SDK does +// not — it never reads a reasoner's InputSchema at execute time. Without that +// step the port diverged in BOTH directions: it accepted `run_iam_hunter {}` +// (binding every path to "") where Python answers "Missing required field: +// repo_path", and it rejected `tier: "2"` / `max_concurrent_hunters: "4"`, +// which Python coerces with int(). T's HandlerInputFields is the transcription +// of the signature; a T without one binds unvalidated. +// +// An afx.InputError is surfaced with Python's 422; any other bind failure stays +// a plain error, which the SDK renders as a failed execution. +// +// reg is also the ONE place the control-plane input schema is attached. Python +// derives it from the reasoner's signature; the Go SDK would otherwise publish +// the contentless `{"type":"object","additionalProperties":true}` default, so +// every registration carries MustInputSchema(name) — the schema the live Python +// node published for that same reasoner. Routing it through reg rather than +// spelling an option out at 20 call sites means a reasoner CANNOT be added here +// without one: a name the fixture does not cover panics on registration. +func reg[T any](rg *registrar, name string, fn func(context.Context, T) (any, error)) { + rg.names = append(rg.names, name) + rg.router.RegisterReasoner(name, func(ctx context.Context, input map[string]any) (any, error) { + in, err := afx.BindHandlerInput[T](input) + if err != nil { + var inputErr *afx.InputError + if errors.As(err, &inputErr) { + return nil, inputErr.ExecuteError() + } + return nil, err + } + return fn(ctx, in) + }, agent.WithInputSchema(MustInputSchema(name))) +} + +// hunterFunc is the shared shape of the seven internal/agents/hunt entry points +// — the Go analogue of the `runner` parameter of hunt.py's `_run_hunter`. +type hunterFunc func(ctx context.Context, app appx.Harnesser, repoPath, resourceGraphPath, inventoryPath, depth string) (schemas.HuntResult, error) + +// hunterHandler ports hunt.py's shared body: +// +// result = await runner(app=_runtime_router, repo_path=..., resource_graph_path=..., +// inventory_path=..., depth=...) +// return result.model_dump() +func hunterHandler(app appx.Harnesser, runner hunterFunc) func(context.Context, HunterInput) (any, error) { + return func(ctx context.Context, in HunterInput) (any, error) { + result, err := runner(ctx, app, in.RepoPath, in.ResourceGraphPath, in.InventoryPath, in.Depth) + if err != nil { + return nil, err + } + return afx.Dump(result) + } +} + +// proverFunc is the shared shape of RunStaticProver / RunLiveProver. +// +// Python parity: prove.py calls `_run__prover(router, repo_path, +// finding_model, attack_path_model, tier)` — attack_path BEFORE tier. The Go +// agents package deliberately puts attackPath last (it is the optional one); +// the argument VALUES are identical either way. +type proverFunc func(ctx context.Context, app appx.Harnesser, repoPath string, finding schemas.RawFinding, tier int, attackPath *schemas.AttackPath) (schemas.VerifiedFinding, error) + +// proverHandler ports the body prove.py's two reasoners share: +// +// finding_model = RawFinding.model_validate(finding) +// attack_path_model = AttackPath.model_validate(attack_path) if attack_path is not None else None +// result = await _run__prover(_runtime_router, repo_path, finding_model, attack_path_model, tier) +// return result.model_dump() +func proverHandler(app appx.Harnesser, runner proverFunc) func(context.Context, ProverInput) (any, error) { + return func(ctx context.Context, in ProverInput) (any, error) { + finding, err := afx.Bind[schemas.RawFinding](in.Finding) + if err != nil { + return nil, err + } + attackPath, err := bindOptional[schemas.AttackPath](in.AttackPath) + if err != nil { + return nil, err + } + result, err := runner(ctx, app, in.RepoPath, finding, in.Tier, attackPath) + if err != nil { + return nil, err + } + return afx.Dump(result) + } +} + +// bindEach ports `[Model.model_validate(x) for x in xs]`: a bind failure on any +// element aborts the whole reasoner, exactly as the list comprehension does. +// An empty input yields an empty (non-nil) slice, like Python's []. +func bindEach[T any](xs []map[string]any) ([]T, error) { + out := make([]T, 0, len(xs)) + for _, x := range xs { + v, err := afx.Bind[T](x) + if err != nil { + return nil, err + } + out = append(out, v) + } + return out, nil +} + +// bindOptional ports `Model.model_validate(x) if x is not None else None`. +// +// Python parity: the guard is `is not None`, NOT truthiness — an EMPTY dict is +// not None, so it is validated into a fully defaulted model rather than +// collapsing to None. A nil Go map is the None case; a non-nil empty map takes +// the validate branch, matching. +func bindOptional[T any](x map[string]any) (*T, error) { + if x == nil { + return nil, nil + } + v, err := afx.Bind[T](x) + if err != nil { + return nil, err + } + return &v, nil +} diff --git a/go/internal/reasoners/reasoners_test.go b/go/internal/reasoners/reasoners_test.go new file mode 100644 index 0000000..32887dc --- /dev/null +++ b/go/internal/reasoners/reasoners_test.go @@ -0,0 +1,764 @@ +package reasoners_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + sdkagent "github.com/Agent-Field/agentfield/sdk/go/agent" + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/cloudsecurity-af/go/internal/afx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/appx" + "github.com/Agent-Field/cloudsecurity-af/go/internal/prompts" + "github.com/Agent-Field/cloudsecurity-af/go/internal/reasoners" + "github.com/Agent-Field/cloudsecurity-af/go/internal/schemas" + "github.com/Agent-Field/cloudsecurity-af/go/internal/scoring" +) + +// pythonRouterNames is the ordered surface Python registers on the +// AgentRouter, read straight off src/cloudsecurity_af/reasoners/: the +// __init__.py import order (recon, hunt, chain, prove, remediate, phases) and +// the decorator order inside each module. +var pythonRouterNames = []string{ + "run_iac_reader", + "run_resource_graph_builder", + "run_cloud_connector", + "run_drift_detector", + "run_iam_hunter", + "run_network_hunter", + "run_data_hunter", + "run_secrets_hunter", + "run_compute_hunter", + "run_logging_hunter", + "run_compliance_hunter", + "run_path_constructor", + "run_static_prover", + "run_live_prover", + "run_fix_generator", + "recon_phase", + "hunt_phase", + "chain_phase", + "prove_phase", + "remediation_phase", +} + +// --- registration parity ----------------------------------------------------- + +func TestRouterNames_MatchPythonRegistrationOrder(t *testing.T) { + if got := reasoners.RouterNames(); !reflect.DeepEqual(got, pythonRouterNames) { + t.Fatalf("RouterNames() =\n%v\nwant\n%v", got, pythonRouterNames) + } + if len(pythonRouterNames) != 20 { + t.Fatalf("expected 20 router reasoners, have %d", len(pythonRouterNames)) + } +} + +func TestRouterNames_ReturnsACopy(t *testing.T) { + first := reasoners.RouterNames() + first[0] = "mutated" + if reasoners.RouterNames()[0] != "run_iac_reader" { + t.Fatal("RouterNames() leaks its backing array") + } +} + +func TestRegisterAll_ReturnsTheRegisteredNamesInOrder(t *testing.T) { + got := reasoners.RegisterAll(sdkagent.NewRouter(), &appx.Fake{}) + if !reflect.DeepEqual(got, pythonRouterNames) { + t.Fatalf("RegisterAll() =\n%v\nwant\n%v", got, pythonRouterNames) + } +} + +func TestTags_MatchPythonAgentRouterTags(t *testing.T) { + // src/cloudsecurity_af/reasoners/__init__.py: + // router = AgentRouter(tags=["cloud", "security", "infrastructure"]) + want := []string{"cloud", "security", "infrastructure"} + if got := reasoners.Tags(); !reflect.DeepEqual(got, want) { + t.Fatalf("Tags() = %v, want %v", got, want) + } + mutated := reasoners.Tags() + mutated[0] = "nope" + if reasoners.Tags()[0] != "cloud" { + t.Fatal("Tags() leaks its backing array") + } +} + +// TestRegisterAll_MountsEveryNameOnTheAgent proves the names are not just +// bookkeeping: after IncludeRouter every one of them resolves to a handler on a +// real *agent.Agent, so Agent.Call(nodeID+"."+name) from a phase has a target. +func TestRegisterAll_MountsEveryNameOnTheAgent(t *testing.T) { + app := newTestAgent(t) + router := sdkagent.NewRouter() + names := reasoners.RegisterAll(router, &appx.Fake{}) + app.IncludeRouter(router, sdkagent.RouterOptions{Tags: reasoners.Tags()}) + + for _, name := range names { + _, err := app.Execute(context.Background(), name, map[string]any{}) + if err != nil && strings.Contains(err.Error(), "unknown reasoner or skill") { + t.Fatalf("%s: not registered on the agent (%v)", name, err) + } + } + + // A name Python does NOT register must stay unknown. + if _, err := app.Execute(context.Background(), "run_deduplicator", map[string]any{}); err == nil || + !strings.Contains(err.Error(), "unknown reasoner or skill") { + t.Fatalf("unexpected extra reasoner registered: %v", err) + } +} + +// TestRegisterAll_NoPrefixOnMount pins the RouterOptions contract the node uses: +// Python's app.include_router(reasoner_router) passes NO prefix, so the reasoner +// is reachable as "run_iac_reader", never "cloud.run_iac_reader". +func TestRegisterAll_NoPrefixOnMount(t *testing.T) { + app := newTestAgent(t) + router := sdkagent.NewRouter() + reasoners.RegisterAll(router, &appx.Fake{}) + app.IncludeRouter(router, sdkagent.RouterOptions{Tags: reasoners.Tags()}) + + if _, err := app.Execute(context.Background(), "cloud.run_iac_reader", map[string]any{}); err == nil || + !strings.Contains(err.Error(), "unknown reasoner or skill") { + t.Fatalf("router was mounted with a prefix: %v", err) + } +} + +// --- hunt.py: the seven hunters --------------------------------------------- + +// TestHunterReasoners_BindArgumentsAndSelectTheRightHunter checks each hunter +// reasoner forwards its four kwargs to the matching agents/hunt entry point: +// the harness prompt must be the hunter's OWN template (line 2 of every hunt +// template is a unique role sentence), the repo_path must reach the harness as +// project_dir, and the reply must be HuntResult.model_dump() with +// strategies_run rewritten to the hunter's single strategy. +func TestHunterReasoners_BindArgumentsAndSelectTheRightHunter(t *testing.T) { + cases := []struct { + reasoner string + promptFile string + strategy string + }{ + {"run_iam_hunter", "hunt/iam.txt", "iam"}, + {"run_network_hunter", "hunt/network.txt", "network"}, + {"run_data_hunter", "hunt/data.txt", "data"}, + {"run_secrets_hunter", "hunt/secrets.txt", "secrets"}, + {"run_compute_hunter", "hunt/compute.txt", "compute"}, + {"run_logging_hunter", "hunt/logging.txt", "logging"}, + {"run_compliance_hunter", "hunt/compliance.txt", "compliance"}, + } + + for _, tc := range cases { + t.Run(tc.reasoner, func(t *testing.T) { + hunt := schemas.NewHuntResult() + hunt.Findings = []schemas.RawFinding{} + fake := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return mustJSON(t, hunt), nil + })} + app := mountRouter(t, fake) + + out, err := app.Execute(context.Background(), tc.reasoner, map[string]any{ + "repo_path": "/repo", + "resource_graph_path": "/tmp/graph.json", + "inventory_path": "/tmp/inventory.json", + "depth": "thorough", + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + + if len(fake.Harnesses) != 1 { + t.Fatalf("harness invocations = %d, want 1", len(fake.Harnesses)) + } + call := fake.Harnesses[0] + if marker := roleLine(t, tc.promptFile); !strings.Contains(call.Prompt, marker) { + t.Fatalf("prompt does not come from %s (missing %q)", tc.promptFile, marker) + } + if call.Opts.ProjectDir != "/repo" { + t.Fatalf("project_dir = %q, want /repo", call.Opts.ProjectDir) + } + if !strings.Contains(call.Prompt, "thorough") { + t.Fatal("depth was not substituted into the prompt") + } + + m := asMap(t, out) + if got := m["strategies_run"]; !reflect.DeepEqual(got, []string{tc.strategy}) { + t.Fatalf("strategies_run = %#v, want [%q]", got, tc.strategy) + } + // model_dump() emits every HuntResult field. + for _, key := range []string{"findings", "total_raw", "deduplicated_count", "strategies_run", "hunt_duration_seconds"} { + if _, ok := m[key]; !ok { + t.Fatalf("result is missing %q (keys: %v)", key, sortedKeys(m)) + } + } + }) + } +} + +// --- recon.py ---------------------------------------------------------------- + +func TestIaCReaderReasoner_BindsRepoPathAndDumpsInventory(t *testing.T) { + repo := t.TempDir() + writeFile(t, filepath.Join(repo, "main.tf"), `resource "aws_s3_bucket" "b" { + bucket = "demo" +} +`) + + fake := &appx.Fake{} + app := mountRouter(t, fake) + + out, err := app.Execute(context.Background(), "run_iac_reader", map[string]any{"repo_path": repo}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if len(fake.Harnesses) != 0 { + t.Fatalf("deterministic parse should not touch the harness, got %d calls", len(fake.Harnesses)) + } + + m := asMap(t, out) + for _, key := range []string{"inventory_saved_path", "total_resources", "iac_type", "iac_version"} { + if _, ok := m[key]; !ok { + t.Fatalf("result is missing %q (keys: %v)", key, sortedKeys(m)) + } + } + if m["total_resources"] != 1 { + t.Fatalf("total_resources = %#v, want 1", m["total_resources"]) + } +} + +func TestResourceGraphBuilderReasoner_BindsBothPaths(t *testing.T) { + repo := t.TempDir() + writeFile(t, filepath.Join(repo, "main.tf"), `resource "aws_s3_bucket" "b" { + bucket = "demo" +} +`) + fake := &appx.Fake{} + app := mountRouter(t, fake) + + inventory, err := app.Execute(context.Background(), "run_iac_reader", map[string]any{"repo_path": repo}) + if err != nil { + t.Fatalf("run_iac_reader: %v", err) + } + invPath, _ := asMap(t, inventory)["inventory_saved_path"].(string) + if invPath == "" { + t.Fatal("run_iac_reader returned no inventory_saved_path") + } + + out, err := app.Execute(context.Background(), "run_resource_graph_builder", map[string]any{ + "repo_path": repo, + "inventory_path": invPath, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + m := asMap(t, out) + for _, key := range []string{"graph_saved_path", "total_nodes", "total_edges"} { + if _, ok := m[key]; !ok { + t.Fatalf("result is missing %q (keys: %v)", key, sortedKeys(m)) + } + } +} + +func TestCloudConnectorReasoner_ForwardsCloudConfigToTheHarness(t *testing.T) { + inventory := schemas.NewResourceInventory() + inventory.TotalResources = 3 + fake := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return mustJSON(t, inventory), nil + })} + app := mountRouter(t, fake) + + out, err := app.Execute(context.Background(), "run_cloud_connector", map[string]any{ + "cloud_config": map[string]any{"provider": "aws", "regions": []any{"eu-west-1"}}, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if len(fake.Harnesses) != 1 { + t.Fatalf("harness invocations = %d, want 1", len(fake.Harnesses)) + } + if !strings.Contains(fake.Harnesses[0].Prompt, "eu-west-1") { + t.Fatal("cloud_config did not reach the cloud-connector prompt") + } + if got := asMap(t, out)["total_resources"]; got != 3 { + t.Fatalf("total_resources = %#v, want 3", got) + } +} + +func TestDriftDetectorReasoner_ForwardsGraphPathAndCloudConfig(t *testing.T) { + drift := schemas.NewDriftReport() + fake := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return mustJSON(t, drift), nil + })} + app := mountRouter(t, fake) + + out, err := app.Execute(context.Background(), "run_drift_detector", map[string]any{ + "iac_graph_path": "/tmp/graph.json", + "cloud_config": map[string]any{"provider": "gcp"}, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if len(fake.Harnesses) != 1 { + t.Fatalf("harness invocations = %d, want 1", len(fake.Harnesses)) + } + prompt := fake.Harnesses[0].Prompt + if !strings.Contains(prompt, "gcp") { + t.Fatal("cloud_config did not reach the drift-detector prompt") + } + // Python parity (reproduced bug): drift_detector.py substitutes + // {{IAC_GRAPH_PATH}} while the template declares {{IAC_GRAPH_JSON}}, so the + // bound path is never interpolated. Pin that so a "fix" cannot land here + // silently — it belongs on the Python side. + if strings.Contains(prompt, "/tmp/graph.json") { + t.Fatal("iac_graph_path was interpolated; Python's replacement is a no-op") + } + m := asMap(t, out) + for _, key := range []string{"drifted_resources", "iac_only_resources", "cloud_only_resources"} { + if _, ok := m[key]; !ok { + t.Fatalf("result is missing %q (keys: %v)", key, sortedKeys(m)) + } + } +} + +// --- chain.py ---------------------------------------------------------------- + +func TestPathConstructorReasoner_BindsFindingsAndOptionalDriftReport(t *testing.T) { + finding := schemas.NewRawFinding() + finding.ID = "f1" + finding.Title = "public bucket" + finding.HunterStrategy = "data" + finding.Category = "public_access" + + plan := schemas.NewPathInvestigationPlan() + fake := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return mustJSON(t, plan), nil + })} + app := mountRouter(t, fake) + + out, err := app.Execute(context.Background(), "run_path_constructor", map[string]any{ + "findings": []any{jsonRoundTrip(t, finding)}, + "resource_graph_path": "/tmp/graph.json", + "max_paths": 7, + "max_children": 2, + // drift_report omitted -> Python's `= None` default. + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if len(fake.Harnesses) == 0 { + t.Fatal("path constructor never reached the harness") + } + parent := fake.Harnesses[0].Prompt + if !strings.Contains(parent, "public bucket") { + t.Fatal("bound findings did not reach the parent prompt") + } + m := asMap(t, out) + for _, key := range []string{"attack_paths", "total_paths_evaluated", "viable_paths", "chain_duration_seconds"} { + if _, ok := m[key]; !ok { + t.Fatalf("result is missing %q (keys: %v)", key, sortedKeys(m)) + } + } +} + +func TestPathConstructorReasoner_RejectsAMalformedFinding(t *testing.T) { + app := mountRouter(t, &appx.Fake{}) + _, err := app.Execute(context.Background(), "run_path_constructor", map[string]any{ + // estimated_severity is a STRICT enum in this port (pydantic parity): + // an unknown value is a validation error, exactly as + // RawFinding.model_validate would raise. + "findings": []any{map[string]any{"estimated_severity": "catastrophic"}}, + "resource_graph_path": "/tmp/graph.json", + "max_paths": 1, + "max_children": 1, + }) + if err == nil { + t.Fatal("expected a bind error for an invalid RawFinding") + } +} + +// --- prove.py ---------------------------------------------------------------- + +func TestProverReasoners_BindFindingAndOptionalAttackPath(t *testing.T) { + for _, reasoner := range []string{"run_static_prover", "run_live_prover"} { + t.Run(reasoner, func(t *testing.T) { + verified := schemas.NewVerifiedFinding() + verified.ID = "f1" + verified.Title = "t" + // verdict/severity are REQUIRED in Python (no default), so a + // canned prover reply must carry them. + verified.Verdict = schemas.VerdictConfirmed + verified.Severity = scoring.SeverityHigh + + finding := schemas.NewRawFinding() + finding.ID = "f1" + finding.Title = "world-readable bucket" + + path := schemas.NewAttackPath() + path.ID = "p1" + path.Title = "bucket -> exfiltration" + + fake := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return mustJSON(t, verified), nil + })} + app := mountRouter(t, fake) + + out, err := app.Execute(context.Background(), reasoner, map[string]any{ + "repo_path": "/repo", + "finding": jsonRoundTrip(t, finding), + "tier": 2, + "attack_path": jsonRoundTrip(t, path), + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if len(fake.Harnesses) != 1 { + t.Fatalf("harness invocations = %d, want 1", len(fake.Harnesses)) + } + call := fake.Harnesses[0] + if call.Opts.ProjectDir != "/repo" { + t.Fatalf("project_dir = %q, want /repo", call.Opts.ProjectDir) + } + if !strings.Contains(call.Prompt, "world-readable bucket") { + t.Fatal("bound finding did not reach the prover prompt") + } + if !strings.Contains(call.Prompt, "bucket -> exfiltration") { + t.Fatal("bound attack_path did not reach the prover prompt") + } + if got := asMap(t, out)["id"]; got != "f1" { + t.Fatalf("id = %#v, want f1", got) + } + }) + } +} + +// TestProverReasoners_OmittedAttackPathIsNone pins the `= None` default: with +// attack_path absent the prompt renders the empty-JSON placeholder rather than +// failing to bind. +func TestProverReasoners_OmittedAttackPathIsNone(t *testing.T) { + verified := schemas.NewVerifiedFinding() + verified.Verdict = schemas.VerdictInconclusive + verified.Severity = scoring.SeverityLow + fake := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return mustJSON(t, verified), nil + })} + app := mountRouter(t, fake) + + if _, err := app.Execute(context.Background(), "run_static_prover", map[string]any{ + "repo_path": "/repo", + "finding": jsonRoundTrip(t, schemas.NewRawFinding()), + "tier": 1, + }); err != nil { + t.Fatalf("Execute: %v", err) + } + if len(fake.Harnesses) != 1 { + t.Fatalf("harness invocations = %d, want 1", len(fake.Harnesses)) + } + if !strings.Contains(fake.Harnesses[0].Prompt, "{}") { + t.Fatal("a nil attack_path should render the empty-object placeholder") + } +} + +// --- remediate.py ------------------------------------------------------------ + +func TestFixGeneratorReasoner_BindsVerifiedFinding(t *testing.T) { + suggestion := schemas.NewRemediationSuggestion() + suggestion.FindingID = "f1" + fake := &appx.Fake{HarnessFn: appx.HarnessJSON(func(string, harness.Options) (json.RawMessage, error) { + return mustJSON(t, suggestion), nil + })} + app := mountRouter(t, fake) + + finding := schemas.NewVerifiedFinding() + finding.ID = "f1" + finding.Title = "unencrypted volume" + finding.Verdict = schemas.VerdictConfirmed + finding.Severity = scoring.SeverityMedium + + out, err := app.Execute(context.Background(), "run_fix_generator", map[string]any{ + "repo_path": "/repo", + "finding": jsonRoundTrip(t, finding), + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if len(fake.Harnesses) != 1 { + t.Fatalf("harness invocations = %d, want 1", len(fake.Harnesses)) + } + if !strings.Contains(fake.Harnesses[0].Prompt, "unencrypted volume") { + t.Fatal("bound finding did not reach the fix-generator prompt") + } + if got := asMap(t, out)["finding_id"]; got != "f1" { + t.Fatalf("finding_id = %#v, want f1", got) + } +} + +// --- phases.py --------------------------------------------------------------- + +// TestPhaseReasoners_DispatchThroughAppCallWithPythonDefaults exercises the five +// phase reasoners end to end through the agent: each must reach internal/phases, +// which issues the `NODE_ID.` control-plane calls that ARE the DAG. +// It also pins the signature defaults (depth "standard", tier 1, the three +// concurrency caps 3) by omitting them from the request. +func TestPhaseReasoners_DispatchThroughAppCallWithPythonDefaults(t *testing.T) { + t.Setenv("NODE_ID", "cloudsecurity") + + t.Run("recon_phase", func(t *testing.T) { + inventory := schemas.NewResourceInventory() + inventory.InventorySavedPath = "/tmp/inv.json" + graph := schemas.NewResourceGraph() + graph.GraphSavedPath = "/tmp/graph.json" + + fake := &appx.Fake{CallFn: func(_ context.Context, target string, _ map[string]any) (map[string]any, error) { + switch target { + case "cloudsecurity.run_iac_reader": + return jsonRoundTrip(t, inventory), nil + case "cloudsecurity.run_resource_graph_builder": + return jsonRoundTrip(t, graph), nil + } + return nil, nil + }} + app := mountRouter(t, fake) + + if _, err := app.Execute(context.Background(), "recon_phase", map[string]any{"repo_path": "/repo"}); err != nil { + t.Fatalf("Execute: %v", err) + } + want := []string{"cloudsecurity.run_iac_reader", "cloudsecurity.run_resource_graph_builder"} + if got := fake.CallTargets(); !reflect.DeepEqual(got, want) { + // tier defaults to 1, so the two tier-2 calls must NOT happen. + t.Fatalf("call targets = %v, want %v", got, want) + } + }) + + t.Run("hunt_phase", func(t *testing.T) { + empty := schemas.NewHuntResult() + fake := &appx.Fake{CallFn: func(_ context.Context, string2 string, _ map[string]any) (map[string]any, error) { + return jsonRoundTrip(t, empty), nil + }} + app := mountRouter(t, fake) + + if _, err := app.Execute(context.Background(), "hunt_phase", map[string]any{ + "repo_path": "/repo", + "resource_graph_path": "/tmp/graph.json", + "inventory_path": "/tmp/inv.json", + }); err != nil { + t.Fatalf("Execute: %v", err) + } + // depth defaults to "standard" -> the 7-hunter map. + if len(fake.Calls) != 7 { + t.Fatalf("hunter calls = %d, want 7 (the standard DEPTH_HUNTER_MAP)", len(fake.Calls)) + } + if max := fake.MaxConcurrentCalls(); max > 3 { + t.Fatalf("max concurrent hunters = %d, want <= 3 (the signature default)", max) + } + for _, c := range fake.Calls { + if !strings.HasPrefix(c.Target, "cloudsecurity.run_") || !strings.HasSuffix(c.Target, "_hunter") { + t.Fatalf("unexpected hunt target %q", c.Target) + } + if got := c.Input["depth"]; got != "standard" { + t.Fatalf("depth kwarg = %#v, want \"standard\"", got) + } + } + }) + + t.Run("chain_phase", func(t *testing.T) { + chain := schemas.NewChainResult() + var recorded map[string]any + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, in map[string]any) (map[string]any, error) { + recorded = in + return jsonRoundTrip(t, chain), nil + }} + app := mountRouter(t, fake) + + if _, err := app.Execute(context.Background(), "chain_phase", map[string]any{ + "findings": []any{}, + "resource_graph_path": "/tmp/graph.json", + }); err != nil { + t.Fatalf("Execute: %v", err) + } + if got := fake.CallTargets(); !reflect.DeepEqual(got, []string{"cloudsecurity.run_path_constructor"}) { + t.Fatalf("call targets = %v", got) + } + // depth "standard" -> DEPTH_CHAIN_LIMITS[standard]; max_children default 3. + if got := recorded["max_children"]; got != 3 { + t.Fatalf("max_children = %#v, want 3", got) + } + if _, ok := recorded["max_paths"]; !ok { + t.Fatalf("max_paths kwarg missing (keys: %v)", sortedKeys(recorded)) + } + }) + + t.Run("prove_phase", func(t *testing.T) { + verified := schemas.NewVerifiedFinding() + verified.ID = "f1" + verified.Verdict = schemas.VerdictLikely + verified.Severity = scoring.SeverityHigh + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { + return jsonRoundTrip(t, verified), nil + }} + app := mountRouter(t, fake) + + hunt := schemas.NewHuntResult() + f := schemas.NewRawFinding() + f.ID = "f1" + hunt.Findings = []schemas.RawFinding{f} + + out, err := app.Execute(context.Background(), "prove_phase", map[string]any{ + "repo_path": "/repo", + "hunt_result": jsonRoundTrip(t, hunt), + "chain_result": jsonRoundTrip(t, schemas.NewChainResult()), + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + // tier defaults to 1 -> the STATIC prover. + if got := fake.CallTargets(); !reflect.DeepEqual(got, []string{"cloudsecurity.run_static_prover"}) { + t.Fatalf("call targets = %v, want the static prover (tier default 1)", got) + } + m := asMap(t, out) + for _, key := range []string{"verified", "total_selected", "total_findings", "not_verified"} { + if _, ok := m[key]; !ok { + t.Fatalf("prove_phase result is missing %q (keys: %v)", key, sortedKeys(m)) + } + } + }) + + t.Run("remediation_phase", func(t *testing.T) { + suggestion := schemas.NewRemediationSuggestion() + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { + return jsonRoundTrip(t, suggestion), nil + }} + app := mountRouter(t, fake) + + confirmed := schemas.NewVerifiedFinding() + confirmed.ID = "f1" + confirmed.Verdict = schemas.VerdictConfirmed + confirmed.Severity = scoring.SeverityCritical + + out, err := app.Execute(context.Background(), "remediation_phase", map[string]any{ + "repo_path": "/repo", + "verified_findings": []any{jsonRoundTrip(t, confirmed)}, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if got := fake.CallTargets(); !reflect.DeepEqual(got, []string{"cloudsecurity.run_fix_generator"}) { + t.Fatalf("call targets = %v", got) + } + if _, ok := asMap(t, out)["verified"]; !ok { + t.Fatal("remediation_phase result is missing \"verified\"") + } + }) +} + +// TestPhaseReasoners_HonorNodeIDEnv proves the phase handlers resolve NODE_ID at +// call time, so a node started with NODE_ID=cloudsecurity-go calls its OWN +// reasoners rather than the Python node's. +func TestPhaseReasoners_HonorNodeIDEnv(t *testing.T) { + t.Setenv("NODE_ID", "cloudsecurity-go") + + chain := schemas.NewChainResult() + fake := &appx.Fake{CallFn: func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { + return jsonRoundTrip(t, chain), nil + }} + app := mountRouter(t, fake) + + if _, err := app.Execute(context.Background(), "chain_phase", map[string]any{ + "findings": []any{}, + "resource_graph_path": "/tmp/graph.json", + }); err != nil { + t.Fatalf("Execute: %v", err) + } + if got := fake.CallTargets(); !reflect.DeepEqual(got, []string{"cloudsecurity-go.run_path_constructor"}) { + t.Fatalf("call targets = %v", got) + } +} + +// --- helpers ----------------------------------------------------------------- + +func newTestAgent(t *testing.T) *sdkagent.Agent { + t.Helper() + app, err := sdkagent.New(sdkagent.Config{NodeID: "cloudsecurity-test", Version: "0.1.0"}) + if err != nil { + t.Fatalf("agent.New: %v", err) + } + return app +} + +// mountRouter builds an agent, registers the router surface against fake and +// mounts it exactly as node.BuildAgent does. +func mountRouter(t *testing.T, fake *appx.Fake) *sdkagent.Agent { + t.Helper() + app := newTestAgent(t) + router := sdkagent.NewRouter() + reasoners.RegisterAll(router, fake) + app.IncludeRouter(router, sdkagent.RouterOptions{Tags: reasoners.Tags()}) + return app +} + +func mustJSON(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal %T: %v", v, err) + } + return b +} + +// jsonRoundTrip renders v the way the control plane hands a model_dump() back: +// a plain map of JSON-native values. +func jsonRoundTrip(t *testing.T, v any) map[string]any { + t.Helper() + var out map[string]any + if err := json.Unmarshal(mustJSON(t, v), &out); err != nil { + t.Fatalf("round-trip %T: %v", v, err) + } + return out +} + +// asMap normalizes a handler's reply into a keyed lookup. Handlers return an +// afx.Payload — an INSERTION-ORDERED object, because the wire key order is part +// of the parity contract (payload_order_test.go pins it) — whose values stay +// typed, exactly as afx.ToMap's did. +func asMap(t *testing.T, v any) map[string]any { + t.Helper() + p, ok := v.(afx.Payload) + if !ok { + t.Fatalf("handler returned %T, want afx.Payload", v) + } + return p.Map() +} + +// roleLine returns line 2 of an embedded prompt template — the sentence that is +// unique to each hunter/agent template and survives placeholder substitution. +func roleLine(t *testing.T, rel string) string { + t.Helper() + text, err := prompts.Load(rel) + if err != nil { + t.Fatalf("prompts.Load(%q): %v", rel, err) + } + lines := strings.Split(text, "\n") + if len(lines) < 2 { + t.Fatalf("prompt %q has no second line", rel) + } + return strings.TrimSpace(lines[1]) +} + +func sortedKeys(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j] < out[j-1]; j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + return out +} + +func writeFile(t *testing.T, path, body string) { + t.Helper() + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} 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..7a3e380 --- /dev/null +++ b/go/internal/reasoners/testdata/python_input_schemas.json @@ -0,0 +1,549 @@ +{ + "chain_phase": { + "properties": { + "depth": { + "type": "string" + }, + "drift_report": { + "type": "object" + }, + "findings": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "max_children": { + "type": "integer" + }, + "resource_graph_path": { + "type": "string" + } + }, + "required": [ + "findings", + "resource_graph_path" + ], + "type": "object" + }, + "hunt_phase": { + "properties": { + "depth": { + "type": "string" + }, + "inventory_path": { + "type": "string" + }, + "max_concurrent_hunters": { + "type": "integer" + }, + "repo_path": { + "type": "string" + }, + "resource_graph_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "resource_graph_path", + "inventory_path" + ], + "type": "object" + }, + "prove": { + "properties": { + "assume_role_arn": { + "type": "object" + }, + "branch": { + "type": "string" + }, + "cloud_provider": { + "type": "string" + }, + "cloud_regions": { + "type": "object" + }, + "commit_sha": { + "type": "object" + }, + "compliance_frameworks": { + "type": "object" + }, + "depth": { + "type": "string" + }, + "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" + }, + "output_formats": { + "type": "object" + }, + "repo_url": { + "type": "string" + }, + "severity_threshold": { + "type": "string" + } + }, + "required": [ + "repo_url" + ], + "type": "object" + }, + "prove_phase": { + "properties": { + "chain_result": { + "additionalProperties": true, + "type": "object" + }, + "depth": { + "type": "string" + }, + "hunt_result": { + "additionalProperties": true, + "type": "object" + }, + "max_concurrent_provers": { + "type": "integer" + }, + "repo_path": { + "type": "string" + }, + "tier": { + "type": "integer" + } + }, + "required": [ + "repo_path", + "hunt_result", + "chain_result" + ], + "type": "object" + }, + "recon_phase": { + "properties": { + "cloud_config": { + "type": "object" + }, + "depth": { + "type": "string" + }, + "repo_path": { + "type": "string" + }, + "tier": { + "type": "integer" + } + }, + "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_cloud_connector": { + "properties": { + "cloud_config": { + "additionalProperties": true, + "type": "object" + } + }, + "required": [ + "cloud_config" + ], + "type": "object" + }, + "run_compliance_hunter": { + "properties": { + "depth": { + "type": "string" + }, + "inventory_path": { + "type": "string" + }, + "repo_path": { + "type": "string" + }, + "resource_graph_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "resource_graph_path", + "inventory_path", + "depth" + ], + "type": "object" + }, + "run_compute_hunter": { + "properties": { + "depth": { + "type": "string" + }, + "inventory_path": { + "type": "string" + }, + "repo_path": { + "type": "string" + }, + "resource_graph_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "resource_graph_path", + "inventory_path", + "depth" + ], + "type": "object" + }, + "run_data_hunter": { + "properties": { + "depth": { + "type": "string" + }, + "inventory_path": { + "type": "string" + }, + "repo_path": { + "type": "string" + }, + "resource_graph_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "resource_graph_path", + "inventory_path", + "depth" + ], + "type": "object" + }, + "run_drift_detector": { + "properties": { + "cloud_config": { + "additionalProperties": true, + "type": "object" + }, + "iac_graph_path": { + "type": "string" + } + }, + "required": [ + "iac_graph_path", + "cloud_config" + ], + "type": "object" + }, + "run_fix_generator": { + "properties": { + "finding": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "finding" + ], + "type": "object" + }, + "run_iac_reader": { + "properties": { + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path" + ], + "type": "object" + }, + "run_iam_hunter": { + "properties": { + "depth": { + "type": "string" + }, + "inventory_path": { + "type": "string" + }, + "repo_path": { + "type": "string" + }, + "resource_graph_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "resource_graph_path", + "inventory_path", + "depth" + ], + "type": "object" + }, + "run_live_prover": { + "properties": { + "attack_path": { + "type": "object" + }, + "finding": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + }, + "tier": { + "type": "integer" + } + }, + "required": [ + "repo_path", + "finding", + "tier" + ], + "type": "object" + }, + "run_logging_hunter": { + "properties": { + "depth": { + "type": "string" + }, + "inventory_path": { + "type": "string" + }, + "repo_path": { + "type": "string" + }, + "resource_graph_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "resource_graph_path", + "inventory_path", + "depth" + ], + "type": "object" + }, + "run_network_hunter": { + "properties": { + "depth": { + "type": "string" + }, + "inventory_path": { + "type": "string" + }, + "repo_path": { + "type": "string" + }, + "resource_graph_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "resource_graph_path", + "inventory_path", + "depth" + ], + "type": "object" + }, + "run_path_constructor": { + "properties": { + "drift_report": { + "type": "object" + }, + "findings": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "max_children": { + "type": "integer" + }, + "max_paths": { + "type": "integer" + }, + "resource_graph_path": { + "type": "string" + } + }, + "required": [ + "findings", + "resource_graph_path", + "max_paths", + "max_children" + ], + "type": "object" + }, + "run_resource_graph_builder": { + "properties": { + "inventory_path": { + "type": "string" + }, + "repo_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "inventory_path" + ], + "type": "object" + }, + "run_secrets_hunter": { + "properties": { + "depth": { + "type": "string" + }, + "inventory_path": { + "type": "string" + }, + "repo_path": { + "type": "string" + }, + "resource_graph_path": { + "type": "string" + } + }, + "required": [ + "repo_path", + "resource_graph_path", + "inventory_path", + "depth" + ], + "type": "object" + }, + "run_static_prover": { + "properties": { + "attack_path": { + "type": "object" + }, + "finding": { + "additionalProperties": true, + "type": "object" + }, + "repo_path": { + "type": "string" + }, + "tier": { + "type": "integer" + } + }, + "required": [ + "repo_path", + "finding", + "tier" + ], + "type": "object" + }, + "scan": { + "properties": { + "base_commit_sha": { + "type": "object" + }, + "branch": { + "type": "string" + }, + "commit_sha": { + "type": "object" + }, + "compliance_frameworks": { + "type": "object" + }, + "depth": { + "type": "string" + }, + "exclude_paths": { + "type": "object" + }, + "fail_on_findings": { + "type": "boolean" + }, + "include_paths": { + "type": "object" + }, + "is_pr": { + "type": "boolean" + }, + "max_concurrent_hunters": { + "type": "object" + }, + "max_concurrent_provers": { + "type": "object" + }, + "max_cost_usd": { + "type": "object" + }, + "max_duration_seconds": { + "type": "object" + }, + "output_formats": { + "type": "object" + }, + "pr_id": { + "type": "object" + }, + "repo_url": { + "type": "string" + }, + "severity_threshold": { + "type": "string" + } + }, + "required": [ + "repo_url" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/go/packaging_test.go b/go/packaging_test.go new file mode 100644 index 0000000..0ab2a1f --- /dev/null +++ b/go/packaging_test.go @@ -0,0 +1,522 @@ +package gomod + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// This file is the parity gate for the packaging the Go node ships with. Every +// assertion here is derived from what the PYTHON stack does — the repo's own +// Dockerfile, docker-compose.yml and app.py — not from what the Go files +// currently say. +// +// Paths are relative to go/, which is this package's directory. +const ( + goDockerfile = "Dockerfile" + goEntrypoint = "docker-entrypoint.sh" + pyDockerfile = "../Dockerfile" + pyCompose = "../docker-compose.yml" + goCompose = "../docker-compose.go.yml" + goREADME = "README.md" + rootREADME = "../README.md" + rootManifest = "../agentfield-package.yaml" + pyApp = "../src/cloudsecurity_af/app.py" + ciWorkflow = "../.github/workflows/go.yml" + workspacesEnv = "SEC_AF_WORKSPACES_DIR" +) + +// gateInputsOutsideGo are the files this gate reads that live OUTSIDE go/. +// Each one is a real input: change it and the assertions below change with it. +// They are therefore also inputs to the CI workflow's paths filter — see +// TestPackaging_CIWatchesEveryFileTheGateReads. +var gateInputsOutsideGo = []string{pyDockerfile, pyCompose, goCompose, rootREADME, rootManifest, pyApp} + +func readRepoFile(t *testing.T, rel string) string { + t.Helper() + body, err := os.ReadFile(filepath.Clean(rel)) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + return string(body) +} + +// CONTRACT (from src/cloudsecurity_af/app.py:75-93 `_workspaces_root`): the +// explicit SEC_AF_WORKSPACES_DIR branch is taken only when the variable is +// TRUTHY. Otherwise the node mkdir's /workspaces, write-probes it and falls +// back to ~/.sec-af/workspaces on OSError. The Python image and the Python +// compose both leave the variable unset, which is what keeps that fallback +// live — and it has to stay live for the Go node too, because +// docker-compose.go.yml bind-mounts a HOST directory onto /workspaces and a +// bind does not inherit the image's chown, so /workspaces arrives owned by the +// host uid and is unwritable by the image's uid 10001 user. +func TestPackaging_WorkspacesDirIsNotBakedIn(t *testing.T) { + if strings.Contains(readRepoFile(t, pyDockerfile), workspacesEnv) { + t.Fatalf("premise broken: the Python Dockerfile now sets %s; re-derive this test", workspacesEnv) + } + if strings.Contains(readRepoFile(t, pyCompose), workspacesEnv) { + t.Fatalf("premise broken: the Python compose now sets %s; re-derive this test", workspacesEnv) + } + + for _, rel := range []string{goDockerfile, goCompose} { + for _, line := range strings.Split(readRepoFile(t, rel), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") { + continue + } + if strings.Contains(trimmed, workspacesEnv+"=") { + t.Errorf("%s sets %s (%q); that skips app.py's writability probe and 500s every remote repo_url clone under the /workspaces bind", + rel, workspacesEnv, trimmed) + } + } + } +} + +// CONTRACT: the container's working directory must be writable by the runtime +// user. The Python image uses /app, which it chowns and which no compose file +// mounts over. /workspaces is the one directory docker-compose.go.yml replaces +// with a host bind, so it cannot serve as the cwd. +func TestPackaging_WorkdirIsNotTheBindMountedWorkspaces(t *testing.T) { + body := readRepoFile(t, goDockerfile) + re := regexp.MustCompile(`(?m)^WORKDIR\s+(\S+)`) + matches := re.FindAllStringSubmatch(body, -1) + if len(matches) == 0 { + t.Fatal("go/Dockerfile declares no WORKDIR") + } + last := matches[len(matches)-1][1] + if last == "/workspaces" { + t.Errorf("runtime WORKDIR is %s, which docker-compose.go.yml bind-mounts from the host and is therefore not writable by uid 10001", last) + } +} + +// CONTRACT (from src/cloudsecurity_af/config.py:91-96, mirrored in +// internal/config): harness_model resolves CLOUDSECURITY_MODEL first, then +// HARNESS_MODEL, then the default. The entrypoint writes opencode.json's model +// AND its single-entry provider whitelist, and the node passes its own resolved +// model to opencode with -m; if the two use different precedence, opencode is +// invoked with a model its own config does not whitelist — the exact failure +// the script's header says it exists to prevent. +func TestPackaging_EntrypointHonorsTheModelPrecedenceChain(t *testing.T) { + body := readRepoFile(t, goEntrypoint) + if !strings.Contains(body, `MODEL="${CLOUDSECURITY_MODEL:-${HARNESS_MODEL:-`) { + t.Errorf("docker-entrypoint.sh must resolve CLOUDSECURITY_MODEL before HARNESS_MODEL; got:\n%s", body) + } + // The image bakes HARNESS_MODEL, so a chain that starts at HARNESS_MODEL + // can never reach CLOUDSECURITY_MODEL inside the container. + if !strings.Contains(readRepoFile(t, goDockerfile), "HARNESS_MODEL=") { + t.Fatal("premise broken: go/Dockerfile no longer bakes HARNESS_MODEL; re-derive this test") + } +} + +// CONTRACT: one stack, one health cadence. The image's own HEALTHCHECK already +// mirrors the Python image (30s/5s/3); a compose healthcheck fully overrides the +// image directive, so it must not drift from it either. +func TestPackaging_HealthcheckCadenceMatchesThePythonStack(t *testing.T) { + want := map[string]string{ + "interval": "30s", + "timeout": "5s", + "retries": "3", + "start_period": "15s", + } + // Derive the expectation from the Python compose rather than hard-coding it. + pyNode := healthcheckBlock(t, readRepoFile(t, pyCompose), "http://localhost:8005/health") + for key, value := range want { + if pyNode[key] != value { + t.Fatalf("premise broken: the Python node's healthcheck %s is %q, not %q; re-derive this test", key, pyNode[key], value) + } + } + + goNode := healthcheckBlock(t, readRepoFile(t, goCompose), "http://localhost:8015/health") + for key, value := range want { + if goNode[key] != value { + t.Errorf("docker-compose.go.yml healthcheck %s = %q, want %q (the Python node's cadence)", key, goNode[key], value) + } + } + + image := readRepoFile(t, goDockerfile) + if !strings.Contains(image, "HEALTHCHECK --interval=30s --timeout=5s --retries=3") { + t.Error("go/Dockerfile's HEALTHCHECK no longer matches the Python image's 30s/5s/3") + } +} + +// healthcheckBlock returns the key/value pairs of the healthcheck: block whose +// test line contains probe. +func healthcheckBlock(t *testing.T, compose, probe string) map[string]string { + t.Helper() + lines := strings.Split(compose, "\n") + start := -1 + for i, line := range lines { + if strings.Contains(line, probe) && strings.Contains(line, "test:") { + start = i + break + } + } + if start < 0 { + t.Fatalf("no healthcheck test line containing %q", probe) + } + out := map[string]string{} + for _, line := range lines[start+1:] { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + key, value, ok := strings.Cut(trimmed, ":") + if !ok { + break + } + key = strings.TrimSpace(key) + if key != "interval" && key != "timeout" && key != "retries" && key != "start_period" { + break + } + out[key] = strings.TrimSpace(value) + } + return out +} + +// CONTRACT: the root manifest redirects a git install to go/, so the quickstart +// paragraph that describes `af install ` must not promise a Python +// virtualenv. The redirect is a git-install behaviour only — the local-path +// escape hatch has to be documented where a user first meets the command. +func TestPackaging_RootREADMEQuickstartDescribesTheRedirect(t *testing.T) { + manifest := readRepoFile(t, rootManifest) + if !strings.Contains(manifest, "superseded_by:") { + t.Fatal("premise broken: the root manifest no longer declares superseded_by; re-derive this test") + } + + readme := readRepoFile(t, rootREADME) + quickstart := sectionBody(t, readme, "### Install into AgentField (`af install`)") + if strings.Contains(quickstart, "provisions an isolated Python environment") { + t.Error("the quickstart still claims `af install ` provisions a Python venv; the manifest redirect installs the Go package, which is compiled with `go build`") + } + for _, want := range []string{ + "follows the repository manifest", + "replaced in place", + "node-scoped secrets", + "af install ./cloudsecurity-af", + } { + if !strings.Contains(quickstart, want) { + t.Errorf("the quickstart does not mention %q", want) + } + } +} + +// sectionBody returns the markdown between heading and the next heading of the +// same or higher level. +func sectionBody(t *testing.T, doc, heading string) string { + t.Helper() + idx := strings.Index(doc, heading) + if idx < 0 { + t.Fatalf("README has no %q section", heading) + } + rest := doc[idx+len(heading):] + level := strings.Count(strings.SplitN(heading, " ", 2)[0], "#") + for i, line := range strings.Split(rest, "\n") { + if i == 0 { + continue + } + if strings.HasPrefix(line, "#") { + if h := len(line) - len(strings.TrimLeft(line, "#")); h <= level { + return rest[:strings.Index(rest, line)] + } + } + } + return rest +} + +// CONTRACT (from src/cloudsecurity_af/app.py:31 +// `NODE_ID = os.getenv("NODE_ID", "cloudsecurity")`): BOTH manifests must +// declare the id the process actually registers under. +// +// `af run` reads expectedNodeID from the manifest +// (control-plane/internal/packages/runner.go), polls /health, extracts +// `node_id` (node_identity.go HealthNodeID) and compares with +// NodeIDsEquivalent, which folds only case and `-`↔`_`. The Python SDK's +// /health reports `{"status": "healthy", "node_id": "cloudsecurity", ...}`, so +// a manifest saying `cloudsecurity-af` makes `af run` kill the process with +// +// port N is answering health checks as "cloudsecurity", not "cloudsecurity-af" +// — another process is using the port +// +// which is exactly the local-path install the quickstart documents as the way +// to get the Python node. (The Go SDK's /health carries no node_id, so the Go +// package's manifest is never checked — which is why only the root one broke.) +func TestPackaging_ManifestsDeclareTheNodeIDTheProcessRegisters(t *testing.T) { + app := readRepoFile(t, pyApp) + m := regexp.MustCompile(`NODE_ID\s*=\s*os\.getenv\("NODE_ID",\s*"([^"]+)"\)`).FindStringSubmatch(app) + if m == nil { + t.Fatalf("premise broken: %s no longer defaults NODE_ID with os.getenv; re-derive this test", pyApp) + } + want := m[1] + + for _, rel := range []string{rootManifest, "agentfield-package.yaml"} { + got := manifestNodeID(t, rel) + if got != want { + t.Errorf("%s declares node_id %q, but the node registers as %q — `af run` refuses to start it", rel, got, want) + } + } +} + +// manifestNodeID reads agent_node.node_id out of a package manifest. +func manifestNodeID(t *testing.T, rel string) string { + t.Helper() + body := readRepoFile(t, rel) + idx := strings.Index(body, "\nagent_node:") + if idx < 0 { + t.Fatalf("%s has no agent_node block", rel) + } + for _, line := range strings.Split(body[idx+1:], "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") || trimmed == "" { + continue + } + if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") && !strings.HasPrefix(line, "agent_node:") { + break // left the block + } + if strings.HasPrefix(trimmed, "node_id:") { + return strings.TrimSpace(strings.TrimPrefix(trimmed, "node_id:")) + } + } + t.Fatalf("%s: agent_node block has no node_id", rel) + return "" +} + +// CONTRACT: a gate that never runs is not a gate. Every file this test file +// reads from outside go/ must appear in BOTH of the workflow's paths filters, +// or a PR that changes only that file merges with the Go workflow skipped — +// and the breakage then surfaces as a `premise broken` fatal on the next +// unrelated go/** PR, attributed to the wrong change. +func TestPackaging_CIWatchesEveryFileTheGateReads(t *testing.T) { + lists := pathsFilters(t, readRepoFile(t, ciWorkflow)) + if len(lists) != 2 { + t.Fatalf("expected a paths filter on both the push and pull_request triggers, found %d", len(lists)) + } + for _, input := range gateInputsOutsideGo { + rel := strings.TrimPrefix(input, "../") + for i, list := range lists { + if !pathsFilterCovers(list, rel) { + t.Errorf("paths filter #%d does not match %q, so a PR touching it skips this gate entirely: %v", i+1, rel, list) + } + } + } +} + +// pathsFilters returns every `paths:` list in the workflow, in file order. +func pathsFilters(t *testing.T, workflow string) [][]string { + t.Helper() + var out [][]string + var current []string + inList := false + entry := regexp.MustCompile(`^\s+-\s+"([^"]+)"\s*$`) + for _, line := range strings.Split(workflow, "\n") { + if strings.TrimSpace(line) == "paths:" { + if inList { + out = append(out, current) + } + inList, current = true, nil + continue + } + if !inList { + continue + } + if m := entry.FindStringSubmatch(line); m != nil { + current = append(current, m[1]) + continue + } + if strings.HasPrefix(strings.TrimSpace(line), "#") || strings.TrimSpace(line) == "" { + continue + } + out = append(out, current) + inList, current = false, nil + } + if inList { + out = append(out, current) + } + return out +} + +// pathsFilterCovers reports whether any entry matches rel, honoring the one +// glob form the workflow uses (`prefix/**`). +func pathsFilterCovers(list []string, rel string) bool { + for _, entry := range list { + if entry == rel { + return true + } + if strings.HasSuffix(entry, "/**") && strings.HasPrefix(rel, strings.TrimSuffix(entry, "**")) { + return true + } + } + return false +} + +// CONTRACT (from src/cloudsecurity_af/app.py:75-93 `_workspaces_root`): the +// /workspaces bind is NOT automatically a shared checkout. +// +// Both images run as uid 10001 (Dockerfile / go/Dockerfile), a bind does not +// inherit the image's `chown`, and Docker auto-creates the default +// ./workspaces root-owned — so the write probe fails and each node falls back +// to its own container-local ~/.sec-af/workspaces. The compose comment and the +// Go README must not promise one host checkout without that condition; the +// same compose file says so fifteen lines earlier, and a reader who believes +// the unconditional claim will look for clones that are not there. +func TestPackaging_SharedCheckoutClaimIsConditional(t *testing.T) { + if !strings.Contains(readRepoFile(t, goCompose), workspacesEnv+" is deliberately unset") { + t.Fatal("premise broken: the compose add-on no longer explains why the write probe must stay live; re-derive this test") + } + for _, rel := range []string{goCompose, goREADME} { + body := readRepoFile(t, rel) + if strings.Contains(body, "so both nodes resolve a given") { + t.Errorf("%s still claims unconditionally that both nodes share one host checkout", rel) + } + if !strings.Contains(body, "~/.sec-af/workspaces") { + t.Errorf("%s describes the bind without naming the ~/.sec-af/workspaces fallback the default takes", rel) + } + } +} + +// CONTRACT (from the control plane): the installed-package REGISTRY is keyed by +// the manifest `name:`, not by `agent_node.node_id`. +// +// control-plane/internal/packages/git.go:649 and installer.go:1112 +// registry.Installed[metadata.Name] = InstalledPackage{...} +// control-plane/internal/core/services/agent_service.go:840-858 +// findAgentInRegistry normalises only by stripping hyphens +// +// so `cloudsecurity` -> `cloudsecurity` never matches `cloudsecurity-af` -> +// `cloudsecurityaf`, and `af run cloudsecurity` fails with "agent node +// cloudsecurity not installed". Reproduced on a machine with the package +// installed: `/home/…/.agentfield/installed.yaml` is keyed `cloudsecurity-af`. +// `af call` is the opposite — it resolves by NODE ID — which is exactly what +// makes a quickstart that mixes the two look right. +func TestPackaging_AfRunUsesThePackageNameAndAfCallTheNodeID(t *testing.T) { + pkgName := manifestName(t, rootManifest) + nodeID := manifestNodeID(t, rootManifest) + if pkgName == nodeID { + t.Skip("package name and node id are identical; the confusion this guards cannot arise") + } + + // Only RUNNABLE lines count — a line whose trimmed form starts with the + // command. Prose that quotes the wrong form in order to warn about it (as + // go/README's install block does) must not trip the gate. + runLine := regexp.MustCompile(`^af run ([A-Za-z0-9._-]+)`) + callLine := regexp.MustCompile(`^af call ([A-Za-z0-9_-]+)\.`) + for _, rel := range []string{goREADME, rootREADME} { + for _, line := range strings.Split(readRepoFile(t, rel), "\n") { + line = strings.TrimSpace(line) + if m := runLine.FindStringSubmatch(line); m != nil && m[1] != pkgName { + t.Errorf("%s documents `af run %s`; the registry is keyed by the manifest name %q, so that is \"not installed\"", rel, m[1], pkgName) + } + if m := callLine.FindStringSubmatch(line); m != nil && m[1] != nodeID { + t.Errorf("%s documents `af call %s.…`; calls resolve by node id %q", rel, m[1], nodeID) + } + } + } +} + +// CONTRACT: the Go image must not share a Docker tag with the Python image. +// +// The root README builds the PYTHON image with `-t cloudsecurity-af`, which +// Docker resolves to `cloudsecurity-af:latest`. The two artifacts differ (the +// Python image is PORT=8005 + `python -m cloudsecurity_af.app`, the Go image is +// PORT=8015 + the static binary), so a shared tag means whichever was built +// last silently owns it and `docker run cloudsecurity-af` gets the other node. +func TestPackaging_GoImageTagDoesNotCollideWithThePythonImage(t *testing.T) { + // Only RUNNABLE lines count, for the same reason as the test above. + dockerBuild := regexp.MustCompile(`^docker build\b.*-t\s+([A-Za-z0-9._/-]+(?::[A-Za-z0-9._-]+)?)`) + tagsIn := func(rel string) []string { + var out []string + for _, line := range strings.Split(readRepoFile(t, rel), "\n") { + if m := dockerBuild.FindStringSubmatch(strings.TrimSpace(line)); m != nil { + out = append(out, withLatest(m[1])) + } + } + return out + } + + pythonTags := map[string]bool{} + for _, line := range strings.Split(readRepoFile(t, rootREADME), "\n") { + trimmed := strings.TrimSpace(line) + if strings.Contains(trimmed, "go/Dockerfile") { + continue // a Go build documented in the root README + } + if m := dockerBuild.FindStringSubmatch(trimmed); m != nil { + pythonTags[withLatest(m[1])] = true + } + } + if len(pythonTags) == 0 { + t.Skip("the root README documents no `docker build -t …` for the Python image") + } + + makefile := readRepoFile(t, "Makefile") + m := regexp.MustCompile(`(?m)^IMAGE \?= *(\S+)`).FindStringSubmatch(makefile) + if m == nil { + t.Fatal("premise broken: go/Makefile no longer defines IMAGE ?=; re-derive this test") + } + goTags := map[string][]string{ + "go/Makefile IMAGE": {withLatest(m[1])}, + "go/README.md docker build": tagsIn(goREADME), + } + for where, tags := range goTags { + for _, tag := range tags { + if pythonTags[tag] { + t.Errorf("%s tags the Go image %q, the same tag the root README builds the PYTHON image with; whichever is built last silently owns it", where, tag) + } + } + } +} + +func withLatest(tag string) string { + if strings.Contains(tag, ":") { + return tag + } + return tag + ":latest" +} + +// CONTRACT: go/README's environment table is the node's documented surface, so +// every variable the Go code reads directly must appear in it. XDG_DATA_HOME — +// which the Go image and compose set and the Python ones do not — was the one +// that did not, which is how an ops divergence went unrecorded. +func TestPackaging_READMEDocumentsEveryEnvVarTheNodeReads(t *testing.T) { + getenv := regexp.MustCompile(`os\.(?:Getenv|LookupEnv)\("([A-Z0-9_]+)"\)`) + read := map[string]bool{} + err := filepath.Walk("internal", func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + body, readErr := os.ReadFile(filepath.Clean(path)) + if readErr != nil { + return readErr + } + for _, m := range getenv.FindAllStringSubmatch(string(body), -1) { + read[m[1]] = true + } + return nil + }) + if err != nil { + t.Fatalf("walk internal/: %v", err) + } + if len(read) == 0 { + t.Fatal("premise broken: no os.Getenv call found under internal/; re-derive this test") + } + + readme := readRepoFile(t, goREADME) + for name := range read { + if !strings.Contains(readme, "`"+name+"`") { + t.Errorf("go/README.md does not document %s, which internal/ reads", name) + } + } +} + +// manifestName returns a manifest's top-level `name:`. +func manifestName(t *testing.T, rel string) string { + t.Helper() + m := regexp.MustCompile(`(?m)^name:\s*(\S+)`).FindStringSubmatch(readRepoFile(t, rel)) + if m == nil { + t.Fatalf("%s has no top-level name:", rel) + } + return m[1] +} From 7534b599c944d46c0c2e0e29faa92bfa860e7916 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 20 Aug 2026 02:11:58 -0400 Subject: [PATCH 5/5] =?UTF-8?q?feat(go):=20packaging=20=E2=80=94=20install?= =?UTF-8?q?ing=20this=20repo=20gives=20you=20the=20Go=20node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root manifest gains superseded_by pointing at go/, whose manifest declares the same package name and the node id the process actually registers (cloudsecurity), so a git install lands the Go node and replaces an existing Python install in place; a local-path install remains the documented Python escape hatch. Adds the multi-stage Go Dockerfile (checksum-verified AForge fetch + opencode, non-root user), the model-aware entrypoint, a compose add-on joining the Python stack under the cloudsecurity-go id, Makefile, go/README, a root-README section and a Go CI workflow. Co-Authored-By: Claude Fable 5 --- .github/workflows/go.yml | 78 +++++++++ README.md | 36 ++++- agentfield-package.yaml | 25 ++- docker-compose.go.yml | 114 +++++++++++++ go/Dockerfile | 150 ++++++++++++++++++ go/Makefile | 80 ++++++++++ go/README.md | 317 +++++++++++++++++++++++++++++++++++++ go/agentfield-package.yaml | 72 +++++++++ go/docker-entrypoint.sh | 35 ++++ 9 files changed, 904 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/go.yml create mode 100644 docker-compose.go.yml create mode 100644 go/Dockerfile create mode 100644 go/Makefile create mode 100644 go/README.md create mode 100644 go/agentfield-package.yaml create mode 100755 go/docker-entrypoint.sh diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..5aa1aca --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,78 @@ +# CI for the Go node under go/. The Python tree has no workflow in this repo, so +# this file is scoped to the Go port only: it runs the exact gate the port +# contract defines (go/docs/DESIGN.md §0.4) plus a Docker build of the image the +# compose add-on ships. +# +# The paths filter is NOT just go/**: go/packaging_test.go asserts the Go +# packaging against the Python stack's own files, so those are inputs to this +# gate as well. go/packaging_test.go's own +# TestPackaging_CIWatchesEveryFileTheGateReads keeps the two lists in sync. +name: Go + +on: + push: + branches: ["main"] + paths: + - "go/**" + - "docker-compose.go.yml" + - ".github/workflows/go.yml" + # go/packaging_test.go derives its expectations from the PYTHON stack, so + # every file it reads has to trigger this workflow — otherwise a change to + # one of them merges with the parity gate never running, and the breakage + # surfaces later as a `premise broken` failure on an unrelated go/** PR. + # go/Dockerfile's aforge stage is a verbatim copy of the root one, so the + # docker-build job needs them too. + - "Dockerfile" + - "docker-compose.yml" + - "README.md" + - "agentfield-package.yaml" + - "src/cloudsecurity_af/app.py" + pull_request: + branches: ["main"] + paths: + - "go/**" + - "docker-compose.go.yml" + - ".github/workflows/go.yml" + # go/packaging_test.go derives its expectations from the PYTHON stack, so + # every file it reads has to trigger this workflow — otherwise a change to + # one of them merges with the parity gate never running, and the breakage + # surfaces later as a `premise broken` failure on an unrelated go/** PR. + # go/Dockerfile's aforge stage is a verbatim copy of the root one, so the + # docker-build job needs them too. + - "Dockerfile" + - "docker-compose.yml" + - "README.md" + - "agentfield-package.yaml" + - "src/cloudsecurity_af/app.py" + +jobs: + go: + runs-on: ubuntu-latest + defaults: + run: + working-directory: go + steps: + - uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v5 + with: + # Track go/go.mod's own directive so a bump lands in one place. + 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-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 — exactly how docker-compose.go.yml builds it. + - name: Build Docker image + run: docker build -f go/Dockerfile -t cloudsecurity-af-go:test . diff --git a/README.md b/README.md index a91a877..de4d8ad 100644 --- a/README.md +++ b/README.md @@ -113,10 +113,10 @@ Already running an [AgentField](https://github.com/Agent-Field/agentfield) contr ```bash af install https://github.com/Agent-Field/cloudsecurity-af -af run cloudsecurity +af run cloudsecurity-af ``` -`af install` clones the repo, provisions an isolated Python environment, and registers the `cloudsecurity` 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 scan some IaC: +`af install` follows the repository manifest to the maintained Go package and registers it as the `cloudsecurity` node with your control plane — one static binary, no per-node virtualenv to build. (`af run` takes the PACKAGE name, `cloudsecurity-af`; `af call` takes the node id, `cloudsecurity`.) If an older Python `cloudsecurity` 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 scan some IaC: ```bash af call cloudsecurity.scan --in '{"repo_url": "https://github.com/org/infra-repo"}' @@ -124,6 +124,14 @@ af call cloudsecurity.scan --in '{"repo_url": "https://github.com/org/infra-repo New to AgentField? Install the control plane first with `curl -fsSL https://agentfield.ai/install.sh | bash`, or use the Docker option 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/cloudsecurity-af +af install ./cloudsecurity-af +``` + ### Local (Docker Compose) ```bash @@ -386,6 +394,30 @@ Package metadata: - License: Apache-2.0 - Core deps: `agentfield`, `pydantic>=2.0`, `pyhcl2>=2.0` +## Go implementation + +The maintained node lives under [`go/`](go/README.md), and installing the bare +repository URL gives you this implementation as the `cloudsecurity` node. +(`af run` assigns a free port from 8001 and exports it as `PORT`; `8015` is the +binary's own default, which is what a bare `go run`, `make run` or +`docker-compose.go.yml` gives you — pass `af run cloudsecurity-af --port 8015` +to pin it.) It registers the same reasoners under the same names, +drives the same control-plane DAG, and reads the same environment variables — +one static binary, no per-node virtualenv to build. The Python implementation +remains available through `python -m cloudsecurity_af.app`, the root Docker +Compose stack, or a local-path install (`af install ./cloudsecurity-af`), which +does not follow the redirect. + +```bash +docker compose up -d # Python stack (control plane + cloudsecurity-af :8005) +docker compose -f docker-compose.go.yml up -d # adds the Go node as cloudsecurity-go :8015 +``` + +The Go add-on Compose file uses the distinct node id `cloudsecurity-go` only so +both implementations can run against one control plane during a changeover. +Build, run, Docker/compose and environment docs live in +[`go/README.md`](go/README.md). + ## Open Core Model CloudSecurity uses an open-core model: `scan` and `prove` remain open source (Apache 2.0), while enterprise adds org-scale controls such as multi-account management, scheduled monitoring, and RBAC/audit features. See [`docs/OPEN_CORE.md`](docs/OPEN_CORE.md) for the full tier breakdown. diff --git a/agentfield-package.yaml b/agentfield-package.yaml index 1b20aab..5d112f0 100644 --- a/agentfield-package.yaml +++ b/agentfield-package.yaml @@ -4,12 +4,35 @@ version: 0.1.0 description: Cloud security posture agent node (read-only scans of AWS/GCP/Azure) author: Agent-Field +# The Go node in go/ is the maintained CloudSecurity 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/cloudsecurity-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 cloudsecurity_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/cloudsecurity-af//go + entrypoint: start: python -m cloudsecurity_af.app healthcheck: /health agent_node: - node_id: cloudsecurity-af + # `cloudsecurity`, not `cloudsecurity-af`: src/cloudsecurity_af/app.py uses + # `NODE_ID = os.getenv("NODE_ID", "cloudsecurity")`, so that is the id the + # process registers and reports on /health, and every reasoner target in + # reasoners/phases.py and orchestrator.py is f"{NODE_ID}.". + # `af run` compares the manifest's node_id against /health's node_id and + # kills the process when they differ, so the stale `cloudsecurity-af` broke + # the local-path install documented above. + node_id: cloudsecurity default_port: 8005 user_environment: diff --git a/docker-compose.go.yml b/docker-compose.go.yml new file mode 100644 index 0000000..b040448 --- /dev/null +++ b/docker-compose.go.yml @@ -0,0 +1,114 @@ +# CloudSecurity-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 `cloudsecurity-af` node on :8005) 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: +# +# cloudsecurity-go -> node id "cloudsecurity-go", :8015 +# +# 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: cloudsecurity-af-go) that joins the +# Python stack's network as an EXTERNAL reference, so +# AGENTFIELD_SERVER=http://agentfield:8080 resolves. The control plane (service +# `agentfield`) lives in the Python project, so there is NO `depends_on` here — +# bring the Python stack up first. +# +# COMPOSE_PROJECT_NAME caveat: the external network name below +# (cloudsecurity-af_default) is the Python project's default-project-name +# resource. The Python docker-compose.yml has NO explicit `name:`, so its +# project name defaults to the compose directory's basename — +# `cloudsecurity-af` when the repo is checked out as a directory of that name. +# If you set COMPOSE_PROJECT_NAME for the Python stack (or the checkout +# directory is named something else), override the external `name:` below to +# match `_default`. +name: cloudsecurity-af-go + +services: + cloudsecurity-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 cloudsecurity-af's compose is "agentfield" + - AGENTFIELD_API_KEY=${AGENTFIELD_API_KEY:-} + - NODE_ID=cloudsecurity-go + - PORT=8015 + - AGENT_CALLBACK_URL=http://cloudsecurity-go:8015 + - 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} + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} + # XDG_DATA_HOME is set here and NOT in docker-compose.yml — a deliberate + # packaging difference (divergence 6 in go/README.md). Both nodes read the + # variable identically and fall back to /opencode-shared-data; + # pointing it at the opencode-data volume below keeps the harness's data + # home across container restarts, which the Python stack does not do. + - XDG_DATA_HOME=/home/cloudsecurity/.local/share + # SEC_AF_WORKSPACES_DIR is deliberately unset, exactly as in + # docker-compose.yml: the /workspaces bind below is owned by the HOST uid, + # not by the image's cloudsecurity user, so the node must be allowed to + # run app.py::_workspaces_root's write probe and fall back to + # ~/.sec-af/workspaces. Setting it skips the probe and turns every remote + # repo_url clone into an HTTP 500. + # Cloud provider credentials (read-only, for prove mode) + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-} + - AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:-us-east-1} + - GOOGLE_APPLICATION_CREDENTIALS=${GOOGLE_APPLICATION_CREDENTIALS:-} + - AZURE_SUBSCRIPTION_ID=${AZURE_SUBSCRIPTION_ID:-} + - AZURE_TENANT_ID=${AZURE_TENANT_ID:-} + - AZURE_CLIENT_ID=${AZURE_CLIENT_ID:-} + - AZURE_CLIENT_SECRET=${AZURE_CLIENT_SECRET:-} + ports: + - "8015:8015" + volumes: + # The Python stack does NOT use a named workspaces volume — its compose + # bind-mounts ${SCAN_REPOS_PATH:-./workspaces} into /workspaces. Mirror + # that bind (same variable, same default) so pointing SCAN_REPOS_PATH at a + # host directory gives both nodes the same clones. + # + # It only does that when that directory is writable by uid 10001, the user + # both images run as. With the DEFAULT ./workspaces, Docker auto-creates + # the bind target root-owned, the write probe above fails, and each node + # falls back to its own container-local ~/.sec-af/workspaces — two + # separate checkouts, which is correct behaviour (it matches the Python + # node exactly) but is not a shared host directory. + - ${SCAN_REPOS_PATH:-./workspaces}:/workspaces + - opencode-data:/home/cloudsecurity/.local/share + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8015/health"] + # Same cadence as this repo's Python node (docker-compose.yml:53-58) and + # as the image's own HEALTHCHECK (go/Dockerfile), so the two nodes in one + # stack report unhealthy on the same schedule. The 60s/30s/5/30s values + # this file shipped with were pr-af's, where they matched pr-af's Python + # compose; here they made the Go node take ~5 minutes to go unhealthy + # against the Python node's 90 seconds. + 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: cloudsecurity-af_default + +volumes: + # Node-local opencode data (XDG_DATA_HOME above). The Python compose has no + # counterpart: it leaves XDG_DATA_HOME unset, so its harness data home is a + # container-local tmpdir. The workspaces path, by contrast, is a host bind and + # deliberately not a volume — see the service's SEC_AF_WORKSPACES_DIR note. + opencode-data: {} diff --git a/go/Dockerfile b/go/Dockerfile new file mode 100644 index 0000000..703967c --- /dev/null +++ b/go/Dockerfile @@ -0,0 +1,150 @@ +# CloudSecurity-AF Go node — multi-stage build. +# +# Build from the repo ROOT so the go/ module is in the build context and the +# paths below (go/go.mod, go/) resolve. The docker-compose.go.yml add-on builds +# it exactly this way (build.context: ., dockerfile: go/Dockerfile): +# +# docker build -f go/Dockerfile -t cloudsecurity-af: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 the SDK and every other dependency +# straight 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). This stage is copied from the repo's own Python Dockerfile so both +# images ship the identical, checksum-verified binary. Both ARGs are overridable +# so CI or a local mirror can serve the assets from somewhere else: +# +# 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 +ARG TARGETARCH + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl && \ + rm -rf /var/lib/apt/lists/* + +RUN set -eux; \ + arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \ + mkdir -p /out; \ + cd /out; \ + curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/aforge-linux-${arch}.gz" -o aforge.gz; \ + gunzip -c aforge.gz > aforge; \ + rm aforge.gz; \ + curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/checksums.txt" -o checksums.txt; \ + grep " aforge-linux-${arch}$" checksums.txt | sed 's/ aforge-linux-.*/ aforge/' > aforge.sha256; \ + test -s aforge.sha256; \ + sha256sum -c aforge.sha256; \ + rm 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 go.mod/go.sum change). +COPY go/go.mod go/go.sum ./ +RUN go mod download + +# Copy the rest of the module and build a static binary. +COPY go/ ./ +ENV CGO_ENABLED=0 GOOS=linux +RUN go build -trimpath -ldflags="-s -w" -o /out/cloudsecurity-af ./cmd/cloudsecurity-af + + +# --------------------------------------------------------------------------- +# Stage 2 — runtime: slim Debian mirroring the Python image (opencode CLI + a +# non-root cloudsecurity user), but shipping the single static Go binary instead +# of a Python runtime. The entrypoint generates opencode.json at container start +# from CLOUDSECURITY_MODEL, falling back to HARNESS_MODEL and then to the image +# default — config.py's precedence chain — so the model env vars are honoured +# (the Python image bakes a fixed opencode.json instead). Note that +# HARNESS_MODEL is already set below, so CLOUDSECURITY_MODEL is the variable to +# reach for when overriding the model for a running container. +# --------------------------------------------------------------------------- +FROM debian:bookworm-slim AS runtime + +ARG OPENCODE_VERSION=1.17.15 + +ENV AGENTFIELD_SERVER=http://agentfield:8080 \ + HARNESS_PROVIDER=aforge \ + AGENTFIELD_AFORGE_COMMAND=exec \ + HARNESS_MODEL=openrouter/moonshotai/kimi-k2.5 \ + AI_MODEL=openrouter/moonshotai/kimi-k2.5 \ + PORT=8015 \ + NODE_ID=cloudsecurity \ + HOME=/home/cloudsecurity \ + PATH=/home/cloudsecurity/.opencode/bin:${PATH} \ + XDG_DATA_HOME=/home/cloudsecurity/.local/share + +# XDG_DATA_HOME IS set here, and the Python image does NOT set it — a deliberate +# packaging difference, recorded as divergence 6 in go/README.md. +# +# config.py::provider_env / internal/config.ProviderEnv both read the variable +# and fall back to `/opencode-shared-data`, so the Python container puts +# the harness's data home somewhere container-local that a restart throws away. +# Pointing it at the cloudsecurity user's home (created and chowned below, and +# backed by the named volume `opencode-data` in docker-compose.go.yml) keeps +# that state across restarts. The node CODE is unchanged; only the image is. + +# SEC_AF_WORKSPACES_DIR is deliberately NOT set here. +# +# app.py::_workspaces_root only takes the explicit branch when the variable is +# truthy; otherwise it mkdir's /workspaces, WRITE-PROBES it with a .write_test +# file and falls back to ~/.sec-af/workspaces on OSError. The Python image and +# the Python compose both leave it unset, so that fallback is live for them. +# docker-compose.go.yml bind-mounts a HOST directory onto /workspaces, and a +# bind does not inherit the image's `chown cloudsecurity /workspaces` — it +# arrives owned by the host uid (root when Docker auto-creates ./workspaces). +# Baking the variable skipped the probe, so every remote repo_url clone failed +# with a permission error and surfaced as HTTP 500 where the Python stack on the +# same bind silently fell back and succeeded. + +# System deps: ca-certificates (HTTPS to the LLM/cloud providers), curl +# (healthcheck + opencode installer), git (repo_url clones in _resolve_repo). +# Create the non-root cloudsecurity user (uid/gid 10001) and install the +# opencode CLI as that user so it lands under /home/cloudsecurity/.opencode. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git && \ + groupadd --gid 10001 cloudsecurity && \ + useradd --uid 10001 --gid cloudsecurity --create-home --home-dir /home/cloudsecurity --shell /bin/sh cloudsecurity && \ + su -s /bin/sh cloudsecurity -c "curl -fsSL https://opencode.ai/install | bash -s -- --version ${OPENCODE_VERSION} --no-modify-path" && \ + mkdir -p /workspaces /home/cloudsecurity/.local/share && \ + chown -R cloudsecurity:cloudsecurity /workspaces /home/cloudsecurity && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=builder /out/cloudsecurity-af /usr/local/bin/cloudsecurity-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 cloudsecurity +# Cwd must be writable by the cloudsecurity user: the recon agents write their +# inventory/graph into per-run temp dirs, and the AgentField Go SDK creates its +# schema output dir under the process cwd when a harness call carries no Cwd. +# `/` is root-owned, and $HOME is cloudsecurity-owned in the image AND cannot be +# replaced by a compose bind the way /workspaces is. +WORKDIR /home/cloudsecurity + +EXPOSE 8015 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD curl -f http://localhost:${PORT:-8015}/health || exit 1 + +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +CMD ["/usr/local/bin/cloudsecurity-af"] diff --git a/go/Makefile b/go/Makefile new file mode 100644 index 0000000..d62f87b --- /dev/null +++ b/go/Makefile @@ -0,0 +1,80 @@ +# CloudSecurity-AF Go port — build/test targets. Run from the go/ directory. +# Mirrors the acceptance gate every port task and CI use (docs/DESIGN.md §0.4): +# 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 fmt-check 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). +fmt-check: + @test -z "$$(gofmt -l .)" || { echo "gofmt needed:"; gofmt -l .; exit 1; } + +# The full local gate CI runs: build + vet + tests + gofmt. +check: build vet test fmt-check + +# Format every Go source file in place. +fmt: + gofmt -w . + +# Optional lint pass. Only the ABSENCE of the tool may be a no-op; a lint +# FAILURE must fail the target. +# +# `command -v X && X run || echo ...` cannot express that: a shell A && B || C +# chain runs C whenever EITHER A or B exits non-zero, so a real lint failure +# printed "golangci-lint not installed" and exited 0 — a false green with a +# false reason. An if/then/else is the only correct shape. +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 CloudSecurity node (node id cloudsecurity, default port 8015). +run: + go run ./cmd/cloudsecurity-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 dir. The compose add-on +# (docker-compose.go.yml) lives at the repo root and joins the Python +# cloudsecurity-af stack's external network. +# Override the image tag with: make docker-build IMAGE=myrepo/cloudsecurity-af-go:dev +# +# The tag is `cloudsecurity-af-go`, NOT `cloudsecurity-af`: the root README's +# `docker build --build-arg AFORGE_VERSION=v0.1.0 -t cloudsecurity-af .` builds +# the PYTHON image and resolves to `cloudsecurity-af:latest`, so sharing the tag +# would make whichever image was built last silently own it — a `docker run +# cloudsecurity-af` would then get the other node (port 8005 vs 8015, a +# different entrypoint) with nothing in `docker images` to tell them apart. +# .github/workflows/go.yml already builds `cloudsecurity-af-go:test`. +IMAGE ?= cloudsecurity-af-go:latest + +docker-build: + docker build -f Dockerfile -t $(IMAGE) .. + +# Bring up the Go node (cloudsecurity-go:8015) as an ADD-ON to the Python stack. +# Start the Python stack first (`docker compose up` — it owns the control plane +# and the shared network); this add-on joins that network as an external ref. +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..d5d83f5 --- /dev/null +++ b/go/README.md @@ -0,0 +1,317 @@ +# CloudSecurity AF — Go node + +A Go implementation of the CloudSecurity-AF cloud-infrastructure security scanner. +It registers the same reasoner surface under the same names as the Python node, +exposes a byte-compatible HTTP API, and drives the same control-plane DAG, so the +UI renders the identical multi-phase orchestration graph (see +[Pipeline DAG on the control plane](#pipeline-dag-on-the-control-plane)). +The Python package under `src/cloudsecurity_af/` is untouched; this +implementation lives entirely under `go/`. + +One binary: + +| Binary | Node ID | Default port | Role | +|--------------------|------------------|--------------|-----------------------------------------| +| `cloudsecurity-af` | `cloudsecurity` | `8015` | Full scan pipeline (`scan` and `prove`) | + +Module path: `github.com/Agent-Field/cloudsecurity-af/go`. + +> **Node id vs package name.** The package is named `cloudsecurity-af`; the node +> it registers is `cloudsecurity`. That is the Python node's behaviour too +> (`NODE_ID = os.getenv("NODE_ID", "cloudsecurity")` in `app.py`), so calls are +> `cloudsecurity.scan` / `cloudsecurity.prove`. Both manifests — the root one +> and `go/agentfield-package.yaml` — declare `node_id: cloudsecurity`, the id +> the process actually registers; `go/packaging_test.go` fails the build if +> either drifts from `app.py`'s default. +> +> The two names are not interchangeable on the command line: `af run` and +> `af logs` take the PACKAGE name (`cloudsecurity-af`, the registry key), while +> `af call` takes the NODE id (`cloudsecurity.scan`). + +## Install + +Installing the bare repository URL follows the root manifest's redirect to this +package, so the process you get is the Go node, registered as `cloudsecurity`: + +```bash +af install https://github.com/Agent-Field/cloudsecurity-af +af run cloudsecurity-af # the PACKAGE name — `af run cloudsecurity` is "not installed" +af call cloudsecurity.scan --in '{"repo_url":"https://github.com/org/infra-repo"}' +``` + +`af run` picks a free port starting at 8001 and exports it as `PORT`, so an +installed node does **not** land on 8015 — pass `--port 8015` if you want it +there. `8015` is the binary's own default (a bare `go run ./cmd/cloudsecurity-af` +or `make run`) and what `docker-compose.go.yml` sets explicitly. + +To install the Python node deliberately, clone the repository and run +`af install ./cloudsecurity-af`; local-path installs do not follow the redirect. +`NODE_ID` / `PORT` still override the Go defaults if you want a different +id/port. + +## Reasoners + +Two externally driven reasoners, exactly as `src/cloudsecurity_af/app.py`: + +| Reasoner | Tier | Builds | +|----------|------|--------------------------------------------------------------------| +| `scan` | 1 | static IaC scan; `cloud=None` | +| `prove` | 2 | live scan; `CloudConfig(provider, regions=["us-east-1"], assume_role_arn)` | + +Both accept the same pipeline knobs (`depth`, `severity_threshold`, +`output_formats`, `compliance_frameworks`, `max_cost_usd`, +`max_duration_seconds`, `include_paths`, `exclude_paths`, `is_pr`, +`fail_on_findings`). `scan` additionally takes `base_commit_sha`, `pr_id` and +the two concurrency caps; `prove` additionally takes `cloud_provider`, +`cloud_regions` and `assume_role_arn`. The exact parameter list is published to +the control plane as each reasoner's input schema. + +Behind them are the 20 router reasoners (tagged `cloud`, `security`, +`infrastructure`): four recon agents, seven hunters, the path constructor, the +two provers, the fix generator, and the five `*_phase` drivers. + +## Pipeline DAG on the control plane + +A scan is not one execution. `scan`/`prove` build a `ScanOrchestrator`, which +calls the five phase reasoners through the control plane, and each phase calls +its own agents the same way — so the run renders as this graph: + +``` +scan / prove +├── recon_phase +│ ├── run_iac_reader +│ ├── run_resource_graph_builder (after iac_reader) +│ ├── run_cloud_connector ┐ gather (2) — only tier >= 2 with a cloud config +│ └── run_drift_detector ┘ +├── hunt_phase +│ └── run__hunter × 5 (quick) / 7 (standard, thorough) semaphore max(1,min(3,N)) +├── chain_phase +│ └── run_path_constructor +├── prove_phase +│ └── run_static_prover × K (tier < 2) | run_live_prover × K semaphore 3 +└── remediation_phase + └── run_fix_generator × M +``` + +Every arrow is an `Agent.Call(ctx, ".", kwargs)` with the +same target name and kwargs the Python node uses, so the node/edge multiset is +identical between the two implementations. The handler's `ctx` is threaded +through unchanged — that is what parents the child execution under the `scan` +execution. + +## 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. +- **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. It is never committed. + +The one non-SDK third-party dependency is `github.com/hashicorp/hcl/v2`, which +replaces Python's `pyhcl2` in the deterministic Terraform parser. + +## Build & run locally + +From `go/`: + +```bash +make build # go build ./... +make vet # go vet ./... +make test # go test ./... +make fmt-check # test -z "$(gofmt -l .)" +make check # all four — the CI gate +make fmt # gofmt -w . +make run # run the node (cloudsecurity, :8015) +``` + +`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 + +Multi-stage build: a checksum-verified AForge CLI fetch (the same stage the +Python `Dockerfile` uses), a Go builder, and a slim Debian runtime with the +pinned `opencode` CLI, a non-root `cloudsecurity` user, and the single static +binary at `/usr/local/bin/cloudsecurity-af`. `docker-entrypoint.sh` generates +`opencode.json` at container start from `CLOUDSECURITY_MODEL`, falling back to +`HARNESS_MODEL` and then to the image default — `config.py`'s precedence chain, +so the model env vars are actually honoured (the Python image bakes a fixed +config instead). `CLOUDSECURITY_MODEL` is the one to reach for inside the +image, since the Dockerfile already sets `HARNESS_MODEL`. + +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 cloudsecurity-af-go:latest . +``` + +The tag is `cloudsecurity-af-go`. The root README's +`docker build ... -t cloudsecurity-af .` builds the **Python** image and +resolves to `cloudsecurity-af:latest`, so sharing the tag would make whichever +image was built last silently own it — and the two differ (port 8005 vs 8015, a +different entrypoint). `make docker-build` and CI use the same `-go` tag. + +### 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`). 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 + cloudsecurity-af :8005) +docker compose -f docker-compose.go.yml up -d # adds cloudsecurity-go :8015 +``` + +Adds: + +| Service | Port | Node id | Notes | +|--------------------|--------|--------------------|--------------------| +| `cloudsecurity-go` | `8015` | `cloudsecurity-go` | full scan pipeline | + +The workspaces directory is a **host bind mount**, not a named volume — the +Python compose mounts `${SCAN_REPOS_PATH:-./workspaces}` into `/workspaces` and +the add-on mirrors that exact bind (same variable, same default). Both nodes +resolve a given `repo_url` to the same checkout only when that directory is +writable by uid 10001, the user both images run as: with the default +`./workspaces`, Docker auto-creates the bind target owned by the host uid, so +`app.py::_workspaces_root`'s write probe fails and each node falls back to its +own container-local `~/.sec-af/workspaces`. Set `SCAN_REPOS_PATH` to a +directory uid 10001 can write if you want one shared clone. + +The control plane (`:8080` inside the network) comes from the Python stack via +the external `cloudsecurity-af_default` network; this assumes the Python stack +was brought up with the default project name `cloudsecurity-af` (its compose +has no explicit `name:`, so the project name is the checkout directory's +basename). See the compose file header for the `COMPOSE_PROJECT_NAME` override. +Health: `curl -f http://localhost:8015/health`. + +## Environment variables + +The node is configured entirely through the environment. + +| Variable | Purpose | +|------------------------------|----------------------------------------------------------------------| +| `OPENROUTER_API_KEY` | LLM provider key (OpenRouter) — required | +| `AGENTFIELD_SERVER` | Control-plane URL (default `http://localhost:8080`) | +| `AGENTFIELD_API_KEY` | Control-plane API key (if the CP has auth enabled) | +| `AGENT_CALLBACK_URL` | Base URL the CP uses to reach this node; unset → `http://localhost:` | +| `NODE_ID` | Node ID (default `cloudsecurity`) | +| `PORT` | Listen port (default `8015`) | +| `HARNESS_PROVIDER` | Harness provider (default `aforge`; `opencode` to roll back). `CLOUDSECURITY_PROVIDER` wins over it | +| `AGENTFIELD_AFORGE_COMMAND` | AForge headless command — `exec` (default) or `do` | +| `HARNESS_MODEL` | Harness model. `CLOUDSECURITY_MODEL` wins over it | +| `AI_MODEL` | Model for direct `.ai()` calls. `CLOUDSECURITY_AI_MODEL` wins over it | +| `CLOUDSECURITY_MAX_TURNS` | Harness turn cap (default `50`); a malformed value fails the boot | +| `CLOUDSECURITY_OPENCODE_BIN` | opencode executable (default `opencode`) | +| `CLOUDSECURITY_AFORGE_BIN` | aforge executable (default `aforge`; `AFORGE_BIN` is the fallback) | +| `SEC_AF_WORKSPACES_DIR` | Clone root for remote `repo_url` values (default `/workspaces`, falling back to `~/.sec-af/workspaces`) | +| `CLOUDSECURITY_REPO_PATH` | Repo path used when `repo_url` is neither a directory nor a URL | +| `XDG_DATA_HOME` | Data home forwarded to the harness (default `/opencode-shared-data`). The Go image and `docker-compose.go.yml` set it to `/home/cloudsecurity/.local/share`, backed by the `opencode-data` volume; the Python image leaves it unset — see divergence 6 | +| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` / `AWS_REGION` / `AWS_DEFAULT_REGION` | Read-only AWS credentials forwarded to the harness | +| `GOOGLE_APPLICATION_CREDENTIALS` | GCP credentials forwarded to the harness | +| `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` / `AZURE_TENANT_ID` / `AZURE_SUBSCRIPTION_ID` | Azure credentials forwarded to the harness | + +Note: the code default model is `openrouter/minimax/minimax-m2.5`, while the +Docker image / compose / manifest set +`HARNESS_MODEL=openrouter/moonshotai/kimi-k2.5`. The env var always wins; both +defaults are intentional (they mirror the Python node). + +## Deliberate divergences from the Python node + +Everything else is a 1:1 port (same concurrency bounds, same result key sets, +same error strings, and prompt text that is byte-identical apart from item 7 +below). These are on purpose and are commented at the source: + +1. **Callback URL.** Python hardcodes the fallback + `http://host.docker.internal:8020` — a Docker-Desktop-only host name, on the + wrong port (the node listens on 8005). The Go node leaves `PublicURL` empty + when `AGENT_CALLBACK_URL` is unset, so the SDK uses + `http://localhost:`, which is correct on bare metal. Every + container deployment sets `AGENT_CALLBACK_URL` explicitly anyway. +2. **AI config.** The Go SDK's `ai.Config` rejects an empty API key at + construction, so `AIConfig` is attached only when `OPENROUTER_API_KEY` is + set; and the `openrouter/` routing prefix is stripped for the direct API call + (Python's LiteLLM consumes that prefix itself). The harness model keeps the + prefix. +3. **Failure diagnostics.** Python prints `SCAN ERROR: ` plus a traceback + before returning 500; Go prints the same line without a traceback (Go error + values carry no stack). +4. **Number literals lost by the SDK decoder.** The Go SDK decodes a reasoner + body with a plain `encoding/json` decoder (no `UseNumber`), so every JSON + number reaches the handler as `float64` and the literal's spelling is gone + before the port sees it. Two consequences, both narrow: an integral float in + a free-form `Any` field (`DriftedResource.iac_config`, `ConfigDiff.iac_value`) + re-renders as `7`, where Python keeps `7.0` (`internal/afx/bind.go`); and a + number handed to a `str` parameter is rendered with the integer spelling, so + `{"depth": 4}` gives Python's `"4"` while `{"depth": 4.0}` gives `"4"` where + Python gives `"4.0"` (`internal/afx/handlerinput.go`). Both trade the rarer + reading for the common one — the alternative turns every `{"port": 5432}` + into `5432.0`. +5. **Map key order.** A Go map has no insertion order, so `pyfmt.Dumps` sorts + keys where a Python dict would keep the order it was built in. The two + fields whose Python order is fixed and knowable — `by_severity` (the + `Severity` enum order) and `cost_breakdown` (`_PHASE_ORDER`) — are declared + in `schemas.BySeverityOrder()` / `schemas.CostBreakdownOrder` and rendered in + that order by every renderer, including the `scan` / `prove` reply. Only + `metadata`, whose keys are assembled ad hoc, is still sorted. +6. **`XDG_DATA_HOME` in the image.** The Go `Dockerfile` sets + `XDG_DATA_HOME=/home/cloudsecurity/.local/share` and + `docker-compose.go.yml` backs it with the named volume `opencode-data`; the + Python image and compose set neither, so `provider_env()` falls back to + `/opencode-shared-data` — container-local and lost on restart. The + node CODE is a 1:1 port (`internal/config`'s `ProviderEnv` reads the same + variable with the same fallback); only the packaging differs, so the harness + data home survives a container restart on the Go side. +7. **Key order in the harness prompt's JSON Schema block.** Python appends + `json.dumps(Model.model_json_schema(), indent=2)` to every + `app.harness(..., schema=Model)` prompt, which preserves pydantic's field + declaration order. The Go path is a `map[string]any` end to end — + `harnessx.SchemaFor[T]()` decodes the committed fixture into one and the SDK + renders it with `json.MarshalIndent`, which sorts map keys — so the block + carries the same schema with the keys alphabetised. For `HuntResult` that is + a 199-line diff of identical content at an identical 4174 bytes, so the SDK's + 4000-token large-schema branch takes the same branch on both sides. Only the + ORDER differs: `harnessx.LoadEmbeddedSchema` decodes with `UseNumber` so + pydantic's numeric literals (`"default": 0.0`) survive verbatim, and + `go/scripts/gen_schemas.py` writes the fixtures with `sort_keys=True` so the + committed file matches the prompt byte for byte (pinned by + `TestEmbeddedSchemas_AreKeySortedLikeTheSDKWillRenderThem`). The order itself + is not fixable inside this port — both `agent.Harness` and + `harness.BuildPromptSuffix` take a `map[string]any`, so an order-preserving + schema type would have to come from the SDK. +8. **pydantic scalar coercion the SDK harness cannot reproduce.** + `afx.Bind` ports pydantic v2's lax scalar ladder (`internal/afx/lax.go`), so + every ported `model_validate` accepts what Python accepts. The Go SDK's + HARNESS validation does not: it decodes the model reply with a plain + `json.Unmarshal` AND validates it against the committed pydantic schema, so + a model that writes `"iac_line": "12"` or `"security_relevant": "true"` + burns the schema-retry budget and ends as a harness error, where the Python + node's `schema.model_validate(data)` accepts it on the first attempt. + Closing this needs a change in `sdk/go/harness`. +9. **Non-finite floats.** Python's `float()` and pydantic both accept `"NaN"`, + `"Infinity"`, `"-inf"` and the overflowing `"1e999"` for `max_cost_usd`, and + the scan then runs (every budget comparison against a non-finite number is + False). JSON has no literal for a non-finite number and `encoding/json` + refuses to marshal one, so Go rejects those four spellings with the ordinary + "cannot unmarshal string ... into float64" (`afx.pyFloat`). Any finite + spelling — including `"2.5"` — is coerced exactly as pydantic does. + +## Testing + +`go test ./...` covers the port end to end: golden tests compare every prompt +builder byte-for-byte against the Python originals (`scripts/gen_golden.py` +regenerates the fixtures with the Python interpreter), schema fixtures under +`internal/harnessx/testdata/schemas/` are the real `model_json_schema()` output, +the phase tests assert the exact `.call` targets, kwargs and semaphore bounds, +and the node tests pin the 22-reasoner registration surface and the 400/500 +error mapping. diff --git a/go/agentfield-package.yaml b/go/agentfield-package.yaml new file mode 100644 index 0000000..83b1718 --- /dev/null +++ b/go/agentfield-package.yaml @@ -0,0 +1,72 @@ +config_version: v1 +# This is THE CloudSecurity-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 cloudsecurity-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: cloudsecurity-af +version: 0.1.0 +description: AI-Native Cloud Infrastructure Security Scanner +author: Agent-Field +language: go # explicit (also auto-detected from go/go.mod) + +entrypoint: + build: ./cmd/cloudsecurity-af + start: bin/cloudsecurity-af + healthcheck: /health + +agent_node: + # `cloudsecurity`, NOT `cloudsecurity-af` — the package NAME and the node id + # differ. src/cloudsecurity_af/app.py uses + # `NODE_ID = os.getenv("NODE_ID", "cloudsecurity")`, every reasoner target in + # reasoners/phases.py and orchestrator.py is built as + # f"{NODE_ID}.", and the README's own examples call + # `cloudsecurity.scan`. Both manifests state the id the process actually + # registers, so `af call cloudsecurity.scan` resolves after an install and + # `af run`'s /health identity check passes. + node_id: cloudsecurity + # 8015 rather than the Python node's 8005: during the changeover both may be + # running, and triggers resolve by node id, not port. + default_port: 8015 + +# Same keys as the root manifest — the Go node reads the same environment. +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 by default; opencode for rollback) + default: aforge + - name: AGENTFIELD_AFORGE_COMMAND + description: AForge headless command + default: exec + - name: CLOUDSECURITY_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: AWS_ACCESS_KEY_ID + description: AWS access key for read-only scanning + type: secret + scope: node + - name: AWS_SECRET_ACCESS_KEY + description: AWS secret key for read-only scanning + type: secret + scope: node + - name: AWS_DEFAULT_REGION + description: Default AWS region to scan + default: us-east-1 diff --git a/go/docker-entrypoint.sh b/go/docker-entrypoint.sh new file mode 100755 index 0000000..38d8936 --- /dev/null +++ b/go/docker-entrypoint.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Generate the opencode config at container start so the model env vars are +# honored — CLOUDSECURITY_MODEL first, then HARNESS_MODEL (see below). +# +# The Python image bakes opencode.json with a hardcoded model and a two-model +# provider whitelist, which means the model env vars are 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 the same +# precedence chain config.py uses fixes that — the env var wins when set, and we +# fall back to the benchmarked default when it isn't. +# +# aforge (the default provider) ignores this file entirely; it is written +# unconditionally so HARNESS_PROVIDER=opencode works without a rebuild. +set -e + +# Same precedence config.py:91-96 uses for harness_model, and that +# internal/config mirrors: CLOUDSECURITY_MODEL -> HARNESS_MODEL -> default. +# Reading only HARNESS_MODEL wrote an opencode.json pinned to a DIFFERENT model +# than the one the node passes to opencode with -m, which is exactly the +# whitelist mismatch this script exists to prevent. The image bakes +# HARNESS_MODEL, so without the first hop CLOUDSECURITY_MODEL could never win. +MODEL="${CLOUDSECURITY_MODEL:-${HARNESS_MODEL:-openrouter/moonshotai/kimi-k2.5}}" + +# opencode keys models under a provider by the slug *without* the provider +# prefix, e.g. "openrouter/z-ai/glm-5.2" -> provider "openrouter", key "z-ai/glm-5.2". +MODEL_KEY="${MODEL#openrouter/}" + +CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode" +mkdir -p "$CONFIG_DIR" + +cat > "$CONFIG_DIR/opencode.json" <