Initial commit on Forgejo
All checks were successful
CI and deploy / Test (push) Successful in 4m52s
CI and deploy / Deploy production (push) Successful in 23s

Fresh repository history for elektrine/tarakan hosted at
https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
Maxfield Luke 2026-07-29 04:43:40 -04:00
commit af6077b9c3
455 changed files with 78366 additions and 0 deletions

View file

@ -0,0 +1,108 @@
defmodule TarakanWeb.API.ClientAuthController do
use TarakanWeb, :controller
alias Tarakan.Accounts.{ApiCredentials, ClientAuthorizations}
alias TarakanWeb.BrowserRateLimit
alias TarakanWeb.Plugs.ClientIp
@doc "Starts a short-lived browser login for Tarakan Client."
def start(conn, params) do
if BrowserRateLimit.allowed?(:client_auth_start_ip, ClientIp.remote_ip_bucket(conn)) do
attrs = %{"client_name" => params["client_name"] || "Tarakan Client"}
case ClientAuthorizations.start(attrs) do
{:ok, device_code, user_code, authorization} ->
path = ~p"/client/authorize/#{user_code}"
conn
|> no_store()
|> put_status(:created)
|> json(%{
device_code: device_code,
user_code: user_code,
verification_uri: TarakanWeb.Endpoint.url() <> path,
verification_uri_complete: TarakanWeb.Endpoint.url() <> path,
expires_in: DateTime.diff(authorization.expires_at, DateTime.utc_now(), :second),
interval: ClientAuthorizations.poll_interval_seconds()
})
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> no_store()
|> put_status(:unprocessable_entity)
|> json(%{error: "invalid_client", details: changeset_errors(changeset)})
end
else
rate_limited(conn)
end
end
@doc "Exchanges an approved device code for a scoped API credential exactly once."
def exchange(conn, %{"device_code" => device_code}) do
if BrowserRateLimit.allowed?(:client_auth_exchange_ip, ClientIp.remote_ip_bucket(conn)) do
case ClientAuthorizations.exchange(device_code) do
{:ok, {token, credential}} ->
conn
|> no_store()
|> json(%{
token: token,
token_type: "Bearer",
expires_at: credential.expires_at,
scopes: credential.scopes
})
{:error, :authorization_pending} ->
conn
|> no_store()
|> put_status(:bad_request)
|> json(%{error: "authorization_pending"})
{:error, :access_denied} ->
conn |> no_store() |> put_status(:forbidden) |> json(%{error: "access_denied"})
{:error, :account_locked} ->
conn |> no_store() |> put_status(:forbidden) |> json(%{error: "access_denied"})
{:error, :expired} ->
conn |> no_store() |> put_status(:gone) |> json(%{error: "expired_token"})
{:error, :credential_limit} ->
conn
|> no_store()
|> put_status(:conflict)
|> json(%{error: "credential_limit"})
{:error, _reason} ->
conn |> no_store() |> put_status(:bad_request) |> json(%{error: "invalid_device_code"})
end
else
rate_limited(conn)
end
end
def exchange(conn, _params) do
conn |> no_store() |> put_status(:bad_request) |> json(%{error: "invalid_device_code"})
end
@doc "Revokes the API credential making this request."
def revoke(conn, _params) do
scope = conn.assigns.current_scope
case ApiCredentials.revoke(scope.account, scope.token_id) do
{:ok, _credential} -> conn |> no_store() |> send_resp(:no_content, "")
{:error, :not_found} -> conn |> no_store() |> send_resp(:no_content, "")
end
end
defp rate_limited(conn) do
conn
|> no_store()
|> put_resp_header("retry-after", "60")
|> put_status(:too_many_requests)
|> json(%{error: "rate_limit_exceeded"})
end
defp no_store(conn), do: put_resp_header(conn, "cache-control", "no-store")
defp changeset_errors(changeset), do: TarakanWeb.ChangesetErrors.to_map(changeset)
end

View file

