Example investigation

What comes back after an Agent reads your code.

One report, walked through end to end: the claims, the code behind them, the assessment, the parts static analysis could not settle, and what a person does next.

Illustrative example using fictional application code.The repository, report and findings below are invented to show the shape of the output. They are not customer evidence and no investigation was run to produce this page.

The report as it arrived

Reports reach a workspace as email. The body below is what the researcher sent; the original message stays attached to the report for the whole life of the case.

GET /v1/invoices/export returns another account's invoices

External researcher, address withheld in this example · Received by email to the workspace intake address

Open

Any authenticated customer can export another customer's invoices. Send a request with a valid session and account_id set to the victim's account UUID; the response contains invoice numbers, totals and issue dates.

Steps given

  1. Sign in as a customer session that carries the invoices:read scope.
  2. Request GET /v1/invoices/export?account_id=<another account's UUID>.
  3. The response is a CSV of the other account's invoices.

Claimed impact

Cross-tenant disclosure of billing data to any account holder. No privilege beyond a normal customer session is required.

Scope of the investigation

The investigator works inside the boundaries the Agent was configured with, not boundaries the report can widen.

Repository
northwind/billing-api
Revision
9f31c7ad4b6e, HEAD resolved once
Tools
Five read-only repository tools. No shell, no writes, no requests to running services.
Claims extracted
3 distinct security claims from the report
Evidence recorded
5 code references, including one that counters the claim
Not attempted
Replaying the proof of concept, or any request against a deployed environment

The claims, one at a time

A report is usually several claims wearing one coat. Each one gets its own finding, because “valid” and “invalid” are not the only honest answers to a partial result.

  1. 01The export endpoint selects invoice rows using an account identifier taken from the request.Supported

    internal/http/invoices.go reads account_id from the query string and passes it to billing.Store.Export unchanged.

  2. 02Nothing in the request path compares that identifier with the authenticated session's account.Supported

    The route is wrapped in requireSession and requireScope. Both run before the handler, and neither reads account_id. The identity in the context is available; the export path does not use it.

  3. 03The reported request therefore returns another account's invoice rows.Likely supported

    The store filters on the supplied identifier and returns every matching row. Confirming this end to end needs a live request against a deployed environment, which static analysis cannot do.

The evidence

Every reference is a repository, a commit, a path and a line range, with the reasoning that connects it to a claim. Source lines are shown here because this is a fictional file; in the product the Agent submits the reference and the explanation, not the code.

internal/http/invoices.golines 5–16Supports the claim

The handler takes account_id straight from the query string and hands it to the store. The identity that requireSession put in the request context is never read in this function, so nothing here constrains the requested account to the caller's own.

// exportInvoices streams every invoice row that belongs to the account_id
// supplied in the query string.
func (s *Server) exportInvoices(w http.ResponseWriter, r *http.Request) {
	accountID := r.URL.Query().Get("account_id")
	if accountID == "" {
		writeError(w, http.StatusBadRequest, "account_id is required")
		return
	}
	rows, err := s.invoices.Export(r.Context(), accountID)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "export failed")
		return
internal/billing/store.golines 3–14Supports the claim

The query filters on the supplied identifier and returns every matching row. The store is given no session, so the only way to keep a caller inside their own account is for the caller above it to check first.

import "context"

