defmodule TarakanWeb.API.ScanController do use TarakanWeb, :controller alias Tarakan.Repositories alias Tarakan.Scans alias Tarakan.FindingMemory alias Tarakan.FindingSignals alias Tarakan.Suppressions @doc """ Records a review submitted by a contributor or external harness. Every submission self-reports its provenance (`agent`, `human`, or `hybrid`) and review kind. Agent and hybrid reviews also identify the model and prompt version. The document is required so a client bug cannot silently record an empty result. Self-reported provenance is not an identity attestation and is never sufficient for reputation or publication on its own. """ def create(conn, %{"host" => host_slug, "owner" => owner, "name" => name} = params) do scope = conn.assigns.current_scope with {:repository, %{} = repository} <- {:repository, visible_repository(host_slug, owner, name, scope)}, {:document, {:ok, findings_json}} <- {:document, encode_document(params)} do attrs = %{ "commit_sha" => params["commit_sha"], "model" => params["model"], "prompt_version" => params["prompt_version"], "run_id" => params["run_id"], "provenance" => params["provenance"] || "agent", "review_kind" => params["review_kind"] || "code_review", "notes" => params["notes"], "findings_json" => findings_json } case Tarakan.Reports.publish_report(scope, repository, attrs) do {:ok, scan} -> conn |> put_status(:created) |> json(scan_json(scan, repository)) {:error, :commit_not_found} -> unprocessable(conn, %{commit_sha: ["commit not found in this repository on GitHub"]}) {:error, reason} when reason in [:identity_changed, :not_public, :commit_mismatch] -> unprocessable(conn, %{ commit_sha: ["repository identity or commit could not be bound safely on GitHub"] }) {:error, :rate_limited} -> upstream_unavailable(conn, "GitHub is rate limiting requests; try again shortly") {:error, :unavailable} -> upstream_unavailable(conn, "GitHub could not be reached; try again shortly") {:error, :unauthorized} -> conn |> put_status(:forbidden) |> json(%{error: "this account is not authorized to submit reviews"}) {:error, :submission_limit} -> conn |> put_resp_header("retry-after", "86400") |> put_status(:too_many_requests) |> json(%{error: "daily review submission limit reached"}) {:error, :submission_rate_limited} -> conn |> put_resp_header("retry-after", "60") |> put_status(:too_many_requests) |> json(%{error: "review submission rate exceeded"}) {:error, :secrets_detected} -> unprocessable(conn, %{ document: ["remove secrets or credentials before publishing a report"] }) {:error, %Ecto.Changeset{} = changeset} -> unprocessable(conn, changeset_errors(changeset)) end else {:repository, nil} -> conn |> put_status(:not_found) |> json(%{error: "repository is not registered with Tarakan"}) {:document, :error} -> unprocessable(conn, %{document: ["is required and must be a Tarakan Scan Format object"]}) end end @doc """ Returns compact, prompt-safe canonical finding memory for a repository. Ships the suppression corpus alongside it so a client can tell its agent what has already been judged a non-bug here before spending tokens rediscovering it. """ def memory(conn, %{"host" => host_slug, "owner" => owner, "name" => name} = params) do case visible_repository(host_slug, owner, name, conn.assigns.current_scope) do %{} = repository -> findings = repository |> FindingMemory.list_repository_memory(limit: 300) |> Enum.map(&memory_finding_json(&1, params["commit_sha"])) suppressions = Suppressions.for_repository(repository, limit: 200) json(conn, %{ repository: "#{repository.owner}/#{repository.name}", target_commit_sha: params["commit_sha"], findings: findings, suppressions: %{ note: "Findings the record already judged non-bugs. Do not re-report a " <> "suppressed finding unless you have new evidence that changes the verdict.", repository: suppressions.repository, patterns: suppressions.patterns } }) nil -> conn |> put_status(:not_found) |> json(%{error: "repository is not registered"}) end end @doc "Records a confirm, dispute, or fixed verdict on one canonical finding." def finding_verdict( conn, %{ "host" => host_slug, "owner" => owner, "name" => name, "public_id" => public_id } = params ) do scope = conn.assigns.current_scope case visible_repository(host_slug, owner, name, scope) do nil -> conn |> put_status(:not_found) |> json(%{error: "repository is not registered"}) repository -> attrs = %{ "commit_sha" => params["commit_sha"], "verdict" => params["verdict"], "provenance" => params["provenance"] || "agent", "notes" => params["notes"], "evidence" => params["evidence"], # An adversarial check was asked to break the finding. Reported by the # worker because only it knows which prompt it ran. "adversarial" => params["adversarial"] == true, # Client-attested reproduction: the worker ran the PoC itself. "repro_status" => params["repro_status"], "repro_runtime" => params["repro_runtime"], "repro_artifact" => params["repro_artifact"], "repro_transcript" => params["repro_transcript"], "client_ip" => TarakanWeb.Plugs.ClientIp.remote_ip_string(conn) } case FindingMemory.record_check(scope, repository, public_id, attrs) do {:ok, _check, canonical} -> conn |> put_status(:created) |> json(canonical_finding_json(canonical)) {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "canonical finding not found"}) {:error, :commit_not_found} -> unprocessable(conn, %{commit_sha: ["finding was not observed at this commit"]}) {:error, :conflict_of_interest} -> conn |> put_status(:conflict) |> json(%{error: "a finding submitter cannot independently verify it"}) {:error, :secrets_detected} -> unprocessable(conn, %{ notes: ["remove secrets or credentials before submitting a check"] }) {:error, :unauthorized} -> conn |> put_status(:forbidden) |> json(%{error: "this credential is not authorized to verify findings"}) {:error, %Ecto.Changeset{} = changeset} -> unprocessable(conn, changeset_errors(changeset)) end end end @doc """ Lists reviews visible to the caller. A reviewer-tier credential with `reviews:read` sees restricted findings; other callers see only disclosed reviews, redacted per the platform's disclosure rules. """ def index(conn, %{"host" => host_slug, "owner" => owner, "name" => name}) do scope = conn.assigns.current_scope case visible_repository(host_slug, owner, name, scope) do %{} = repository -> # Repository visibility is resolved the same way as the web record; # sensitive findings are additionally gated per-scan by list_scans. scans = Scans.list_scans(scope, repository) encoded = Enum.map(scans, &scan_summary_json/1) json(conn, %{reports: encoded}) nil -> conn |> put_status(:not_found) |> json(%{error: "repository is not registered with Tarakan"}) end end @doc """ Records a verdict (and optional proof-of-concept) on a review. The caller must be an independent qualified reviewer and not the review's submitter. """ def verdict(conn, %{"host" => host_slug, "owner" => owner, "name" => name, "id" => id} = params) do scope = conn.assigns.current_scope with {:repository, %{} = repository} <- {:repository, visible_repository(host_slug, owner, name, scope)}, {:scan_id, {scan_id, ""}} <- {:scan_id, Integer.parse(id)}, {:scan, {:ok, scan}} <- {:scan, Scans.get_scan(scope, scan_id)}, {:owned, true} <- {:owned, scan.repository_id == repository.id} do attrs = %{ "verdict" => params["verdict"], "provenance" => params["provenance"] || "agent", "notes" => params["notes"], "evidence" => params["evidence"], "client_ip" => TarakanWeb.Plugs.ClientIp.remote_ip_string(conn) } case Scans.record_confirmation(scope, scan, attrs) do {:ok, _confirmation} -> {:ok, updated} = Scans.get_scan(scope, scan_id) conn |> put_status(:created) |> json(scan_summary_json(updated)) {:error, :conflict_of_interest} -> conn |> put_status(:conflict) |> json(%{error: "the submitter of a review cannot verify it"}) {:error, :secrets_detected} -> unprocessable(conn, %{ notes: ["remove secrets or credentials before submitting a check"] }) {:error, :unauthorized} -> conn |> put_status(:forbidden) |> json(%{error: "this credential is not authorized to verify reviews"}) {:error, %Ecto.Changeset{} = changeset} -> unprocessable(conn, changeset_errors(changeset)) {:error, reason} -> upstream_unavailable(conn, "verdict could not be recorded (#{inspect(reason)})") end else {:repository, nil} -> conn |> put_status(:not_found) |> json(%{error: "repository is not registered"}) {:scan_id, _invalid} -> conn |> put_status(:not_found) |> json(%{error: "review not found"}) {:scan, {:error, :not_found}} -> conn |> put_status(:not_found) |> json(%{error: "review not found"}) {:owned, false} -> conn |> put_status(:not_found) |> json(%{error: "review not found for this repository"}) end end defp visible_repository(host_slug, owner, name, scope) do case Tarakan.Hosts.host_for_slug(host_slug) do {:ok, host} -> Repositories.get_visible_repository(host, owner, name, scope) :error -> nil end end defp scan_summary_json(scan) do %{ id: scan.id, commit_sha: scan.commit_sha, provenance: scan.provenance, review_kind: scan.review_kind, model: scan.model, prompt_version: scan.prompt_version, run_id: scan.run_id, review_status: scan.review_status, visibility: scan.visibility, verified: not is_nil(scan.verified_at), findings_count: scan.findings_count, details_visible: scan.details_visible, submitter: scan.submitted_by && scan.submitted_by.handle, findings: scan_findings_json(scan), confirmations: scan_confirmations_json(scan) } end defp scan_findings_json(%{details_visible: true, findings: findings}) when is_list(findings) do Enum.map(findings, fn finding -> %{ public_id: finding.public_id, canonical_finding_id: finding.canonical_finding && finding.canonical_finding.public_id, disposition: finding.disposition, file: finding.file_path, line_start: finding.line_start, line_end: finding.line_end, severity: finding.severity, title: finding.title, description: finding.description } end) end defp scan_findings_json(_scan), do: [] defp scan_confirmations_json(%{confirmations: confirmations}) when is_list(confirmations) do Enum.map(confirmations, fn confirmation -> %{ verdict: confirmation.verdict, provenance: confirmation.provenance, verifier: confirmation.account && confirmation.account.handle } end) end defp scan_confirmations_json(_scan), do: [] defp encode_document(%{"document" => document}) when is_map(document) do {:ok, Jason.encode!(document)} end defp encode_document(_params), do: :error defp scan_json(scan, repository) do base = TarakanWeb.Endpoint.url() findings = public_finding_links(scan, base) %{ id: scan.id, kind: "report", repository: "#{repository.owner}/#{repository.name}", commit_sha: scan.commit_sha, commit_committed_at: scan.commit_committed_at, model: scan.model, prompt_version: scan.prompt_version, run_id: scan.run_id, provenance: scan.provenance, provenance_attestation: "self_reported", review_kind: scan.review_kind, findings_count: scan.findings_count, verified: Scans.Scan.verified?(scan), review_status: scan.review_status, visibility: scan.visibility, disclosed: scan.visibility in ["public", "public_summary"], record_url: base <> TarakanWeb.RepositoryPaths.repository_security_path(repository), findings: findings } end defp public_finding_links(%{findings: findings}, base) when is_list(findings) do Enum.map(findings, fn finding -> %{ public_id: finding.public_id, title: finding.title, severity: finding.severity, url: base <> "/findings/#{finding.public_id}" } end) end defp public_finding_links(_scan, _base), do: [] defp memory_finding_json(finding, target_commit_sha) do finding |> Map.put(:same_commit, finding.last_seen_commit_sha == target_commit_sha) |> Map.update!(:description, &String.slice(&1, 0, 1200)) end @doc """ Records a rubric-scored severity for one canonical finding. The submitter's original claim is never overwritten; this is stored beside it. """ def finding_severity( conn, %{"host" => host_slug, "owner" => owner, "name" => name, "public_id" => public_id} = params ) do scope = conn.assigns.current_scope with %{} = repository <- visible_repository(host_slug, owner, name, scope), {:ok, finding} <- FindingMemory.get(repository, public_id), {:ok, updated} <- FindingSignals.calibrate_severity( scope, finding, params["severity"], params["rubric"] ) do json(conn, canonical_finding_json(updated)) else nil -> conn |> put_status(:not_found) |> json(%{error: "repository is not registered"}) {:error, :not_found} -> not_found_finding(conn) {:error, :unauthorized} -> forbidden(conn) {:error, %Ecto.Changeset{} = changeset} -> unprocessable(conn, changeset_errors(changeset)) end end @doc """ Stores the worker's embedding of a finding's code context and clusters it. Vectors are only comparable within one model, so `embedding_model` is required and clustering never crosses models. """ def finding_embedding( conn, %{"host" => host_slug, "owner" => owner, "name" => name, "public_id" => public_id} = params ) do scope = conn.assigns.current_scope with %{} = repository <- visible_repository(host_slug, owner, name, scope), {:ok, finding} <- FindingMemory.get(repository, public_id), {:ok, updated} <- FindingSignals.record_embedding( scope, finding, params["embedding"], params["embedding_model"] ) do json(conn, canonical_finding_json(updated)) else nil -> conn |> put_status(:not_found) |> json(%{error: "repository is not registered"}) {:error, :not_found} -> not_found_finding(conn) {:error, :unauthorized} -> forbidden(conn) {:error, %Ecto.Changeset{} = changeset} -> unprocessable(conn, changeset_errors(changeset)) end end defp not_found_finding(conn) do conn |> put_status(:not_found) |> json(%{error: "canonical finding not found"}) end defp forbidden(conn) do conn |> put_status(:forbidden) |> json(%{error: "not authorized to record signals on this finding"}) end defp canonical_finding_json(finding) do %{ public_id: finding.public_id, status: finding.status, commit_sha: finding.last_seen_commit_sha, detections_count: finding.detections_count, distinct_submitters_count: finding.distinct_submitters_count, distinct_models_count: finding.distinct_models_count, confirmations_count: finding.confirmations_count, disputes_count: finding.disputes_count, refutations_count: finding.refutations_count, survived_refutations_count: finding.survived_refutations_count, reproductions_count: finding.reproductions_count, reproduced_at: finding.reproduced_at, calibrated_severity: finding.calibrated_severity, code_pattern_key: finding.code_pattern_key, verified: finding.status == "verified" } end defp unprocessable(conn, errors) do conn |> put_status(:unprocessable_entity) |> json(%{errors: errors}) end defp upstream_unavailable(conn, message) do conn |> put_status(:service_unavailable) |> json(%{error: message}) end defp changeset_errors(changeset), do: TarakanWeb.ChangesetErrors.to_map(changeset) end