@ -0,0 +1,62 @@
defmodule TarakanWeb.API.FindingController do
@moduledoc """
Public read access to disclosed findings.
Anonymous reads pass through the same disclosure policy as the finding page
(`Scans.get_finding/2` with a nil scope): restricted or summary-only reviews
answer 404, never a redacted body.
"""
use TarakanWeb, :controller
alias Tarakan.Scans
def show(conn, %{"public_id" => public_id}) do
case Scans.get_finding(nil, public_id) do
{:ok, {scan, finding}} ->
json(conn, %{finding: finding_json(scan, finding)})
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "finding was not found or is not public"})
end
end
defp finding_json(scan, finding) do
canonical = finding.canonical_finding
repository = scan.repository
%{
public_id: finding.public_id,
title: (canonical && canonical.title) || finding.title,
severity: finding.severity,
status: (canonical && canonical.status) || "open",
description: (canonical && canonical.description) || finding.description,
reproduction_steps: canonical && canonical.reproduction_steps,
affected_versions: canonical && canonical.affected_versions,
cwe_id: canonical && canonical.cwe_id,
cve_id: canonical && canonical.cve_id,
file_path: finding.file_path,
line_start: finding.line_start,
line_end: finding.line_end,
first_seen_commit_sha: canonical && canonical.first_seen_commit_sha,
last_seen_commit_sha: canonical && canonical.last_seen_commit_sha,
verified_at: canonical && canonical.verified_at,
vendor_notified_at: canonical && canonical.vendor_notified_at,
counters: %{
detections_count: (canonical && canonical.detections_count) || 0,
distinct_submitters_count: (canonical && canonical.distinct_submitters_count) || 0,
distinct_models_count: (canonical && canonical.distinct_models_count) || 0,
confirmations_count: (canonical && canonical.confirmations_count) || 0,
disputes_count: (canonical && canonical.disputes_count) || 0
},
repository: %{
host: repository.host,
owner: repository.owner,
name: repository.name
},
record_url: TarakanWeb.Endpoint.url() <> ~p"/findings/#{finding.public_id}",
inserted_at: finding.inserted_at
}
end
end

View file