// Export returns every invoice that belongs to accountID. The identifier is a
// caller-supplied argument; this layer has no session to compare it with.
func (s *Store) Export(ctx context.Context, accountID string) ([]Invoice, error) {
	rows, err := s.db.QueryContext(ctx, `
		SELECT id, number, total_cents, issued_at
		FROM invoices
		WHERE account_id = $1
		ORDER BY issued_at DESC`, accountID)
	if err != nil {
		return nil, err
internal/http/refunds.golines 9–17Counters the claim

Checked as counterevidence: the codebase does contain an account comparison, on the refund route, and it returns 404 before touching the ledger. That is the check the export path is missing, and it shows the omission is specific to export rather than a codebase that never compares accounts.

Checked as counterevidence. It was found, and it does not disprove the claim.

	invoice, err := s.invoices.Get(r.Context(), r.PathValue("id"))
	if err != nil {
		writeError(w, http.StatusNotFound, "invoice not found")
		return
	}
	if invoice.AccountID != identity.AccountID {
		writeError(w, http.StatusNotFound, "invoice not found")
		return
	}
internal/http/routes.golines 6–11Context

The reported endpoint exists in this commit and sits behind requireSession and requireScope("invoices:read"). Both middleware run before the handler; neither receives the account_id from the query string.

// requireSession; each route also declares the scope it needs.
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
	mux.Handle("GET /v1/invoices/export",
		s.requireSession(s.requireScope("invoices:read")(http.HandlerFunc(s.exportInvoices))))
	mux.Handle("GET /v1/invoices",
		s.requireSession(s.requireScope("invoices:read")(http.HandlerFunc(s.listInvoices))))
internal/http/middleware.golines 20–34Context

This is the control a reader would expect to find. It answers "may this session call this endpoint" and not "which account may this session act on". The identity in the context does carry AccountID; the export path simply never compares it.


// requireScope checks that the session carries the named scope. It says
// nothing about which account the session may act on.
func (s *Server) requireScope(scope string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			identity := identityFrom(r.Context())
			if !identity.HasScope(scope) {
				writeError(w, http.StatusForbidden, "missing scope")
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}

The assessment

Automated assessmentAI · Likely valid
likely_supported

The code evidence supports the reported claim well enough to act on, and a human still decides. It is not a statement about your production environment.

The claim has two halves and both hold in this commit. The export handler reads account_id from the query string and never looks at the authenticated identity, and the query behind it filters on that value alone. The one control in the chain, requireScope, answers a different question: it confirms the session may call the endpoint, not which account it may read.

The codebase does compare accounts elsewhere. The refund route looks up the invoice and returns 404 when invoice.AccountID does not match the session, which is the check the export path is missing. That is the reason for a likely-supported reading rather than a stronger one: the report describes a real gap in this repository, and nothing in it rules out a compensating control somewhere outside the repository.

What this is not. It is not a statement that your production environment is exploitable, and it is not a decision. The next step is a person reading it.

What the investigation could not settle

An assessment that hides its gaps is worse than no assessment. These stayed on the report.

Suggested engineer validation

The investigation also proposes what would settle each open question, in the order that costs the least to check.

  1. Add a test that signs in as account A, calls the export with account B's identifier, and asserts a 404 or an empty result. That single test settles the claim.
  2. Decide where the check belongs. The refund route compares in the handler; the store takes a bare accountID and will keep trusting whatever the caller passes.
  3. Look for other handlers that read an account identifier from the request and call a store method that takes one.

The same report, second claim

Automated assessmentAI · Needs more evidence
insufficient_evidence

Exports are also forwarded to a partner address by a nightly job.

This is a normal outcome, not a failure and not a rejection. The report is not dismissed, and nothing is written to the report's decision. A human decides whether to extend repository access and rerun, or to ask the researcher where the job runs.

The investigation could not find the job. The operations repository that would contain it is not in the Agent's allowlist, so there is no evidence either way.

“Not confirmed” never silently becomes “false positive”. A report that was not reproduced keeps its evidence, its conversation and its place in the history.

The five automated assessments

These are the only assessments the product can produce, and the same vocabulary appears in the app.

ValueShown asMeans
supportedAI · Valid vulnerabilityThe code evidence directly supports the claim.
likely_supportedAI · Likely validThe evidence strongly suggests the claim holds.
insufficient_evidenceAI · Needs more evidenceNot enough to decide. Not a rejection.
likely_unsupportedAI · Likely not validThe evidence suggests the claim does not hold.
contradicted_by_codeAI · Not a vulnerabilityThe code directly contradicts the claim.

A human disposition is stored separately and is never replaced by any of these: valid, invalid, informational, duplicate, or out of scope. Both stay visible, so a disagreement with the model is part of the record.

Deliberately left out of this page

The product records these for every investigation. Inventing values for a fictional report would be inventing measurements, so they are named instead of filled in.

See it against your own repository.

The same output, on a report you actually received, with the evidence pointing at code your team owns.