tarakan/lib/tarakan/abuse.ex
Maxfield Luke af6077b9c3
All checks were successful
CI and deploy / Test (push) Successful in 4m52s
CI and deploy / Deploy production (push) Successful in 23s
Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at
https://git.elektrine.com/elektrine/tarakan.
2026-07-29 04:43:40 -04:00

105 lines
3.5 KiB
Elixir

defmodule Tarakan.Abuse do
@moduledoc """
Shared anti-abuse helpers: quorum eligibility, client IP hashing, and
network-collusion detection for independent checks.
"""
import Ecto.Query, warn: false
alias Tarakan.Accounts.Account
alias Tarakan.Repo
alias Tarakan.Scans.{Confirmation, FindingCheck}
# New accounts cannot manufacture quorum until they leave probation and age in.
@min_account_age_hours 24
# Same client IP cannot farm quorum across sockpuppets on one issue.
@ip_collusion_days 7
@doc """
Whether an account's standing may contribute to verification quorum.
Probation never counts. Fresh `new`/`contributor` accounts must age in;
trusted reviewers and platform staff count once active.
"""
def quorum_eligible?(%Account{} = account) do
account.state == "active" and (trusted_for_quorum?(account) or account_aged?(account))
end
def quorum_eligible?(_), do: false
def trusted_for_quorum?(%Account{
trust_tier: "reviewer"
}),
do: true
def trusted_for_quorum?(%Account{platform_role: role}) when role in ["moderator", "admin"],
do: true
def trusted_for_quorum?(_), do: false
@doc "SQL-friendly fragment pieces for quorum account filters."
def quorum_account_states, do: ["active"]
def min_account_age_seconds, do: @min_account_age_hours * 3600
def account_age_cutoff do
DateTime.add(DateTime.utc_now(), -@min_account_age_hours * 3600, :second)
end
defp account_aged?(%Account{inserted_at: %DateTime{} = inserted_at}) do
DateTime.compare(inserted_at, account_age_cutoff()) != :gt
end
defp account_aged?(_), do: false
@doc """
Opaque hash of a client IP for collusion signals. Uses the app secret so
hashes are not reversible offline without the secret.
"""
def hash_client_ip(nil), do: nil
def hash_client_ip(""), do: nil
def hash_client_ip(ip) when is_binary(ip) do
secret = Application.get_env(:tarakan, TarakanWeb.Endpoint)[:secret_key_base] || "tarakan"
:crypto.mac(:hmac, :sha256, secret, "client-ip:" <> String.trim(ip))
end
def hash_client_ip(_), do: nil
@doc """
True when another account already checked this canonical finding from the
same client IP recently. Used to withhold quorum credit, not to hide the check.
"""
def colluding_ip_check?(canonical_id, account_id, client_ip_hash)
when is_integer(canonical_id) and is_integer(account_id) and is_binary(client_ip_hash) do
since = DateTime.add(DateTime.utc_now(), -@ip_collusion_days, :day)
Repo.exists?(
from check in FindingCheck,
where:
check.canonical_finding_id == ^canonical_id and
check.account_id != ^account_id and
check.client_ip_hash == ^client_ip_hash and
check.inserted_at >= ^since
)
end
def colluding_ip_check?(_canonical_id, _account_id, _hash), do: false
@doc "Same-network collusion for whole-report confirmations."
def colluding_ip_confirmation?(scan_id, account_id, client_ip_hash)
when is_integer(scan_id) and is_integer(account_id) and is_binary(client_ip_hash) do
since = DateTime.add(DateTime.utc_now(), -@ip_collusion_days, :day)
Repo.exists?(
from confirmation in Confirmation,
where:
confirmation.scan_id == ^scan_id and
confirmation.account_id != ^account_id and
confirmation.client_ip_hash == ^client_ip_hash and
confirmation.inserted_at >= ^since
)
end
def colluding_ip_confirmation?(_scan_id, _account_id, _hash), do: false
end