@ -0,0 +1,208 @@
defmodule TarakanWeb.API.InfestationController do
@moduledoc """
Public read access to the cross-repository infestation map.
Patterns aggregate only publicly disclosed canonical findings on listed
repositories, so listing and detail responses need no further filtering.
Query params are whitelisted: `days` buckets to 7/30/90/365, `min_repos`
clamps to 2..50, `limit` to 100.
"""
use TarakanWeb, :controller
alias Tarakan.FindingSignals
alias Tarakan.Infestations
@days [7, 30, 90, 365]
@default_days 30
@default_limit 40
@max_limit 100
def index(conn, params) do
patterns =
Infestations.list_infestations(
days: parse_days(params["days"]),
min_repos: parse_min_repos(params["min_repos"]),
limit: parse_limit(params["limit"])
)
json(conn, %{patterns: Enum.map(patterns, &pattern_json/1)})
end
def show(conn, %{"pattern_key" => pattern_key}) do
case Infestations.get_infestation(pattern_key) do
nil ->
conn
|> put_status(:not_found)
|> json(%{error: "pattern was not found"})
infestation ->
page = Infestations.list_instances_page(pattern_key, limit: 50)
json(conn, %{
pattern: pattern_json(infestation),
instances: Enum.map(page.entries, &instance_json/1)
})
end
end
@doc """
Vaccine pack: the validated detector for this infestation's dominant code
cluster, as a rule file.
Free, and 404s when no worker has produced a rule that actually matches known
instances yet. The previous version of this endpoint always returned something,
and what it returned matched nothing.
"""
def vaccine(conn, %{"pattern_key" => pattern_key}) do
with %{} = infestation <- Infestations.get_infestation(pattern_key),
cluster when is_binary(cluster) <- FindingSignals.dominant_cluster(pattern_key),
rule when not is_nil(rule) <- FindingSignals.best_rule(cluster) do
conn
|> put_resp_content_type("application/x-yaml")
|> send_resp(200, vaccine_yaml(infestation, rule))
else
nil ->
conn
|> put_status(:not_found)
|> json(%{
error: "no validated detector for this pattern yet",
pattern_key: pattern_key
})
end
end
# The rule is served verbatim as the worker wrote and validated it. The header
# carries the provenance so a reader can tell how much the detector was tested
# rather than having to trust it.
defp vaccine_yaml(infestation, rule) do
"""
# Tarakan vaccine pack for #{infestation.pattern_key}
# #{infestation.title}
#
# Engine: #{rule.engine}
# Validated: matched #{rule.matched_count} of #{rule.checked_count} known instances
# at #{rule.validated_at}
# Source: #{TarakanWeb.Endpoint.url()}/infestations/#{infestation.pattern_key}
#
# This detector was written and validated against real instances by a
# contributor's agent. Review what it matches before gating CI on it.
#{rule.rule_yaml}
"""
end
@doc """
Submits a detector for one code cluster.
The worker validates before submitting: it has both the rule engine and the
code, so it runs the rule against known members and reports which ones it hit.
`matched_finding_ids` is the receipt for `matched_count` and must list exactly
the findings that matched, or the submission is rejected.
"""
def put_rule(conn, %{"code_pattern_key" => key} = params) do
scope = conn.assigns[:current_scope]
cond do
is_nil(scope) || is_nil(scope.account) ->
conn
|> put_status(:unauthorized)
|> json(%{error: "missing or invalid API token"})
true ->
attrs = %{
engine: params["engine"] || "semgrep",
language: params["language"],
rule_yaml: params["rule_yaml"],
checked_count: params["checked_count"] || 0,
matched_count: params["matched_count"] || 0,
matched_finding_ids: params["matched_finding_ids"] || []
}
case FindingSignals.put_rule(scope, key, attrs) do
{:ok, rule} ->
conn
|> put_status(:created)
|> json(%{
code_pattern_key: rule.code_pattern_key,
engine: rule.engine,
matched_count: rule.matched_count,
checked_count: rule.checked_count,
validated_at: rule.validated_at,
# An unvalidated rule is stored but never served, so say so rather
# than letting the worker assume it shipped.
servable: Tarakan.Scans.CodePatternRule.servable?(rule)
})
{:error, :unauthorized} ->
conn |> put_status(:forbidden) |> json(%{error: "not authorized"})
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: TarakanWeb.ChangesetErrors.to_map(changeset)})
end
end
end
defp parse_days(value) do
case Integer.parse(to_string(value)) do
{days, ""} when days in @days -> days
_other -> @default_days
end
end
defp parse_min_repos(nil), do: 2
defp parse_min_repos(value) do
case Integer.parse(to_string(value)) do
{min_repos, ""} -> min_repos |> max(2) |> min(50)
_other -> 2
end
end
defp parse_limit(nil), do: @default_limit
defp parse_limit(value) do
case Integer.parse(to_string(value)) do
{limit, ""} when limit > 0 -> min(limit, @max_limit)
_other -> @default_limit
end
end
defp pattern_json(pattern) do
%{
pattern_key: pattern.pattern_key,
title: pattern.title,
severity: pattern.severity,
repo_count: pattern.repo_count,
instance_count: pattern.instance_count,
open_count: pattern.open_count,
verified_count: pattern.verified_count,
fixed_count: pattern.fixed_count,
disputed_count: pattern.disputed_count,
first_seen_at: pattern.first_seen_at,
last_seen_at: pattern.last_seen_at,
sample_file_path: pattern.sample_file_path,
sample_occurrence_public_id: pattern.sample_occurrence_public_id,
record_url: TarakanWeb.Endpoint.url() <> ~p"/infestations/#{pattern.pattern_key}"
}
end
defp instance_json(instance) do
%{
public_id: instance.public_id,
occurrence_public_id: instance.occurrence_public_id,
status: instance.status,
severity: instance.severity,
title: instance.title,
file_path: instance.file_path,
detections_count: instance.detections_count,
confirmations_count: instance.confirmations_count,
repository: %{
host: instance.host,
owner: instance.owner,
name: instance.name
},
updated_at: instance.updated_at
}
end
end

View file

