Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
commit
af6077b9c3
455 changed files with 78366 additions and 0 deletions
138
lib/tarakan_web/controllers/account_session_controller.ex
Normal file
138
lib/tarakan_web/controllers/account_session_controller.ex
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
defmodule TarakanWeb.AccountSessionController do
|
||||
use TarakanWeb, :controller
|
||||
|
||||
alias Tarakan.Accounts
|
||||
alias TarakanWeb.AccountAuth
|
||||
alias TarakanWeb.BrowserRateLimit
|
||||
|
||||
def create(conn, %{"_action" => "confirmed"} = params) do
|
||||
rate_limited_create(conn, params, "Account confirmed successfully.")
|
||||
end
|
||||
|
||||
def create(conn, params) do
|
||||
rate_limited_create(conn, params, "Welcome back!")
|
||||
end
|
||||
|
||||
defp rate_limited_create(conn, params, info) do
|
||||
account_params = Map.get(params, "account", %{})
|
||||
|
||||
identifier =
|
||||
account_params["identifier"] || account_params["email"] || account_params["token"] ||
|
||||
account_params["session_token"] || "missing"
|
||||
|
||||
remote_ip = remote_ip(conn)
|
||||
identifier_key = :crypto.hash(:sha256, identifier |> to_string() |> String.downcase())
|
||||
|
||||
checks = [
|
||||
BrowserRateLimit.allowed?(:login_ip, remote_ip),
|
||||
BrowserRateLimit.allowed?(:login_pair, {remote_ip, identifier_key})
|
||||
]
|
||||
|
||||
if Enum.all?(checks) do
|
||||
create(conn, params, info)
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "Invalid credentials or too many attempts. Try again later.")
|
||||
|> redirect(to: ~p"/accounts/log-in")
|
||||
end
|
||||
end
|
||||
|
||||
# instant registration login (one-time session bootstrap token; does not
|
||||
# confirm the email address the way a magic-link login does)
|
||||
defp create(conn, %{"account" => %{"session_token" => encoded} = account_params}, info) do
|
||||
with {:ok, token} <- Base.url_decode64(encoded, padding: false),
|
||||
{account, _inserted_at} <- Accounts.get_account_by_session_token(token) do
|
||||
# One-time bootstrap: log_in_account mints the real session token.
|
||||
Accounts.delete_account_session_token(token)
|
||||
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|> AccountAuth.log_in_account(account, account_params)
|
||||
else
|
||||
_other ->
|
||||
conn
|
||||
|> put_flash(:error, "The link is invalid or it has expired.")
|
||||
|> redirect(to: ~p"/accounts/log-in")
|
||||
end
|
||||
end
|
||||
|
||||
# magic link login
|
||||
defp create(conn, %{"account" => %{"token" => token} = account_params}, info) do
|
||||
case Accounts.login_account_by_magic_link(token) do
|
||||
{:ok, {account, disconnect_ref}} ->
|
||||
AccountAuth.disconnect_sessions(disconnect_ref)
|
||||
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|> AccountAuth.log_in_account(account, account_params)
|
||||
|
||||
_ ->
|
||||
conn
|
||||
|> put_flash(:error, "The link is invalid or it has expired.")
|
||||
|> redirect(to: ~p"/accounts/log-in")
|
||||
end
|
||||
end
|
||||
|
||||
# handle/email + password login
|
||||
defp create(conn, %{"account" => account_params}, info) do
|
||||
identifier = Map.get(account_params, "identifier", "")
|
||||
password = Map.get(account_params, "password", "")
|
||||
|
||||
if account = Accounts.get_account_by_identifier_and_password(identifier, password) do
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|> AccountAuth.log_in_account(account, account_params)
|
||||
else
|
||||
# Do not disclose whether the handle or email is registered.
|
||||
conn
|
||||
|> put_flash(:error, "Invalid handle, email, or password")
|
||||
|> put_flash(:identifier, String.slice(identifier, 0, 160))
|
||||
|> redirect(to: ~p"/accounts/log-in")
|
||||
end
|
||||
end
|
||||
|
||||
def update_password(conn, %{"account" => account_params}) do
|
||||
account = conn.assigns.current_scope.account
|
||||
|
||||
cond do
|
||||
not Accounts.sudo_mode?(account) ->
|
||||
# The sudo window can expire between rendering the form and POSTing it;
|
||||
# send the account through re-authentication instead of crashing.
|
||||
conn
|
||||
|> put_flash(
|
||||
:error,
|
||||
"Confirm it's you with a magic link before changing sensitive settings."
|
||||
)
|
||||
|> redirect(to: AccountAuth.reauth_path(~p"/accounts/settings"))
|
||||
|
||||
is_nil(account.confirmed_at) ->
|
||||
conn
|
||||
|> put_flash(
|
||||
:error,
|
||||
"Confirm your email address with a login link before setting a password."
|
||||
)
|
||||
|> redirect(to: ~p"/accounts/settings")
|
||||
|
||||
true ->
|
||||
{:ok, {account, disconnect_ref}} =
|
||||
Accounts.update_account_password(account, account_params)
|
||||
|
||||
# disconnect all existing LiveViews with old sessions
|
||||
AccountAuth.disconnect_sessions(disconnect_ref)
|
||||
|
||||
conn
|
||||
|> put_session(:account_return_to, ~p"/accounts/settings")
|
||||
|> put_flash(:info, "Password updated successfully!")
|
||||
|> AccountAuth.log_in_account(account)
|
||||
end
|
||||
end
|
||||
|
||||
def delete(conn, _params) do
|
||||
conn
|
||||
|> put_flash(:info, "Logged out successfully.")
|
||||
|> AccountAuth.log_out_account()
|
||||
end
|
||||
|
||||
# Bucket by /64 for IPv6 so prefix rotation cannot mint fresh rate-limit buckets.
|
||||
defp remote_ip(conn), do: TarakanWeb.Plugs.ClientIp.remote_ip_bucket(conn)
|
||||
end
|
||||
108
lib/tarakan_web/controllers/api/client_auth_controller.ex
Normal file
108
lib/tarakan_web/controllers/api/client_auth_controller.ex
Normal 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
|
||||
62
lib/tarakan_web/controllers/api/finding_controller.ex
Normal file
62
lib/tarakan_web/controllers/api/finding_controller.ex
Normal 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
|
||||
208
lib/tarakan_web/controllers/api/infestation_controller.ex
Normal file
208
lib/tarakan_web/controllers/api/infestation_controller.ex
Normal 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
|
||||
58
lib/tarakan_web/controllers/api/leaderboard_controller.ex
Normal file
58
lib/tarakan_web/controllers/api/leaderboard_controller.ex
Normal 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
|
||||
152
lib/tarakan_web/controllers/api/repository_controller.ex
Normal file
152
lib/tarakan_web/controllers/api/repository_controller.ex
Normal 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
|
||||
488
lib/tarakan_web/controllers/api/scan_controller.ex
Normal file
488
lib/tarakan_web/controllers/api/scan_controller.ex
Normal 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
|
||||
534
lib/tarakan_web/controllers/api/work_controller.ex
Normal file
534
lib/tarakan_web/controllers/api/work_controller.ex
Normal 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
|
||||
75
lib/tarakan_web/controllers/badge_controller.ex
Normal file
75
lib/tarakan_web/controllers/badge_controller.ex
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
defmodule TarakanWeb.BadgeController do
|
||||
@moduledoc """
|
||||
Embeddable SVG badges for a repository's security posture.
|
||||
|
||||
Served without authentication so a README on any host can render one, and
|
||||
cached briefly so an embedded badge cannot be used to hammer the database.
|
||||
The SVG is self-contained (no external fonts or images) because most hosts
|
||||
proxy and sanitize badge images.
|
||||
"""
|
||||
|
||||
use TarakanWeb, :controller
|
||||
|
||||
alias Tarakan.Repositories
|
||||
alias Tarakan.SecurityPosture
|
||||
|
||||
@cache_seconds 900
|
||||
|
||||
def show(conn, %{"host" => host, "owner" => owner, "name" => name}) do
|
||||
render_badge(conn, Repositories.get_repository(host, owner, name))
|
||||
end
|
||||
|
||||
def show(conn, %{"owner" => owner, "name" => name}) do
|
||||
render_badge(conn, Repositories.get_repository_by_slug(owner, name))
|
||||
end
|
||||
|
||||
defp render_badge(conn, repository) do
|
||||
badge =
|
||||
case repository do
|
||||
%{listing_status: "listed"} = repository ->
|
||||
SecurityPosture.cached_badge(repository)
|
||||
|
||||
_unlisted_or_missing ->
|
||||
%{label: "security", message: "not listed", color: "#5a6a72"}
|
||||
end
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("image/svg+xml")
|
||||
|> put_resp_header("cache-control", "public, max-age=#{@cache_seconds}")
|
||||
|> send_resp(200, svg(badge))
|
||||
end
|
||||
|
||||
# Flat two-part badge. Widths are estimated from character count because the
|
||||
# SVG carries no font metrics; 6.6px per character matches the 11px
|
||||
# sans-serif stack closely enough for the shape to stay tight.
|
||||
defp svg(%{label: label, message: message, color: color}) do
|
||||
label_width = text_width(label)
|
||||
message_width = text_width(message)
|
||||
total = label_width + message_width
|
||||
|
||||
"""
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="#{total}" height="20" role="img" aria-label="#{escape(label)}: #{escape(message)}">
|
||||
<title>#{escape(label)}: #{escape(message)}</title>
|
||||
<g shape-rendering="crispEdges">
|
||||
<rect width="#{label_width}" height="20" fill="#1c1f22"/>
|
||||
<rect x="#{label_width}" width="#{message_width}" height="20" fill="#{escape(color)}"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,Geneva,sans-serif" font-size="11">
|
||||
<text x="#{div(label_width, 2)}" y="14">#{escape(label)}</text>
|
||||
<text x="#{label_width + div(message_width, 2)}" y="14">#{escape(message)}</text>
|
||||
</g>
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
|
||||
defp text_width(text), do: round(String.length(text) * 6.6) + 12
|
||||
|
||||
defp escape(value) do
|
||||
value
|
||||
|> to_string()
|
||||
|> String.replace("&", "&")
|
||||
|> String.replace("<", "<")
|
||||
|> String.replace(">", ">")
|
||||
|> String.replace("\"", """)
|
||||
end
|
||||
end
|
||||
40
lib/tarakan_web/controllers/billing_controller.ex
Normal file
40
lib/tarakan_web/controllers/billing_controller.ex
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
defmodule TarakanWeb.BillingController do
|
||||
@moduledoc """
|
||||
Stripe exit points for subscription billing.
|
||||
|
||||
Plain POST endpoints (authenticated) that create a Checkout or Billing
|
||||
Portal session and 303-redirect to Stripe. LiveViews use the same
|
||||
`Tarakan.Billing` functions directly; these exist for non-LiveView pages
|
||||
like /pricing.
|
||||
"""
|
||||
|
||||
use TarakanWeb, :controller
|
||||
|
||||
alias Tarakan.Billing
|
||||
|
||||
def checkout(conn, %{"plan" => plan}) do
|
||||
case Billing.ensure_checkout(conn.assigns.current_scope, plan) do
|
||||
{:ok, checkout_url} ->
|
||||
redirect(conn, external: checkout_url)
|
||||
|
||||
{:error, _reason} ->
|
||||
conn
|
||||
|> put_flash(:error, "Checkout could not be started. Try again shortly.")
|
||||
|> redirect(to: ~p"/pricing")
|
||||
end
|
||||
end
|
||||
|
||||
def checkout(conn, _params), do: checkout(conn, %{"plan" => "enterprise"})
|
||||
|
||||
def portal(conn, _params) do
|
||||
case Billing.portal_session(conn.assigns.current_scope.account) do
|
||||
{:ok, portal_url} ->
|
||||
redirect(conn, external: portal_url)
|
||||
|
||||
{:error, _reason} ->
|
||||
conn
|
||||
|> put_flash(:error, "The billing portal is unavailable for this account.")
|
||||
|> redirect(to: ~p"/accounts/billing")
|
||||
end
|
||||
end
|
||||
end
|
||||
24
lib/tarakan_web/controllers/error_html.ex
Normal file
24
lib/tarakan_web/controllers/error_html.ex
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
defmodule TarakanWeb.ErrorHTML do
|
||||
@moduledoc """
|
||||
This module is invoked by your endpoint in case of errors on HTML requests.
|
||||
|
||||
See config/config.exs.
|
||||
"""
|
||||
use TarakanWeb, :html
|
||||
|
||||
# If you want to customize your error pages,
|
||||
# uncomment the embed_templates/1 call below
|
||||
# and add pages to the error directory:
|
||||
#
|
||||
# * lib/tarakan_web/controllers/error_html/404.html.heex
|
||||
# * lib/tarakan_web/controllers/error_html/500.html.heex
|
||||
#
|
||||
# embed_templates "error_html/*"
|
||||
|
||||
# The default is to render a plain text page based on
|
||||
# the template name. For example, "404.html" becomes
|
||||
# "Not Found".
|
||||
def render(template, _assigns) do
|
||||
Phoenix.Controller.status_message_from_template(template)
|
||||
end
|
||||
end
|
||||
21
lib/tarakan_web/controllers/error_json.ex
Normal file
21
lib/tarakan_web/controllers/error_json.ex
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
defmodule TarakanWeb.ErrorJSON do
|
||||
@moduledoc """
|
||||
This module is invoked by your endpoint in case of errors on JSON requests.
|
||||
|
||||
See config/config.exs.
|
||||
"""
|
||||
|
||||
# If you want to customize a particular status code,
|
||||
# you may add your own clauses, such as:
|
||||
#
|
||||
# def render("500.json", _assigns) do
|
||||
# %{errors: %{detail: "Internal Server Error"}}
|
||||
# end
|
||||
|
||||
# By default, Phoenix returns the status message from
|
||||
# the template name. For example, "404.json" becomes
|
||||
# "Not Found".
|
||||
def render(template, _assigns) do
|
||||
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
|
||||
end
|
||||
end
|
||||
139
lib/tarakan_web/controllers/feed_controller.ex
Normal file
139
lib/tarakan_web/controllers/feed_controller.ex
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
defmodule TarakanWeb.FeedController do
|
||||
@moduledoc """
|
||||
Atom 1.0 feeds over the public record.
|
||||
|
||||
Both feeds reuse the anonymous disclosure rules: findings come from
|
||||
`Scans.list_recent_disclosed_findings/1` (listed repositories, fully public
|
||||
reviews, terminal trust states), infestations from `Infestations.list_infestations/1`
|
||||
(public canonical findings on listed repositories only).
|
||||
"""
|
||||
use TarakanWeb, :controller
|
||||
|
||||
alias Tarakan.Infestations
|
||||
alias Tarakan.Scans
|
||||
|
||||
@feed_limit 50
|
||||
@cache_control "public, max-age=300"
|
||||
|
||||
def findings(conn, _params) do
|
||||
base = TarakanWeb.Endpoint.url()
|
||||
|
||||
entries =
|
||||
[limit: @feed_limit]
|
||||
|> Scans.list_recent_disclosed_findings()
|
||||
|> Enum.map(fn %{canonical: canonical, repository: repository} = row ->
|
||||
url = base <> ~p"/findings/#{row.occurrence_public_id}"
|
||||
|
||||
%{
|
||||
id: url,
|
||||
link: url,
|
||||
title: canonical.title,
|
||||
updated: canonical.updated_at,
|
||||
summary:
|
||||
"#{String.capitalize(canonical.severity)} in " <>
|
||||
"#{repository.owner}/#{repository.name} (#{canonical.file_path}): " <>
|
||||
truncate(canonical.description, 240)
|
||||
}
|
||||
end)
|
||||
|
||||
send_feed(conn, "Tarakan findings", "#{base}/feeds/findings.xml", entries)
|
||||
end
|
||||
|
||||
def infestations(conn, _params) do
|
||||
base = TarakanWeb.Endpoint.url()
|
||||
|
||||
entries =
|
||||
[days: 30, limit: @feed_limit]
|
||||
|> Infestations.list_infestations()
|
||||
|> Enum.map(fn pattern ->
|
||||
url = base <> ~p"/infestations/#{pattern.pattern_key}"
|
||||
|
||||
%{
|
||||
id: url,
|
||||
link: url,
|
||||
title: pattern.title,
|
||||
updated: pattern.last_seen_at || pattern.first_seen_at || DateTime.utc_now(),
|
||||
summary:
|
||||
"Seen in #{pattern.repo_count} repositories over 30 days: " <>
|
||||
"#{pattern.open_count} open, #{pattern.verified_count} verified, " <>
|
||||
"#{pattern.fixed_count} fixed, #{pattern.disputed_count} disputed."
|
||||
}
|
||||
end)
|
||||
|
||||
send_feed(conn, "Tarakan infestations", "#{base}/feeds/infestations.xml", entries)
|
||||
end
|
||||
|
||||
defp send_feed(conn, title, self_url, entries) do
|
||||
base = TarakanWeb.Endpoint.url()
|
||||
|
||||
updated =
|
||||
entries |> Enum.map(& &1.updated) |> Enum.max(DateTime, fn -> DateTime.utc_now() end)
|
||||
|
||||
body = [
|
||||
~s(<?xml version="1.0" encoding="UTF-8"?>),
|
||||
~s(<feed xmlns="http://www.w3.org/2005/Atom">),
|
||||
"<title>",
|
||||
xml_escape(title),
|
||||
"</title>",
|
||||
"<id>",
|
||||
xml_escape(self_url),
|
||||
"</id>",
|
||||
~s(<link rel="self" href="),
|
||||
xml_escape(self_url),
|
||||
~s("/>),
|
||||
~s(<link rel="alternate" href="),
|
||||
xml_escape(base),
|
||||
~s("/>),
|
||||
"<updated>",
|
||||
rfc3339(updated),
|
||||
"</updated>",
|
||||
Enum.map(entries, &entry_xml/1),
|
||||
"</feed>"
|
||||
]
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("application/atom+xml")
|
||||
|> put_resp_header("cache-control", @cache_control)
|
||||
|> send_resp(200, body)
|
||||
end
|
||||
|
||||
defp entry_xml(entry) do
|
||||
[
|
||||
"<entry>",
|
||||
"<id>",
|
||||
xml_escape(entry.id),
|
||||
"</id>",
|
||||
"<title>",
|
||||
xml_escape(entry.title),
|
||||
"</title>",
|
||||
"<updated>",
|
||||
rfc3339(entry.updated),
|
||||
"</updated>",
|
||||
~s(<link href="),
|
||||
xml_escape(entry.link),
|
||||
~s("/>),
|
||||
"<summary>",
|
||||
xml_escape(entry.summary),
|
||||
"</summary>",
|
||||
"</entry>"
|
||||
]
|
||||
end
|
||||
|
||||
defp rfc3339(%DateTime{} = datetime) do
|
||||
datetime |> DateTime.truncate(:second) |> DateTime.to_iso8601()
|
||||
end
|
||||
|
||||
defp truncate(text, max) when byte_size(text) <= max, do: text
|
||||
defp truncate(text, max), do: String.slice(text, 0, max - 1) <> "…"
|
||||
|
||||
defp xml_escape(nil), do: ""
|
||||
|
||||
defp xml_escape(text) do
|
||||
text
|
||||
|> String.replace("&", "&")
|
||||
|> String.replace("<", "<")
|
||||
|> String.replace(">", ">")
|
||||
|> String.replace("\"", """)
|
||||
|> String.replace("'", "'")
|
||||
end
|
||||
end
|
||||
82
lib/tarakan_web/controllers/github_auth_controller.ex
Normal file
82
lib/tarakan_web/controllers/github_auth_controller.ex
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
defmodule TarakanWeb.GitHubAuthController do
|
||||
use TarakanWeb, :controller
|
||||
|
||||
alias Tarakan.Accounts
|
||||
alias Tarakan.GitHub.OAuth
|
||||
alias TarakanWeb.AccountAuth
|
||||
alias TarakanWeb.SafeRedirect
|
||||
|
||||
def request(conn, params) do
|
||||
return_to = safe_return_to(params["return_to"])
|
||||
|
||||
if OAuth.configured?() do
|
||||
state = OAuth.generate_state()
|
||||
{verifier, challenge} = OAuth.generate_pkce()
|
||||
redirect_uri = url(~p"/auth/github/callback")
|
||||
|
||||
conn
|
||||
|> put_session(:github_oauth_state, state)
|
||||
|> put_session(:github_oauth_verifier, verifier)
|
||||
|> put_session(:github_oauth_return_to, return_to)
|
||||
|> redirect(external: OAuth.authorize_url(state, challenge, redirect_uri))
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "GitHub login has not been configured yet.")
|
||||
|> redirect(to: return_to)
|
||||
end
|
||||
end
|
||||
|
||||
def callback(conn, %{"code" => code, "state" => state}) do
|
||||
expected_state = get_session(conn, :github_oauth_state)
|
||||
verifier = get_session(conn, :github_oauth_verifier)
|
||||
return_to = safe_return_to(get_session(conn, :github_oauth_return_to))
|
||||
redirect_uri = url(~p"/auth/github/callback")
|
||||
current_account = conn.assigns.current_scope && conn.assigns.current_scope.account
|
||||
|
||||
with true <- OAuth.valid_state?(expected_state, state),
|
||||
true <- is_binary(verifier),
|
||||
:ok <- authorize_identity_link(current_account),
|
||||
{:ok, token} <- OAuth.exchange_code(code, verifier, redirect_uri),
|
||||
{:ok, profile} <- OAuth.fetch_user(token),
|
||||
{:ok, account} <- Accounts.upsert_external_identity(:github, profile, current_account),
|
||||
true <- Accounts.access_allowed?(account) do
|
||||
conn
|
||||
|> clear_oauth_session()
|
||||
|> put_session(:account_return_to, return_to)
|
||||
|> put_flash(:info, "Signed in as @#{account.handle}.")
|
||||
|> AccountAuth.log_in_account(account)
|
||||
else
|
||||
_error -> authorization_failed(conn, return_to)
|
||||
end
|
||||
end
|
||||
|
||||
def callback(conn, _params) do
|
||||
authorization_failed(conn, safe_return_to(get_session(conn, :github_oauth_return_to)))
|
||||
end
|
||||
|
||||
defp authorization_failed(conn, return_to) do
|
||||
conn
|
||||
|> clear_oauth_session()
|
||||
|> put_flash(:error, "GitHub authentication failed. Please try again.")
|
||||
|> redirect(to: return_to)
|
||||
end
|
||||
|
||||
defp clear_oauth_session(conn) do
|
||||
conn
|
||||
|> delete_session(:github_oauth_state)
|
||||
|> delete_session(:github_oauth_verifier)
|
||||
|> delete_session(:github_oauth_return_to)
|
||||
end
|
||||
|
||||
defp safe_return_to(path) when is_binary(path) do
|
||||
SafeRedirect.local_path(path)
|
||||
end
|
||||
|
||||
defp safe_return_to(_path), do: "/"
|
||||
|
||||
defp authorize_identity_link(nil), do: :ok
|
||||
|
||||
defp authorize_identity_link(account) do
|
||||
if Accounts.sudo_mode?(account), do: :ok, else: {:error, :recent_authentication_required}
|
||||
end
|
||||
end
|
||||
82
lib/tarakan_web/controllers/gitlab_auth_controller.ex
Normal file
82
lib/tarakan_web/controllers/gitlab_auth_controller.ex
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
defmodule TarakanWeb.GitLabAuthController do
|
||||
use TarakanWeb, :controller
|
||||
|
||||
alias Tarakan.Accounts
|
||||
alias Tarakan.GitLab.OAuth
|
||||
alias TarakanWeb.AccountAuth
|
||||
alias TarakanWeb.SafeRedirect
|
||||
|
||||
def request(conn, params) do
|
||||
return_to = safe_return_to(params["return_to"])
|
||||
|
||||
if OAuth.configured?() do
|
||||
state = OAuth.generate_state()
|
||||
{verifier, challenge} = OAuth.generate_pkce()
|
||||
redirect_uri = url(~p"/auth/gitlab/callback")
|
||||
|
||||
conn
|
||||
|> put_session(:gitlab_oauth_state, state)
|
||||
|> put_session(:gitlab_oauth_verifier, verifier)
|
||||
|> put_session(:gitlab_oauth_return_to, return_to)
|
||||
|> redirect(external: OAuth.authorize_url(state, challenge, redirect_uri))
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "GitLab login has not been configured yet.")
|
||||
|> redirect(to: return_to)
|
||||
end
|
||||
end
|
||||
|
||||
def callback(conn, %{"code" => code, "state" => state}) do
|
||||
expected_state = get_session(conn, :gitlab_oauth_state)
|
||||
verifier = get_session(conn, :gitlab_oauth_verifier)
|
||||
return_to = safe_return_to(get_session(conn, :gitlab_oauth_return_to))
|
||||
redirect_uri = url(~p"/auth/gitlab/callback")
|
||||
current_account = conn.assigns.current_scope && conn.assigns.current_scope.account
|
||||
|
||||
with true <- OAuth.valid_state?(expected_state, state),
|
||||
true <- is_binary(verifier),
|
||||
:ok <- authorize_identity_link(current_account),
|
||||
{:ok, token} <- OAuth.exchange_code(code, verifier, redirect_uri),
|
||||
{:ok, profile} <- OAuth.fetch_user(token),
|
||||
{:ok, account} <- Accounts.upsert_external_identity(:gitlab, profile, current_account),
|
||||
true <- Accounts.access_allowed?(account) do
|
||||
conn
|
||||
|> clear_oauth_session()
|
||||
|> put_session(:account_return_to, return_to)
|
||||
|> put_flash(:info, "Signed in as @#{account.handle}.")
|
||||
|> AccountAuth.log_in_account(account)
|
||||
else
|
||||
_error -> authorization_failed(conn, return_to)
|
||||
end
|
||||
end
|
||||
|
||||
def callback(conn, _params) do
|
||||
authorization_failed(conn, safe_return_to(get_session(conn, :gitlab_oauth_return_to)))
|
||||
end
|
||||
|
||||
defp authorization_failed(conn, return_to) do
|
||||
conn
|
||||
|> clear_oauth_session()
|
||||
|> put_flash(:error, "GitLab authentication failed. Please try again.")
|
||||
|> redirect(to: return_to)
|
||||
end
|
||||
|
||||
defp clear_oauth_session(conn) do
|
||||
conn
|
||||
|> delete_session(:gitlab_oauth_state)
|
||||
|> delete_session(:gitlab_oauth_verifier)
|
||||
|> delete_session(:gitlab_oauth_return_to)
|
||||
end
|
||||
|
||||
defp safe_return_to(path) when is_binary(path) do
|
||||
SafeRedirect.local_path(path)
|
||||
end
|
||||
|
||||
defp safe_return_to(_path), do: "/"
|
||||
|
||||
defp authorize_identity_link(nil), do: :ok
|
||||
|
||||
defp authorize_identity_link(account) do
|
||||
if Accounts.sudo_mode?(account), do: :ok, else: {:error, :recent_authentication_required}
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
defmodule TarakanWeb.InfestationRedirectController do
|
||||
@moduledoc """
|
||||
Permanent redirects from the pre-rename `/patterns` URLs to `/infestations`.
|
||||
"""
|
||||
use TarakanWeb, :controller
|
||||
|
||||
def index(conn, _params) do
|
||||
conn
|
||||
|> put_status(:moved_permanently)
|
||||
|> redirect(to: ~p"/infestations")
|
||||
end
|
||||
|
||||
def show(conn, %{"pattern_key" => pattern_key}) do
|
||||
conn
|
||||
|> put_status(:moved_permanently)
|
||||
|> redirect(to: ~p"/infestations/#{pattern_key}")
|
||||
end
|
||||
end
|
||||
289
lib/tarakan_web/controllers/seo_controller.ex
Normal file
289
lib/tarakan_web/controllers/seo_controller.ex
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
defmodule TarakanWeb.SEOController do
|
||||
@moduledoc """
|
||||
Search-engine discovery endpoints.
|
||||
|
||||
Streams listed repos and findings via cursors. Single-file sitemap stays
|
||||
under 50k URLs; multi-file index kicks in at 20k listed repositories.
|
||||
"""
|
||||
|
||||
use TarakanWeb, :controller
|
||||
|
||||
alias Tarakan.Repositories
|
||||
alias Tarakan.Scans
|
||||
alias Tarakan.Work
|
||||
alias TarakanWeb.RepositoryPaths
|
||||
|
||||
@max_urls 50_000
|
||||
@multi_file_listed_threshold 20_000
|
||||
@child_page_size 10_000
|
||||
|
||||
def robots(conn, _params) do
|
||||
body = """
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Disallow: /*/code/
|
||||
Disallow: /findings/*/code
|
||||
|
||||
Sitemap: #{url(~p"/sitemap.xml")}
|
||||
"""
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("text/plain")
|
||||
|> send_resp(200, body)
|
||||
end
|
||||
|
||||
def security_txt(conn, _params) do
|
||||
contact = Application.get_env(:tarakan, :security_contact, "security@example.com")
|
||||
base = TarakanWeb.Endpoint.url()
|
||||
|
||||
expires =
|
||||
DateTime.utc_now()
|
||||
|> DateTime.add(365, :day)
|
||||
|> DateTime.truncate(:second)
|
||||
|> DateTime.to_iso8601()
|
||||
|
||||
body = """
|
||||
Contact: mailto:#{contact}
|
||||
Expires: #{expires}
|
||||
Preferred-Languages: en
|
||||
Canonical: #{base}/.well-known/security.txt
|
||||
Policy: #{base}/policies/disclosure
|
||||
"""
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("text/plain")
|
||||
|> send_resp(200, body)
|
||||
end
|
||||
|
||||
def sitemap(conn, _params) do
|
||||
listed = Repositories.registry_stats().repositories || 0
|
||||
|
||||
if listed >= @multi_file_listed_threshold do
|
||||
sitemap_index(conn)
|
||||
else
|
||||
sitemap_single(conn)
|
||||
end
|
||||
end
|
||||
|
||||
def sitemap_hubs(conn, _params) do
|
||||
base = TarakanWeb.Endpoint.url()
|
||||
now = DateTime.utc_now()
|
||||
|
||||
entries =
|
||||
hub_entries(now)
|
||||
|> Enum.map(fn {path, lastmod, priority} -> url_entry(base, path, lastmod, priority) end)
|
||||
|
||||
send_urlset(conn, entries)
|
||||
end
|
||||
|
||||
def sitemap_repos(conn, %{"page" => page}) do
|
||||
page = parse_page(page)
|
||||
base = TarakanWeb.Endpoint.url()
|
||||
offset = (page - 1) * @child_page_size
|
||||
|
||||
{entries, _} =
|
||||
Repositories.stream_listed_repositories(limit: 1_000)
|
||||
|> Enum.reduce_while({[], 0}, fn repo, {acc, i} ->
|
||||
cond do
|
||||
i < offset ->
|
||||
{:cont, {acc, i + 1}}
|
||||
|
||||
length(acc) >= @child_page_size ->
|
||||
{:halt, {acc, i}}
|
||||
|
||||
true ->
|
||||
entry =
|
||||
{RepositoryPaths.repository_security_path(repo), repo.updated_at, "0.8"}
|
||||
|
||||
{:cont, {[entry | acc], i + 1}}
|
||||
end
|
||||
end)
|
||||
|
||||
xml_entries =
|
||||
entries
|
||||
|> Enum.reverse()
|
||||
|> Enum.map(fn {path, lastmod, priority} -> url_entry(base, path, lastmod, priority) end)
|
||||
|
||||
send_urlset(conn, xml_entries)
|
||||
end
|
||||
|
||||
def sitemap_findings(conn, %{"page" => page}) do
|
||||
page = parse_page(page)
|
||||
base = TarakanWeb.Endpoint.url()
|
||||
offset = (page - 1) * @child_page_size
|
||||
|
||||
{entries, _} =
|
||||
Scans.stream_indexable_findings()
|
||||
|> Enum.reduce_while({[], 0}, fn finding, {acc, i} ->
|
||||
cond do
|
||||
i < offset ->
|
||||
{:cont, {acc, i + 1}}
|
||||
|
||||
length(acc) >= @child_page_size ->
|
||||
{:halt, {acc, i}}
|
||||
|
||||
true ->
|
||||
priority =
|
||||
cond do
|
||||
Map.get(finding, :verified) == true -> "0.85"
|
||||
(Map.get(finding, :confirmations_count) || 0) > 0 -> "0.75"
|
||||
true -> "0.65"
|
||||
end
|
||||
|
||||
entry = {~p"/findings/#{finding.public_id}", finding.updated_at, priority}
|
||||
{:cont, {[entry | acc], i + 1}}
|
||||
end
|
||||
end)
|
||||
|
||||
xml_entries =
|
||||
entries
|
||||
|> Enum.reverse()
|
||||
|> Enum.map(fn {path, lastmod, priority} -> url_entry(base, path, lastmod, priority) end)
|
||||
|
||||
send_urlset(conn, xml_entries)
|
||||
end
|
||||
|
||||
def sitemap_jobs(conn, %{"page" => page}) do
|
||||
page = parse_page(page)
|
||||
base = TarakanWeb.Endpoint.url()
|
||||
|
||||
entries =
|
||||
Work.list_indexable_public_tasks()
|
||||
|> Enum.drop((page - 1) * @child_page_size)
|
||||
|> Enum.take(@child_page_size)
|
||||
|> Enum.map(fn task ->
|
||||
priority = if task.kind == "verify_findings", do: "0.8", else: "0.7"
|
||||
url_entry(base, ~p"/jobs/#{task.id}", task.updated_at, priority)
|
||||
end)
|
||||
|
||||
send_urlset(conn, entries)
|
||||
end
|
||||
|
||||
defp sitemap_index(conn) do
|
||||
base = TarakanWeb.Endpoint.url()
|
||||
listed = Repositories.registry_stats().repositories || 0
|
||||
repo_pages = max(ceil(listed / @child_page_size), 1)
|
||||
# Findings unknown without full count; emit a few pages (crawlers stop on empty).
|
||||
finding_pages = 5
|
||||
job_pages = 1
|
||||
|
||||
locs =
|
||||
["#{base}/sitemap/hubs.xml"] ++
|
||||
Enum.map(1..repo_pages, &"#{base}/sitemap/repos/#{&1}") ++
|
||||
Enum.map(1..finding_pages, &"#{base}/sitemap/findings/#{&1}") ++
|
||||
Enum.map(1..job_pages, &"#{base}/sitemap/jobs/#{&1}")
|
||||
|
||||
body = [
|
||||
~s(<?xml version="1.0" encoding="UTF-8"?>),
|
||||
~s(<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">),
|
||||
Enum.map(locs, fn loc ->
|
||||
["<sitemap><loc>", xml_escape(loc), "</loc></sitemap>"]
|
||||
end),
|
||||
"</sitemapindex>"
|
||||
]
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("text/xml")
|
||||
|> send_resp(200, body)
|
||||
end
|
||||
|
||||
defp sitemap_single(conn) do
|
||||
base = TarakanWeb.Endpoint.url()
|
||||
now = DateTime.utc_now()
|
||||
|
||||
hub_entries = hub_entries(now)
|
||||
|
||||
repository_entries =
|
||||
Repositories.stream_listed_repositories()
|
||||
|> Enum.map(fn repository ->
|
||||
{RepositoryPaths.repository_security_path(repository), repository.updated_at, "0.8"}
|
||||
end)
|
||||
|
||||
finding_entries =
|
||||
Scans.stream_indexable_findings()
|
||||
|> Enum.map(fn finding ->
|
||||
priority =
|
||||
cond do
|
||||
Map.get(finding, :verified) == true -> "0.85"
|
||||
(Map.get(finding, :confirmations_count) || 0) > 0 -> "0.75"
|
||||
true -> "0.65"
|
||||
end
|
||||
|
||||
{~p"/findings/#{finding.public_id}", finding.updated_at, priority}
|
||||
end)
|
||||
|
||||
job_entries =
|
||||
Enum.map(Work.list_indexable_public_tasks(), fn task ->
|
||||
priority = if task.kind == "verify_findings", do: "0.8", else: "0.7"
|
||||
{~p"/jobs/#{task.id}", task.updated_at, priority}
|
||||
end)
|
||||
|
||||
entries =
|
||||
(hub_entries ++ finding_entries ++ job_entries ++ repository_entries)
|
||||
|> Enum.take(@max_urls)
|
||||
|> Enum.map(fn
|
||||
{path, lastmod, priority} -> url_entry(base, path, lastmod, priority)
|
||||
{path, lastmod} -> url_entry(base, path, lastmod, "0.5")
|
||||
end)
|
||||
|
||||
send_urlset(conn, entries)
|
||||
end
|
||||
|
||||
defp hub_entries(now) do
|
||||
[
|
||||
{"/explore", now, "1.0"},
|
||||
{"/", now, "0.9"},
|
||||
{"/jobs", now, "0.9"},
|
||||
{"/agents", now, "0.9"},
|
||||
{"/infestations", now, "0.85"},
|
||||
{"/leaderboard", now, "0.7"}
|
||||
]
|
||||
end
|
||||
|
||||
defp send_urlset(conn, entries) do
|
||||
xml = [
|
||||
~s(<?xml version="1.0" encoding="UTF-8"?>),
|
||||
~s(<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">),
|
||||
entries,
|
||||
"</urlset>"
|
||||
]
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("text/xml")
|
||||
|> send_resp(200, xml)
|
||||
end
|
||||
|
||||
defp parse_page(page) do
|
||||
case Integer.parse(to_string(page)) do
|
||||
{n, _} when n >= 1 -> n
|
||||
_ -> 1
|
||||
end
|
||||
end
|
||||
|
||||
defp url_entry(base, path, lastmod, priority) do
|
||||
[
|
||||
"<url><loc>",
|
||||
xml_escape(base <> path),
|
||||
"</loc>",
|
||||
lastmod_element(lastmod),
|
||||
"<priority>",
|
||||
priority,
|
||||
"</priority>",
|
||||
"</url>"
|
||||
]
|
||||
end
|
||||
|
||||
defp lastmod_element(nil), do: []
|
||||
|
||||
defp lastmod_element(%DateTime{} = datetime) do
|
||||
["<lastmod>", datetime |> DateTime.to_date() |> Date.to_iso8601(), "</lastmod>"]
|
||||
end
|
||||
|
||||
defp xml_escape(text) do
|
||||
text
|
||||
|> String.replace("&", "&")
|
||||
|> String.replace("<", "<")
|
||||
|> String.replace(">", ">")
|
||||
end
|
||||
end
|
||||
194
lib/tarakan_web/controllers/webhooks/stripe_controller.ex
Normal file
194
lib/tarakan_web/controllers/webhooks/stripe_controller.ex
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
defmodule TarakanWeb.Webhooks.StripeController do
|
||||
@moduledoc """
|
||||
Stripe webhook receiver.
|
||||
|
||||
Verifies the `Stripe-Signature` header against the raw request body (cached
|
||||
by `TarakanWeb.Endpoint.read_body/2`) and processes:
|
||||
|
||||
* `checkout.session.completed` - mode `payment` funds the matching
|
||||
bounty; mode `subscription` activates the account's plan subscription
|
||||
* `customer.subscription.updated` / `customer.subscription.deleted` and
|
||||
`invoice.payment_failed` - sync the subscription lifecycle
|
||||
(`Tarakan.Billing`)
|
||||
|
||||
Unknown events are acknowledged and ignored so Stripe does not retry them
|
||||
forever.
|
||||
"""
|
||||
|
||||
use TarakanWeb, :controller
|
||||
|
||||
alias Tarakan.Billing
|
||||
alias Tarakan.Market
|
||||
|
||||
require Logger
|
||||
|
||||
@signature_tolerance_seconds 300
|
||||
|
||||
def handle(conn, _params) do
|
||||
case verify_signature(conn) do
|
||||
:ok ->
|
||||
process_event(conn.params)
|
||||
json(conn, %{received: true})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: to_string(reason)})
|
||||
end
|
||||
end
|
||||
|
||||
# Checkout completion is shared by bounty funding (mode "payment") and
|
||||
# plan subscriptions (mode "subscription").
|
||||
defp process_event(%{
|
||||
"type" => "checkout.session.completed",
|
||||
"data" => %{"object" => %{"mode" => "subscription"} = session}
|
||||
}) do
|
||||
case Billing.handle_checkout_completed(session) do
|
||||
{:ok, _subscription} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("stripe webhook: subscription checkout not applied: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# Completion is not payment. Delayed-notification methods complete the
|
||||
# session with `payment_status: "unpaid"` and settle later through
|
||||
# `checkout.session.async_payment_succeeded`, so the status is passed through
|
||||
# and the bounty stays pending until Stripe says the money arrived.
|
||||
defp process_event(%{
|
||||
"type" => "checkout.session.completed",
|
||||
"data" => %{"object" => %{"id" => session_id} = session}
|
||||
}) do
|
||||
fund_bounty(session_id, Map.get(session, "payment_status"))
|
||||
end
|
||||
|
||||
defp process_event(%{
|
||||
"type" => "checkout.session.async_payment_succeeded",
|
||||
"data" => %{"object" => %{"id" => session_id}}
|
||||
}) do
|
||||
fund_bounty(session_id, "paid")
|
||||
end
|
||||
|
||||
defp process_event(%{
|
||||
"type" => "checkout.session.async_payment_failed",
|
||||
"data" => %{"object" => %{"id" => session_id}}
|
||||
}) do
|
||||
Logger.warning("stripe webhook: async payment failed for #{session_id}; bounty stays pending")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp process_event(%{
|
||||
"type" => "customer.subscription.updated",
|
||||
"data" => %{"object" => object}
|
||||
}) do
|
||||
Billing.handle_subscription_updated(object)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp process_event(%{
|
||||
"type" => "customer.subscription.deleted",
|
||||
"data" => %{"object" => object}
|
||||
}) do
|
||||
Billing.handle_subscription_deleted(object)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp process_event(%{
|
||||
"type" => "invoice.payment_failed",
|
||||
"data" => %{"object" => object}
|
||||
}) do
|
||||
Billing.handle_invoice_payment_failed(object)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp process_event(_event), do: :ignored
|
||||
|
||||
defp fund_bounty(session_id, payment_status) do
|
||||
case Market.mark_funded(session_id, payment_status) do
|
||||
{:ok, _bounty} ->
|
||||
:ok
|
||||
|
||||
{:error, :payment_not_completed} ->
|
||||
Logger.info(
|
||||
"stripe webhook: session #{session_id} completed unpaid (#{inspect(payment_status)}); " <>
|
||||
"waiting for async payment confirmation"
|
||||
)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning(
|
||||
"stripe webhook: could not fund bounty for #{session_id}: #{inspect(reason)}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_signature(conn) do
|
||||
with {:ok, secret} <- webhook_secret(),
|
||||
{:ok, header} <- signature_header(conn),
|
||||
{:ok, raw_body} <- raw_body(conn),
|
||||
{:ok, timestamp, signatures} <- parse_signature_header(header),
|
||||
:ok <- check_timestamp(timestamp) do
|
||||
expected = sign(secret, timestamp, raw_body)
|
||||
|
||||
if Enum.any?(signatures, &Plug.Crypto.secure_compare(expected, &1)) do
|
||||
:ok
|
||||
else
|
||||
{:error, :invalid_signature}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp webhook_secret do
|
||||
case Application.get_env(:tarakan, :stripe_webhook_secret) do
|
||||
secret when is_binary(secret) and secret != "" -> {:ok, secret}
|
||||
_missing -> {:error, :webhook_not_configured}
|
||||
end
|
||||
end
|
||||
|
||||
defp signature_header(conn) do
|
||||
case Plug.Conn.get_req_header(conn, "stripe-signature") do
|
||||
[header | _rest] -> {:ok, header}
|
||||
[] -> {:error, :missing_signature}
|
||||
end
|
||||
end
|
||||
|
||||
defp raw_body(conn) do
|
||||
case conn.assigns[:raw_body] do
|
||||
body when is_binary(body) -> {:ok, body}
|
||||
_missing -> {:error, :missing_raw_body}
|
||||
end
|
||||
end
|
||||
|
||||
# Header shape: t=1492774577,v1=5257a869e7ec...,v1=6ffbb59b...
|
||||
defp parse_signature_header(header) do
|
||||
pairs =
|
||||
for pair <- String.split(header, ","),
|
||||
[key, value] <- [String.split(pair, "=", parts: 2)],
|
||||
do: {key, value}
|
||||
|
||||
timestamp = pairs |> Enum.find_value(fn {key, value} -> key == "t" && value end)
|
||||
|
||||
signatures = for {"v1", value} <- pairs, do: value
|
||||
|
||||
with timestamp when is_binary(timestamp) <- timestamp,
|
||||
{unix, ""} <- Integer.parse(timestamp),
|
||||
true <- signatures != [] do
|
||||
{:ok, unix, signatures}
|
||||
else
|
||||
_invalid -> {:error, :malformed_signature}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_timestamp(timestamp) do
|
||||
if abs(System.system_time(:second) - timestamp) <= @signature_tolerance_seconds do
|
||||
:ok
|
||||
else
|
||||
{:error, :timestamp_outside_tolerance}
|
||||
end
|
||||
end
|
||||
|
||||
defp sign(secret, timestamp, raw_body) do
|
||||
:crypto.mac(:hmac, :sha256, secret, "#{timestamp}.#{raw_body}")
|
||||
|> Base.encode16(case: :lower)
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue