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