@ -0,0 +1,58 @@
defmodule TarakanWeb.API.LeaderboardController do
@moduledoc """
Public read access to the contributor leaderboard.
Ranks accounts by public verification work; `sort`, `severity`, and
`window` params are whitelisted exactly as the leaderboard page does.
"""
use TarakanWeb, :controller
alias Tarakan.Leaderboard
@limit 25
@sorts %{
"reputation" => :reputation,
"reviews" => :reviews,
"findings" => :findings,
"verdicts" => :verdicts
}
@windows %{"7" => 7, "30" => 30, "90" => 90, "all" => :all}
def index(conn, params) do
sort = Map.get(@sorts, params["sort"], :reputation)
severity = if params["severity"] in Leaderboard.severities(), do: params["severity"]
window = Map.get(@windows, params["window"], :all)
entries = Leaderboard.top(sort, @limit, severity: severity, window: window)
leaderboard =
entries
|> Enum.with_index(1)
|> Enum.map(fn {entry, rank} -> entry_json(entry, rank) end)
json(conn, %{leaderboard: leaderboard})
end
defp entry_json(entry, rank) do
%{
rank: rank,
handle: entry.account.handle,
credit_balance: entry.account.credit_balance,
reputation: %{
total: entry.reputation.total,
verification: entry.reputation.verification,
votes: entry.reputation.votes,
slashed: entry.reputation.slashed,
at_risk: entry.reputation.at_risk
},
stats: %{
reviews: entry.stats.reviews,
findings: entry.stats.findings,
verdicts: entry.stats.verdicts,
repositories: entry.stats.repositories
},
slashed_stakes: entry.slashed_stakes,
profile_url: TarakanWeb.Endpoint.url() <> "/" <> entry.account.handle
}
end
end

View file

@ -0,0 +1,152 @@
defmodule TarakanWeb.API.RepositoryController do
@moduledoc """
Discovery and registration of public repositories for scanning clients.
`GET` lists reviewable repositories (`status=unscanned` for the work queue).
`POST` registers a public GitHub repository by URL or `owner/name` so mass
importers (awesome lists, OSINT harvests) can feed the registry.
"""
use TarakanWeb, :controller
alias Tarakan.Repositories
def index(conn, params) do
opts =
[
status: params["status"],
limit: parse_limit(params["limit"]),
min_stars: parse_min_stars(params["min_stars"]),
language: parse_language(params["language"] || params["lang"])
]
|> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end)
repositories = Repositories.list_reviewable_repositories(opts)
json(conn, %{repositories: Enum.map(repositories, &repository_json/1)})
end
@doc """
Registers a public GitHub repository.
Body: `{"url": "owner/name"}` or `{"url": "https://github.com/owner/name"}`.
Idempotent: an already-registered repository is returned as 200.
"""
def create(conn, params) do
url =
params
|> Map.get("url")
|> Kernel.||(Map.get(params, "repository"))
|> case do
value when is_binary(value) -> String.trim(value)
_ -> ""
end
if url == "" do
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: %{url: ["is required"]}})
else
case Repositories.register_github_repository(url, conn.assigns.current_scope) do
{:ok, repository} ->
# Idempotent: 200 whether newly inserted or already present.
json(conn, %{repository: repository_json(repository)})
{:error, reason} ->
registration_error(conn, reason)
end
end
end
defp registration_error(conn, :invalid_github_repository) do
conn
|> put_status(:unprocessable_entity)
|> json(%{error: "url must be a public GitHub owner/name or repository URL"})
end
defp registration_error(conn, reason) when reason in [:not_found, :not_public] do
conn
|> put_status(:not_found)
|> json(%{error: "repository was not found or is not public"})
end
defp registration_error(conn, :registration_limit) do
conn
|> put_status(:too_many_requests)
|> json(%{error: "daily repository registration limit reached"})
end
defp registration_error(conn, reason) when reason in [:rate_limited, :request_limited] do
conn
|> put_status(:too_many_requests)
|> json(%{error: "too many registration requests; try again shortly"})
end
defp registration_error(conn, :unavailable) do
conn
|> put_status(:service_unavailable)
|> json(%{error: "could not reach GitHub to verify the repository"})
end
defp registration_error(conn, :unauthorized) do
conn
|> put_status(:forbidden)
|> json(%{error: "this credential cannot register repositories"})
end
defp registration_error(conn, _reason) do
conn
|> put_status(:unprocessable_entity)
|> json(%{error: "repository could not be registered"})
end
defp parse_limit(nil), do: 100
defp parse_limit(value) when is_binary(value) do
case Integer.parse(value) do
{limit, ""} when limit > 0 -> min(limit, 500)
_other -> 100
end
end
defp parse_limit(_), do: 100
defp parse_min_stars(nil), do: nil
defp parse_min_stars(value) do
case Integer.parse(to_string(value)) do
{n, _} when n > 0 -> n
_ -> nil
end
end
defp parse_language(nil), do: nil
defp parse_language(value) when is_binary(value) do
case String.trim(value) do
"" -> nil
lang -> lang
end
end
defp parse_language(_), do: nil
defp repository_json(repository) do
status = Map.get(repository, :status) || repository.listing_status
%{
host: repository.host,
owner: repository.owner,
name: repository.name,
status: status,
listing_status: repository.listing_status,
default_branch: repository.default_branch,
primary_language: repository.primary_language,
stars_count: repository.stars_count,
scan_count: repository.scan_count,
last_scanned_at: repository.last_scanned_at,
registered_at: repository.inserted_at,
record_url:
TarakanWeb.Endpoint.url() <> TarakanWeb.RepositoryPaths.repository_path(repository)
}
end
end

View file

@ -0,0 +1,488 @@
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

View file

@ -0,0 +1,534 @@
defmodule TarakanWeb.API.WorkController do
use TarakanWeb, :controller
alias Tarakan.PromptSafety
alias Tarakan.Repositories
alias Tarakan.Work
alias Tarakan.Work.ReviewTask
def index(conn, %{"host" => host_slug, "owner" => owner, "name" => name}) do
case visible_repository(host_slug, owner, name, conn.assigns.current_scope) do
nil ->
not_found(conn, "repository is not registered with Tarakan")
repository ->
tasks =
Work.list_tasks(repository,
limit: 100,
scope: conn.assigns.current_scope,
active_only: true
)
encoded = Enum.map(tasks, &task_json/1)
json(conn, %{jobs: encoded})
end
end
@doc """
Global Jobs queue: open / changes_requested, plus the caller's active claims.
"""
def queue(conn, params) do
limit =
case Integer.parse(to_string(params["limit"] || "50")) do
{n, _} when n > 0 -> min(n, 100)
_ -> 50
end
account_id =
case conn.assigns.current_scope do
%{account: %{id: id}} -> id
_ -> nil
end
opts =
[
limit: limit,
account_id: account_id,
min_stars: parse_min_stars(params["min_stars"]),
language: parse_language(params["language"] || params["lang"]),
kind: parse_kind(params["kind"]),
kinds: parse_kinds(params["kinds"])
]
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
tasks = Work.list_open_claimable_tasks(opts)
# What each job is worth, resolved for the whole page in one query, so a
# worker can see where its tokens are actually paid for.
contract_value =
tasks
|> Enum.map(& &1.repository_id)
|> Tarakan.Market.open_value_by_repository()
encoded =
Enum.map(tasks, &Map.put(task_json(&1), :contract, contract_value[&1.repository_id]))
json(conn, %{jobs: encoded})
end
defp parse_min_stars(nil), do: nil
defp parse_min_stars(value) do
case Integer.parse(to_string(value)) do
{n, _} when n > 0 -> n
_ -> nil
end
end
defp parse_language(nil), do: nil
defp parse_language(value) when is_binary(value) do
case String.trim(value) do
"" -> nil
lang -> lang
end
end
defp parse_language(_), do: nil
defp parse_kind(nil), do: nil
defp parse_kind(value) when is_binary(value) do
case String.trim(value) do
"" -> nil
kind -> kind
end
end
defp parse_kind(_), do: nil
# `?kinds=diff_review,refute_finding` - what this worker can actually run.
defp parse_kinds(value) when is_binary(value) do
value
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> case do
[] -> nil
list -> list
end
end
defp parse_kinds(value) when is_list(value), do: parse_kinds(Enum.join(value, ","))
defp parse_kinds(_), do: nil
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
def show(conn, %{"id" => id}) do
with {:ok, task} <- fetch_visible_task(id, conn.assigns.current_scope) do
json(conn, task_json(task))
else
{:error, :not_found} -> not_found(conn, "job not found")
end
end
def claim(conn, %{"id" => id}) do
with {:ok, task} <- fetch_visible_task(id, conn.assigns.current_scope),
{:ok, task} <- Work.claim_task(task, conn.assigns.current_scope) do
json(conn, task_json(task))
else
error -> lifecycle_error(conn, error)
end
end
def release(conn, %{"id" => id}) do
with {:ok, task} <- fetch_visible_task(id, conn.assigns.current_scope),
{:ok, task} <- Work.release_task(task, conn.assigns.current_scope) do
json(conn, task_json(task))
else
error -> lifecycle_error(conn, error)
end
end
def renew(conn, %{"id" => id}) do
with {:ok, task} <- fetch_visible_task(id, conn.assigns.current_scope),
{:ok, task} <- Work.renew_claim(task, conn.assigns.current_scope) do
json(conn, task_json(task))
else
error -> lifecycle_error(conn, error)
end
end
# Retained for client compatibility. It submits evidence for independent
# review; no client may mark its own contribution accepted.
# Finding-kind Requests accept `document` (Tarakan Review/Scan Format v1).
def complete(conn, %{"id" => id} = params) do
attrs =
params
|> Map.take([
"provenance",
"summary",
"evidence",
"document",
"model",
"prompt_version",
"notes",
"verdict"
])
with {:ok, task} <- fetch_visible_task(id, conn.assigns.current_scope),
{:ok, task} <- Work.submit_task(task, conn.assigns.current_scope, attrs) do
json(conn, task_json(task))
else
error -> lifecycle_error(conn, error)
end
end
def publish(conn, %{"id" => id} = params) do
transition(conn, id, &Work.publish_task/3, decision_attrs(params))
end
def accept(conn, %{"id" => id} = params) do
transition(conn, id, &Work.accept_task/3, decision_attrs(params))
end
def request_changes(conn, %{"id" => id} = params) do
transition(conn, id, &Work.request_changes/3, decision_attrs(params))
end
def reject(conn, %{"id" => id} = params) do
transition(conn, id, &Work.reject_task/3, decision_attrs(params))
end
def cancel(conn, %{"id" => id} = params) do
transition(conn, id, &Work.cancel_task/3, decision_attrs(params))
end
defp transition(conn, id, function, attrs) do
with {:ok, task} <- fetch_visible_task(id, conn.assigns.current_scope),
{:ok, task} <- function.(task, conn.assigns.current_scope, attrs) do
json(conn, task_json(task))
else
error -> lifecycle_error(conn, error)
end
end
defp decision_attrs(params), do: Map.take(params, ["reason", "evidence"])
defp fetch_visible_task(id, scope) do
with {id, ""} <- Integer.parse(id),
%ReviewTask{} = task <- Work.get_visible_task(id, scope) do
{:ok, task}
else
_other -> {:error, :not_found}
end
end
# Titles and descriptions are written by whoever opened the job and are read
# by whoever claims it - specifically, by their agent. Neutralize before the
# payload leaves the server rather than trusting every client to do it.
defp task_json(task) do
%{
id: task.id,
kind: task.kind,
capability: task.capability,
title: PromptSafety.sanitize_line(task.title),
description: PromptSafety.sanitize(task.description, max_bytes: 5_000),
status: task.status,
visibility: task.visibility,
commit_sha: task.commit_sha,
# diff_review reviews base_commit_sha..commit_sha. The worker already has
# the repository, so it diffs locally rather than us shipping the patch.
base_commit_sha: Map.get(task, :base_commit_sha),
target_code_pattern_key: Map.get(task, :target_code_pattern_key),
commit_committed_at: task.commit_committed_at,
repository: repository_json(task.repository),
creator: account_json(task.created_by),
claimant: account_json(task.claimed_by),
reviewer: account_json(task.reviewed_by),
lease: lease_json(task),
contribution: contribution_json(task.contribution),
contributions: Enum.map(task.contributions || [], &contribution_json/1),
decisions: Enum.map(task.decisions || [], &decision_json/1),
linked_review_id: task.linked_review_id,
linked_review: linked_review_json(Map.get(task, :linked_review)),
target_review_id: Map.get(task, :target_review_id),
target_review: linked_review_json(Map.get(task, :target_review)),
target_finding: target_finding_json(Map.get(task, :target_canonical_finding)),
published_at: task.published_at,
submitted_at: task.submitted_at,
reviewed_at: task.reviewed_at,
completed_at: task.completed_at,
disclosed_at: task.disclosed_at,
discloser: account_json(task.disclosed_by),
sensitive_data_reviewed: not is_nil(task.sensitive_data_reviewed_at),
inserted_at: task.inserted_at,
updated_at: task.updated_at,
job_url: url(~p"/jobs/#{task.id}")
}
end
# The finding under attack, reproduction, or re-scoring. Its title and
# description were written by another agent, so they are sanitized on the way
# out for exactly the same reason the job title is.
defp target_finding_json(nil), do: nil
defp target_finding_json(%Ecto.Association.NotLoaded{}), do: nil
defp target_finding_json(finding) do
%{
public_id: finding.public_id,
title: PromptSafety.sanitize_line(finding.title),
description: PromptSafety.sanitize(finding.description, max_bytes: 8_000),
severity: finding.severity,
calibrated_severity: finding.calibrated_severity,
status: finding.status,
file_path: finding.file_path,
line_start: finding.line_start,
line_end: finding.line_end,
first_seen_commit_sha: finding.first_seen_commit_sha,
last_seen_commit_sha: finding.last_seen_commit_sha,
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,
finding_url: url(~p"/findings/#{finding.public_id}")
}
end
defp linked_review_json(nil), do: nil
defp linked_review_json(%Ecto.Association.NotLoaded{}), do: nil
defp linked_review_json(review) do
findings = Map.get(review, :findings)
findings =
case findings do
%Ecto.Association.NotLoaded{} -> []
list when is_list(list) -> list
_ -> []
end
%{
id: review.id,
review_status: review.review_status,
visibility: review.visibility,
findings_count: review.findings_count,
provenance: review.provenance,
review_kind: review.review_kind,
model: review.model,
prompt_version: review.prompt_version,
commit_sha: review.commit_sha,
source_request_id: review.source_request_id,
findings:
Enum.map(findings, fn finding ->
%{
id: finding.id,
file: finding.file_path,
line_start: finding.line_start,
line_end: finding.line_end,
severity: finding.severity,
title: PromptSafety.sanitize_line(finding.title),
description: PromptSafety.sanitize(finding.description, max_bytes: 2_000)
}
end)
}
end
defp repository_json(repository) do
%{
id: repository.id,
host: repository.host,
owner: repository.owner,
name: repository.name,
canonical_url: repository.canonical_url,
participation_mode: repository.participation_mode,
primary_language: repository.primary_language,
stars_count: repository.stars_count,
record_url:
TarakanWeb.Endpoint.url() <> TarakanWeb.RepositoryPaths.repository_path(repository)
}
end
defp account_json(nil), do: nil
defp account_json(account) do
%{
id: account.id,
handle: account.handle
}
end
defp lease_json(%ReviewTask{claimed_by: nil}), do: nil
defp lease_json(task) do
%{
claimed_at: task.claimed_at,
expires_at: task.claim_expires_at,
active: ReviewTask.claim_active?(task)
}
end
defp contribution_json(nil), do: nil
defp contribution_json(contribution) do
%{
id: contribution.id,
version: contribution.version,
provenance: contribution.provenance,
summary: contribution.summary,
evidence: contribution.evidence,
contributor: account_json(contribution.account),
submitted_at: contribution.inserted_at
}
end
defp decision_json(decision) do
%{
id: decision.id,
action: decision.action,
reason: decision.reason,
evidence: decision.evidence,
reviewer: account_json(decision.account),
decided_at: decision.inserted_at
}
end
defp lifecycle_error(conn, {:error, :not_found}),
do: not_found(conn, "job not found")
defp lifecycle_error(conn, {:error, :own_task}),
do: forbidden(conn, "that action is not allowed on this job")
defp lifecycle_error(conn, {:error, :not_independent}),
do: forbidden(conn, "an independent reviewer is required")
defp lifecycle_error(conn, {:error, reason})
when reason in [:forbidden, :unauthorized, :account_inactive, :insufficient_trust],
do: forbidden(conn, "this account is not authorized for that action")
defp lifecycle_error(conn, {:error, :not_claimant}),
do: forbidden(conn, "only the current claimant may perform that action")
defp lifecycle_error(conn, {:error, :claim_expired}),
do: conflict(conn, "the claim has expired; claim the task again")
defp lifecycle_error(conn, {:error, :claim_limit}),
do: conflict(conn, "this account has reached its active claim limit")
defp lifecycle_error(conn, {:error, :claim_rate_limited}),
do: too_many_requests(conn, "too many claim changes; try again shortly")
defp lifecycle_error(conn, {:error, :already_claimed}),
do: conflict(conn, "job has an active claim")
defp lifecycle_error(conn, {:error, :closed}),
do: conflict(conn, "job is closed")
defp lifecycle_error(conn, {:error, :not_open}),
do: conflict(conn, "job is not open for claims")
defp lifecycle_error(conn, {:error, :invalid_state}),
do: conflict(conn, "that transition is not valid from the task's current state")
defp lifecycle_error(conn, {:error, :active_work}),
do: conflict(conn, "active or submitted work must be resolved before cancellation")
defp lifecycle_error(conn, {:error, :capability_mismatch}) do
unprocessable(conn, %{provenance: ["does not satisfy this task's required capability"]})
end
defp lifecycle_error(conn, {:error, :document_required}) do
unprocessable(conn, %{
document: ["is required (Tarakan Review/Scan Format object with findings)"]
})
end
defp lifecycle_error(conn, {:error, :document_or_prose_required}) do
unprocessable(conn, %{
document: ["provide a Review Format document, or summary+evidence for legacy prose"]
})
end
defp lifecycle_error(conn, {:error, :document_not_allowed}) do
unprocessable(conn, %{
document: ["is not accepted for this request kind; use summary and evidence"]
})
end
defp lifecycle_error(conn, {:error, :verdict_required}) do
unprocessable(conn, %{
verdict: ["is required (confirmed or disputed) with notes ≥ 20 characters"]
})
end
defp lifecycle_error(conn, {:error, :verdict_notes_required}) do
unprocessable(conn, %{
notes: ["must be at least 20 characters (use notes or summary)"]
})
end
defp lifecycle_error(conn, {:error, :target_review_required}),
do: unprocessable(conn, %{target_review_id: ["is required for verify_findings"]})
defp lifecycle_error(conn, {:error, :target_review_missing}),
do: unprocessable(conn, %{target_review_id: ["does not exist"]})
defp lifecycle_error(conn, {:error, :target_review_mismatch}),
do: unprocessable(conn, %{target_review_id: ["must belong to the same repository"]})
defp lifecycle_error(conn, {:error, :conflict_of_interest}),
do: forbidden(conn, "review submitters cannot verify their own review")
defp lifecycle_error(conn, {:error, :invalid_document}) do
unprocessable(conn, %{document: ["must be a valid JSON Review/Scan Format object"]})
end
defp lifecycle_error(conn, {:error, :submission_limit}),
do: too_many_requests(conn, "daily review submission limit reached")
defp lifecycle_error(conn, {:error, :submission_rate_limited}),
do: too_many_requests(conn, "too many review submissions; try again shortly")
defp lifecycle_error(conn, {:error, :commit_not_found}),
do: unprocessable(conn, %{commit_sha: ["is not known for this repository"]})
defp lifecycle_error(conn, {:error, :commit_mismatch}),
do: unprocessable(conn, %{commit_sha: ["does not match the GitHub commit"]})
defp lifecycle_error(conn, {:error, reason})
when reason in [:rate_limited, :unavailable, :identity_changed],
do: service_unavailable(conn, "could not verify commit identity (#{reason})")
defp lifecycle_error(conn, {:error, :linked_review_restricted}),
do: conflict(conn, "linked review is restricted or missing; cannot close this request")
defp lifecycle_error(conn, {:error, %Ecto.Changeset{} = changeset}),
do: unprocessable(conn, changeset_errors(changeset))
defp lifecycle_error(conn, {:error, reason}),
do: forbidden(conn, "action denied: #{inspect(reason)}")
defp service_unavailable(conn, message) do
conn |> put_status(:service_unavailable) |> json(%{error: message})
end
defp not_found(conn, message) do
conn |> put_status(:not_found) |> json(%{error: message})
end
defp forbidden(conn, message) do
conn |> put_status(:forbidden) |> json(%{error: message})
end
defp conflict(conn, message) do
conn |> put_status(:conflict) |> json(%{error: message})
end
defp too_many_requests(conn, message) do
conn |> put_status(:too_many_requests) |> json(%{error: message})
end
defp unprocessable(conn, errors) do
conn |> put_status(:unprocessable_entity) |> json(%{errors: errors})
end
defp changeset_errors(changeset), do: TarakanWeb.ChangesetErrors.to_map(changeset)
end