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
4
priv/repo/migrations/.formatter.exs
Normal file
4
priv/repo/migrations/.formatter.exs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[
|
||||
import_deps: [:ecto_sql],
|
||||
inputs: ["*.exs"]
|
||||
]
|
||||
27
priv/repo/migrations/20260709225522_create_repositories.exs
Normal file
27
priv/repo/migrations/20260709225522_create_repositories.exs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
defmodule Tarakan.Repo.Migrations.CreateRepositories do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:repositories) do
|
||||
add :host, :string, null: false, default: "github.com"
|
||||
add :owner, :string, null: false
|
||||
add :name, :string, null: false
|
||||
add :canonical_url, :string, null: false
|
||||
add :status, :string, null: false, default: "unscanned"
|
||||
add :scan_count, :integer, null: false, default: 0
|
||||
add :open_findings_count, :integer, null: false, default: 0
|
||||
add :verified_findings_count, :integer, null: false, default: 0
|
||||
add :last_scanned_at, :utc_datetime_usec
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create unique_index(:repositories, [:host, :owner, :name])
|
||||
create index(:repositories, [:status])
|
||||
create index(:repositories, [:last_scanned_at])
|
||||
|
||||
create constraint(:repositories, :repositories_status_must_be_valid,
|
||||
check: "status IN ('unscanned', 'scanning', 'findings', 'clear', 'stale')"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
defmodule Tarakan.Repo.Migrations.CreateUsersAndAddGithubMetadataToRepositories do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:users) do
|
||||
add :github_id, :bigint, null: false
|
||||
add :github_login, :string, null: false
|
||||
add :name, :string
|
||||
add :avatar_url, :string
|
||||
add :profile_url, :string, null: false
|
||||
add :last_login_at, :utc_datetime_usec, null: false
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create unique_index(:users, [:github_id])
|
||||
create unique_index(:users, [:github_login])
|
||||
|
||||
alter table(:repositories) do
|
||||
add :github_id, :bigint
|
||||
add :default_branch, :string
|
||||
add :description, :text
|
||||
add :primary_language, :string
|
||||
add :stars_count, :integer, null: false, default: 0
|
||||
add :forks_count, :integer, null: false, default: 0
|
||||
add :archived, :boolean, null: false, default: false
|
||||
add :last_synced_at, :utc_datetime_usec
|
||||
add :submitted_by_id, references(:users, on_delete: :nilify_all)
|
||||
end
|
||||
|
||||
create unique_index(:repositories, [:github_id], where: "github_id IS NOT NULL")
|
||||
create index(:repositories, [:submitted_by_id])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
defmodule Tarakan.Repo.Migrations.CreateAccountsAuthTables do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
execute "CREATE EXTENSION IF NOT EXISTS citext", ""
|
||||
|
||||
create table(:accounts) do
|
||||
add :handle, :citext, null: false
|
||||
add :display_name, :string
|
||||
add :email, :citext
|
||||
add :hashed_password, :string
|
||||
add :confirmed_at, :utc_datetime
|
||||
add :reputation, :bigint, null: false, default: 0
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:accounts, [:handle])
|
||||
create unique_index(:accounts, [:email], where: "email IS NOT NULL")
|
||||
|
||||
create table(:accounts_tokens) do
|
||||
add :account_id, references(:accounts, on_delete: :delete_all), null: false
|
||||
add :token, :binary, null: false
|
||||
add :context, :string, null: false
|
||||
add :sent_to, :string
|
||||
add :authenticated_at, :utc_datetime
|
||||
|
||||
timestamps(type: :utc_datetime, updated_at: false)
|
||||
end
|
||||
|
||||
create index(:accounts_tokens, [:account_id])
|
||||
create unique_index(:accounts_tokens, [:context, :token])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
defmodule Tarakan.Repo.Migrations.RefactorGithubUsersIntoIdentities do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
rename table(:users), to: table(:identities)
|
||||
rename table(:identities), :github_id, to: :provider_uid
|
||||
rename table(:identities), :github_login, to: :provider_login
|
||||
|
||||
execute(
|
||||
"ALTER TABLE identities ALTER COLUMN provider_uid TYPE varchar USING provider_uid::varchar",
|
||||
"ALTER TABLE identities ALTER COLUMN provider_uid TYPE bigint USING provider_uid::bigint"
|
||||
)
|
||||
|
||||
drop index(:identities, [:provider_uid], name: :users_github_id_index)
|
||||
drop index(:identities, [:provider_login], name: :users_github_login_index)
|
||||
|
||||
alter table(:identities) do
|
||||
add :provider, :string, null: false, default: "github"
|
||||
add :account_id, references(:accounts, on_delete: :delete_all)
|
||||
end
|
||||
|
||||
execute(
|
||||
"""
|
||||
INSERT INTO accounts
|
||||
(handle, display_name, confirmed_at, reputation, inserted_at, updated_at)
|
||||
SELECT
|
||||
CASE
|
||||
WHEN char_length(lower(provider_login)) <= 32 THEN lower(provider_login)
|
||||
ELSE left(lower(provider_login), 20) || '-' || id::varchar
|
||||
END,
|
||||
name,
|
||||
date_trunc('second', now()),
|
||||
0,
|
||||
date_trunc('second', now()),
|
||||
date_trunc('second', now())
|
||||
FROM identities
|
||||
""",
|
||||
"SELECT 1"
|
||||
)
|
||||
|
||||
execute(
|
||||
"""
|
||||
UPDATE identities AS identity
|
||||
SET account_id = account.id
|
||||
FROM accounts AS account
|
||||
WHERE account.handle = CASE
|
||||
WHEN char_length(lower(identity.provider_login)) <= 32
|
||||
THEN lower(identity.provider_login)
|
||||
ELSE left(lower(identity.provider_login), 20) || '-' || identity.id::varchar
|
||||
END
|
||||
""",
|
||||
"SELECT 1"
|
||||
)
|
||||
|
||||
execute(
|
||||
"ALTER TABLE identities ALTER COLUMN account_id SET NOT NULL",
|
||||
"ALTER TABLE identities ALTER COLUMN account_id DROP NOT NULL"
|
||||
)
|
||||
|
||||
create unique_index(:identities, [:provider, :provider_uid])
|
||||
create unique_index(:identities, [:provider, :provider_login])
|
||||
create index(:identities, [:account_id])
|
||||
|
||||
rename table(:repositories), :submitted_by_id, to: :submitted_by_identity_id
|
||||
|
||||
execute(
|
||||
"ALTER TABLE repositories RENAME CONSTRAINT repositories_submitted_by_id_fkey TO repositories_submitted_by_identity_id_fkey",
|
||||
"ALTER TABLE repositories RENAME CONSTRAINT repositories_submitted_by_identity_id_fkey TO repositories_submitted_by_id_fkey"
|
||||
)
|
||||
|
||||
drop index(:repositories, [:submitted_by_identity_id],
|
||||
name: :repositories_submitted_by_id_index
|
||||
)
|
||||
|
||||
alter table(:repositories) do
|
||||
add :submitted_by_id, references(:accounts, on_delete: :nilify_all)
|
||||
end
|
||||
|
||||
execute(
|
||||
"""
|
||||
UPDATE repositories AS repository
|
||||
SET submitted_by_id = identity.account_id
|
||||
FROM identities AS identity
|
||||
WHERE repository.submitted_by_identity_id = identity.id
|
||||
""",
|
||||
"SELECT 1"
|
||||
)
|
||||
|
||||
create index(:repositories, [:submitted_by_id])
|
||||
create index(:repositories, [:submitted_by_identity_id])
|
||||
end
|
||||
end
|
||||
78
priv/repo/migrations/20260710175409_create_scans.exs
Normal file
78
priv/repo/migrations/20260710175409_create_scans.exs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
defmodule Tarakan.Repo.Migrations.CreateScans do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:scans) do
|
||||
add :repository_id, references(:repositories, on_delete: :delete_all), null: false
|
||||
add :submitted_by_id, references(:accounts, on_delete: :restrict), null: false
|
||||
add :commit_sha, :string, null: false
|
||||
add :commit_committed_at, :utc_datetime_usec
|
||||
add :model, :string, null: false
|
||||
add :prompt_version, :string, null: false
|
||||
add :notes, :text
|
||||
add :findings_count, :integer, null: false, default: 0
|
||||
add :confirmations_count, :integer, null: false, default: 0
|
||||
add :disputes_count, :integer, null: false, default: 0
|
||||
add :verified_at, :utc_datetime_usec
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create index(:scans, [:repository_id, "inserted_at DESC"])
|
||||
create index(:scans, [:submitted_by_id])
|
||||
|
||||
create unique_index(
|
||||
:scans,
|
||||
[:repository_id, :submitted_by_id, :commit_sha, :model, :prompt_version],
|
||||
name: :scans_unique_submission_index
|
||||
)
|
||||
|
||||
create constraint(:scans, :scans_commit_sha_must_be_full_sha,
|
||||
check: "commit_sha ~ '^[0-9a-f]{40}$'"
|
||||
)
|
||||
|
||||
create constraint(:scans, :scans_counts_must_be_nonnegative,
|
||||
check: "findings_count >= 0 AND confirmations_count >= 0 AND disputes_count >= 0"
|
||||
)
|
||||
|
||||
create table(:scan_findings) do
|
||||
add :scan_id, references(:scans, on_delete: :delete_all), null: false
|
||||
add :position, :integer, null: false, default: 0
|
||||
add :file_path, :string, null: false, size: 500
|
||||
add :line_start, :integer
|
||||
add :line_end, :integer
|
||||
add :severity, :string, null: false
|
||||
add :title, :string, null: false
|
||||
add :description, :text, null: false
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create index(:scan_findings, [:scan_id])
|
||||
|
||||
create constraint(:scan_findings, :scan_findings_severity_must_be_valid,
|
||||
check: "severity IN ('critical', 'high', 'medium', 'low', 'info')"
|
||||
)
|
||||
|
||||
create constraint(:scan_findings, :scan_findings_lines_must_be_ordered,
|
||||
check:
|
||||
"(line_start IS NULL) OR (line_start >= 1 AND (line_end IS NULL OR line_end >= line_start))"
|
||||
)
|
||||
|
||||
create table(:scan_confirmations) do
|
||||
add :scan_id, references(:scans, on_delete: :delete_all), null: false
|
||||
add :account_id, references(:accounts, on_delete: :restrict), null: false
|
||||
add :verdict, :string, null: false
|
||||
add :notes, :text
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create unique_index(:scan_confirmations, [:scan_id, :account_id])
|
||||
create index(:scan_confirmations, [:account_id])
|
||||
|
||||
create constraint(:scan_confirmations, :scan_confirmations_verdict_must_be_valid,
|
||||
check: "verdict IN ('confirmed', 'disputed')"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddReviewProvenance do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
drop_if_exists index(
|
||||
:scans,
|
||||
[:repository_id, :submitted_by_id, :commit_sha, :model, :prompt_version],
|
||||
name: :scans_unique_submission_index
|
||||
)
|
||||
|
||||
alter table(:scans) do
|
||||
add :provenance, :string, null: false, default: "agent"
|
||||
add :review_kind, :string, null: false, default: "code_review"
|
||||
modify :model, :string, null: true, from: {:string, null: false}
|
||||
modify :prompt_version, :string, null: true, from: {:string, null: false}
|
||||
end
|
||||
|
||||
create unique_index(
|
||||
:scans,
|
||||
[
|
||||
:repository_id,
|
||||
:submitted_by_id,
|
||||
:commit_sha,
|
||||
:provenance,
|
||||
:review_kind,
|
||||
"COALESCE(model, '')",
|
||||
"COALESCE(prompt_version, '')"
|
||||
],
|
||||
name: :scans_unique_submission_index
|
||||
)
|
||||
|
||||
create constraint(:scans, :scans_provenance_must_be_valid,
|
||||
check: "provenance IN ('agent', 'human', 'hybrid')"
|
||||
)
|
||||
|
||||
create constraint(:scans, :scans_review_kind_must_be_valid,
|
||||
check:
|
||||
"review_kind IN ('code_review', 'threat_model', 'privacy_review', 'business_logic')"
|
||||
)
|
||||
|
||||
alter table(:scan_confirmations) do
|
||||
add :provenance, :string, null: false, default: "human"
|
||||
end
|
||||
|
||||
create constraint(:scan_confirmations, :scan_confirmations_provenance_must_be_valid,
|
||||
check: "provenance IN ('agent', 'human', 'hybrid')"
|
||||
)
|
||||
end
|
||||
end
|
||||
63
priv/repo/migrations/20260710225758_create_review_tasks.exs
Normal file
63
priv/repo/migrations/20260710225758_create_review_tasks.exs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
defmodule Tarakan.Repo.Migrations.CreateReviewTasks do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:review_tasks) do
|
||||
add :repository_id, references(:repositories, on_delete: :delete_all), null: false
|
||||
add :created_by_id, references(:accounts, on_delete: :restrict), null: false
|
||||
add :claimed_by_id, references(:accounts, on_delete: :restrict)
|
||||
add :commit_sha, :string, null: false
|
||||
add :commit_committed_at, :utc_datetime_usec
|
||||
add :kind, :string, null: false
|
||||
add :capability, :string, null: false, default: "human"
|
||||
add :title, :string, null: false
|
||||
add :description, :text, null: false
|
||||
add :status, :string, null: false, default: "open"
|
||||
add :claimed_at, :utc_datetime_usec
|
||||
add :claim_expires_at, :utc_datetime_usec
|
||||
add :completed_at, :utc_datetime_usec
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create index(:review_tasks, [:repository_id, :status, "inserted_at DESC"])
|
||||
create index(:review_tasks, [:created_by_id])
|
||||
create index(:review_tasks, [:claimed_by_id])
|
||||
|
||||
create constraint(:review_tasks, :review_tasks_commit_sha_must_be_full_sha,
|
||||
check: "commit_sha ~ '^[0-9a-f]{40}$'"
|
||||
)
|
||||
|
||||
create constraint(:review_tasks, :review_tasks_kind_must_be_valid,
|
||||
check:
|
||||
"kind IN ('code_review', 'threat_model', 'privacy_review', 'business_logic', 'verify_findings', 'write_fix')"
|
||||
)
|
||||
|
||||
create constraint(:review_tasks, :review_tasks_capability_must_be_valid,
|
||||
check: "capability IN ('human', 'agent', 'hybrid')"
|
||||
)
|
||||
|
||||
create constraint(:review_tasks, :review_tasks_status_must_be_valid,
|
||||
check: "status IN ('open', 'claimed', 'completed')"
|
||||
)
|
||||
|
||||
create table(:review_task_contributions) do
|
||||
add :review_task_id, references(:review_tasks, on_delete: :delete_all), null: false
|
||||
add :account_id, references(:accounts, on_delete: :restrict), null: false
|
||||
add :provenance, :string, null: false
|
||||
add :summary, :text, null: false
|
||||
add :evidence, :text
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create unique_index(:review_task_contributions, [:review_task_id])
|
||||
create index(:review_task_contributions, [:account_id])
|
||||
|
||||
create constraint(
|
||||
:review_task_contributions,
|
||||
:review_task_contributions_provenance_must_be_valid,
|
||||
check: "provenance IN ('human', 'agent', 'hybrid')"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddAdversarialReviewTaskLifecycle do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
execute(
|
||||
"ALTER TABLE review_tasks DROP CONSTRAINT IF EXISTS review_tasks_status_must_be_valid"
|
||||
)
|
||||
|
||||
alter table(:review_tasks) do
|
||||
modify :status, :string, default: "proposed", from: {:string, default: "open"}
|
||||
add :published_at, :utc_datetime_usec
|
||||
add :submitted_at, :utc_datetime_usec
|
||||
add :reviewed_at, :utc_datetime_usec
|
||||
add :reviewed_by_id, references(:accounts, on_delete: :restrict)
|
||||
end
|
||||
|
||||
execute("""
|
||||
UPDATE review_tasks
|
||||
SET published_at = inserted_at,
|
||||
submitted_at = CASE WHEN status = 'completed' THEN completed_at ELSE NULL END,
|
||||
reviewed_at = CASE WHEN status = 'completed' THEN completed_at ELSE NULL END,
|
||||
status = CASE WHEN status = 'completed' THEN 'accepted' ELSE status END
|
||||
""")
|
||||
|
||||
create constraint(:review_tasks, :review_tasks_status_must_be_valid,
|
||||
check:
|
||||
"status IN ('proposed', 'open', 'claimed', 'submitted', 'accepted', 'changes_requested', 'rejected', 'cancelled')"
|
||||
)
|
||||
|
||||
create index(:review_tasks, [:reviewed_by_id])
|
||||
create index(:review_tasks, [:repository_id, :created_by_id, :status])
|
||||
create index(:review_tasks, [:repository_id, :claimed_by_id, :status])
|
||||
|
||||
drop_if_exists unique_index(:review_task_contributions, [:review_task_id])
|
||||
|
||||
alter table(:review_task_contributions) do
|
||||
add :version, :integer, null: false, default: 1
|
||||
end
|
||||
|
||||
execute("""
|
||||
UPDATE review_task_contributions
|
||||
SET evidence = 'Legacy contribution did not include separate reproduction evidence.'
|
||||
WHERE evidence IS NULL OR btrim(evidence) = ''
|
||||
""")
|
||||
|
||||
alter table(:review_task_contributions) do
|
||||
modify :evidence, :text, null: false, from: :text
|
||||
end
|
||||
|
||||
create unique_index(:review_task_contributions, [:review_task_id, :version])
|
||||
|
||||
alter table(:review_tasks) do
|
||||
add :latest_contribution_id,
|
||||
references(:review_task_contributions, on_delete: :nilify_all)
|
||||
end
|
||||
|
||||
execute("""
|
||||
UPDATE review_tasks AS task
|
||||
SET latest_contribution_id = contribution.id
|
||||
FROM review_task_contributions AS contribution
|
||||
WHERE contribution.review_task_id = task.id
|
||||
""")
|
||||
|
||||
create index(:review_tasks, [:latest_contribution_id])
|
||||
|
||||
create table(:review_task_decisions) do
|
||||
add :review_task_id, references(:review_tasks, on_delete: :delete_all), null: false
|
||||
add :account_id, references(:accounts, on_delete: :restrict), null: false
|
||||
add :action, :string, null: false
|
||||
add :reason, :text, null: false
|
||||
add :evidence, :text
|
||||
|
||||
timestamps(type: :utc_datetime_usec, updated_at: false)
|
||||
end
|
||||
|
||||
create index(:review_task_decisions, [:review_task_id, "inserted_at DESC"])
|
||||
create index(:review_task_decisions, [:account_id])
|
||||
|
||||
create constraint(:review_task_decisions, :review_task_decisions_action_must_be_valid,
|
||||
check: "action IN ('publish', 'accept', 'request_changes', 'reject', 'cancel')"
|
||||
)
|
||||
end
|
||||
|
||||
def down do
|
||||
drop table(:review_task_decisions)
|
||||
|
||||
alter table(:review_tasks) do
|
||||
remove :latest_contribution_id
|
||||
end
|
||||
|
||||
drop unique_index(:review_task_contributions, [:review_task_id, :version])
|
||||
|
||||
alter table(:review_task_contributions) do
|
||||
remove :version
|
||||
modify :evidence, :text, null: true, from: {:text, null: false}
|
||||
end
|
||||
|
||||
create unique_index(:review_task_contributions, [:review_task_id])
|
||||
|
||||
drop constraint(:review_tasks, :review_tasks_status_must_be_valid)
|
||||
|
||||
execute("UPDATE review_tasks SET status = 'completed' WHERE status = 'accepted'")
|
||||
|
||||
execute(
|
||||
"UPDATE review_tasks SET status = 'open' WHERE status NOT IN ('open', 'claimed', 'completed')"
|
||||
)
|
||||
|
||||
alter table(:review_tasks) do
|
||||
remove :reviewed_by_id
|
||||
remove :reviewed_at
|
||||
remove :submitted_at
|
||||
remove :published_at
|
||||
modify :status, :string, default: "open", from: {:string, default: "proposed"}
|
||||
end
|
||||
|
||||
create constraint(:review_tasks, :review_tasks_status_must_be_valid,
|
||||
check: "status IN ('open', 'claimed', 'completed')"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddScanReviewControls do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:scans) do
|
||||
add :review_status, :string, null: false, default: "quarantined"
|
||||
add :visibility, :string, null: false, default: "restricted"
|
||||
add :moderation_reason, :string
|
||||
add :moderation_notes, :text
|
||||
add :reviewed_at, :utc_datetime_usec
|
||||
|
||||
add :reviewed_by_id, references(:accounts, on_delete: :restrict)
|
||||
end
|
||||
|
||||
create index(:scans, [:repository_id, :review_status, :visibility, "inserted_at DESC"],
|
||||
name: :scans_public_record_index
|
||||
)
|
||||
|
||||
create index(:scans, [:reviewed_by_id])
|
||||
|
||||
create constraint(:scans, :scans_review_status_must_be_valid,
|
||||
check: "review_status IN ('quarantined', 'accepted', 'rejected', 'contested')"
|
||||
)
|
||||
|
||||
create constraint(:scans, :scans_visibility_must_be_valid,
|
||||
check: "visibility IN ('restricted', 'public_summary', 'public')"
|
||||
)
|
||||
|
||||
create constraint(:scans, :scans_publication_requires_acceptance,
|
||||
check: "visibility = 'restricted' OR review_status = 'accepted'"
|
||||
)
|
||||
|
||||
create constraint(:scans, :scans_acceptance_requires_verification,
|
||||
check: "review_status != 'accepted' OR verified_at IS NOT NULL"
|
||||
)
|
||||
|
||||
create constraint(:scans, :scans_acceptance_requires_quorum,
|
||||
check: "review_status != 'accepted' OR confirmations_count - disputes_count >= 2"
|
||||
)
|
||||
|
||||
create constraint(:scans, :scans_moderation_requires_attribution,
|
||||
check:
|
||||
"review_status = 'quarantined' OR (reviewed_by_id IS NOT NULL AND reviewed_at IS NOT NULL AND length(btrim(moderation_reason)) >= 3 AND length(btrim(moderation_notes)) >= 20)"
|
||||
)
|
||||
|
||||
execute "UPDATE repositories SET status = 'unscanned' WHERE status = 'clear'"
|
||||
drop constraint(:repositories, :repositories_status_must_be_valid)
|
||||
|
||||
create constraint(:repositories, :repositories_status_must_be_valid,
|
||||
check: "status IN ('unscanned', 'scanning', 'findings', 'reviewed', 'stale')"
|
||||
)
|
||||
end
|
||||
|
||||
def down do
|
||||
execute "UPDATE repositories SET status = 'unscanned' WHERE status = 'reviewed'"
|
||||
drop constraint(:repositories, :repositories_status_must_be_valid)
|
||||
|
||||
create constraint(:repositories, :repositories_status_must_be_valid,
|
||||
check: "status IN ('unscanned', 'scanning', 'findings', 'clear', 'stale')"
|
||||
)
|
||||
|
||||
drop constraint(:scans, :scans_moderation_requires_attribution)
|
||||
drop constraint(:scans, :scans_acceptance_requires_quorum)
|
||||
drop constraint(:scans, :scans_acceptance_requires_verification)
|
||||
drop constraint(:scans, :scans_publication_requires_acceptance)
|
||||
drop constraint(:scans, :scans_visibility_must_be_valid)
|
||||
drop constraint(:scans, :scans_review_status_must_be_valid)
|
||||
drop index(:scans, [:reviewed_by_id])
|
||||
drop index(:scans, name: :scans_public_record_index)
|
||||
|
||||
alter table(:scans) do
|
||||
remove :reviewed_by_id
|
||||
remove :reviewed_at
|
||||
remove :moderation_notes
|
||||
remove :moderation_reason
|
||||
remove :visibility
|
||||
remove :review_status
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
defmodule Tarakan.Repo.Migrations.CreateApiCredentials do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:api_credentials) do
|
||||
add :account_id, references(:accounts, on_delete: :delete_all), null: false
|
||||
add :repository_id, references(:repositories, on_delete: :delete_all)
|
||||
add :name, :string, null: false
|
||||
add :token_hash, :binary, null: false
|
||||
add :token_prefix, :string, null: false
|
||||
add :scopes, {:array, :string}, null: false, default: []
|
||||
add :expires_at, :utc_datetime_usec, null: false
|
||||
add :last_used_at, :utc_datetime_usec
|
||||
add :revoked_at, :utc_datetime_usec
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create unique_index(:api_credentials, [:token_hash])
|
||||
create index(:api_credentials, [:account_id, :revoked_at])
|
||||
create index(:api_credentials, [:repository_id])
|
||||
create index(:api_credentials, [:expires_at])
|
||||
|
||||
create constraint(:api_credentials, :api_credentials_scopes_must_be_valid,
|
||||
check:
|
||||
"scopes <@ ARRAY['tasks:read', 'tasks:claim', 'contributions:write', 'findings:submit']::varchar[]"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddAuthorizationFoundations do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:accounts) do
|
||||
add :state, :string, null: false, default: "probation"
|
||||
add :platform_role, :string, null: false, default: "member"
|
||||
add :trust_tier, :string, null: false, default: "new"
|
||||
end
|
||||
|
||||
create constraint(:accounts, :accounts_state_must_be_valid,
|
||||
check: "state IN ('probation', 'active', 'restricted', 'suspended', 'banned')"
|
||||
)
|
||||
|
||||
create constraint(:accounts, :accounts_platform_role_must_be_valid,
|
||||
check: "platform_role IN ('member', 'moderator', 'admin')"
|
||||
)
|
||||
|
||||
create constraint(:accounts, :accounts_trust_tier_must_be_valid,
|
||||
check: "trust_tier IN ('new', 'contributor', 'reviewer')"
|
||||
)
|
||||
|
||||
create index(:accounts, [:state])
|
||||
create index(:accounts, [:platform_role])
|
||||
create index(:accounts, [:trust_tier])
|
||||
|
||||
alter table(:repositories) do
|
||||
add :participation_mode, :string, null: false, default: "unclaimed"
|
||||
end
|
||||
|
||||
create constraint(:repositories, :repositories_participation_mode_must_be_valid,
|
||||
check:
|
||||
"participation_mode IN ('unclaimed', 'community', 'maintainer_verified', 'curated', 'paused')"
|
||||
)
|
||||
|
||||
create index(:repositories, [:participation_mode])
|
||||
|
||||
create table(:repository_memberships) do
|
||||
add :repository_id, references(:repositories, on_delete: :delete_all), null: false
|
||||
add :account_id, references(:accounts, on_delete: :delete_all), null: false
|
||||
add :role, :string, null: false
|
||||
add :status, :string, null: false, default: "pending"
|
||||
add :verified_at, :utc_datetime_usec
|
||||
add :verified_by_account_id, references(:accounts)
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create unique_index(:repository_memberships, [:repository_id, :account_id])
|
||||
create index(:repository_memberships, [:account_id, :status])
|
||||
create index(:repository_memberships, [:repository_id, :status, :role])
|
||||
|
||||
create constraint(:repository_memberships, :repository_memberships_role_must_be_valid,
|
||||
check: "role IN ('steward', 'reviewer')"
|
||||
)
|
||||
|
||||
create constraint(:repository_memberships, :repository_memberships_status_must_be_valid,
|
||||
check: "status IN ('verified', 'pending', 'revoked')"
|
||||
)
|
||||
|
||||
create constraint(
|
||||
:repository_memberships,
|
||||
:repository_memberships_verified_state_must_be_consistent,
|
||||
check:
|
||||
"(status = 'verified' AND verified_at IS NOT NULL) OR (status <> 'verified' AND verified_at IS NULL)"
|
||||
)
|
||||
|
||||
create table(:audit_events) do
|
||||
add :actor_id, references(:accounts)
|
||||
add :token_id, :bigint
|
||||
add :action, :string, null: false
|
||||
add :subject_type, :string
|
||||
add :subject_id, :bigint
|
||||
add :repository_id, references(:repositories)
|
||||
add :from_state, :string
|
||||
add :to_state, :string
|
||||
add :reason_code, :string
|
||||
add :request_id, :string
|
||||
add :client_version, :string
|
||||
add :metadata, :map, null: false, default: %{}
|
||||
|
||||
timestamps(type: :utc_datetime_usec, updated_at: false)
|
||||
end
|
||||
|
||||
create index(:audit_events, [:actor_id, :inserted_at])
|
||||
create index(:audit_events, [:repository_id, :inserted_at])
|
||||
create index(:audit_events, [:subject_type, :subject_id, :inserted_at])
|
||||
create index(:audit_events, [:request_id])
|
||||
|
||||
execute """
|
||||
CREATE FUNCTION tarakan_prevent_audit_event_mutation()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'audit_events are append-only';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
""",
|
||||
"DROP FUNCTION IF EXISTS tarakan_prevent_audit_event_mutation()"
|
||||
|
||||
execute """
|
||||
CREATE TRIGGER audit_events_append_only
|
||||
BEFORE UPDATE OR DELETE ON audit_events
|
||||
FOR EACH ROW EXECUTE FUNCTION tarakan_prevent_audit_event_mutation()
|
||||
""",
|
||||
"DROP TRIGGER IF EXISTS audit_events_append_only ON audit_events"
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
defmodule Tarakan.Repo.Migrations.CreateModerationCases do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:moderation_cases) do
|
||||
add :reporter_id, references(:accounts, on_delete: :restrict), null: false
|
||||
add :subject_owner_id, references(:accounts, on_delete: :nilify_all)
|
||||
add :repository_id, references(:repositories, on_delete: :delete_all)
|
||||
add :subject_type, :string, null: false
|
||||
add :subject_id, :bigint, null: false
|
||||
add :reason, :string, null: false
|
||||
add :description, :text, null: false
|
||||
add :status, :string, null: false, default: "open"
|
||||
add :assigned_to_id, references(:accounts, on_delete: :nilify_all)
|
||||
add :resolved_by_id, references(:accounts, on_delete: :restrict)
|
||||
add :resolution, :text
|
||||
add :resolved_at, :utc_datetime_usec
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create index(:moderation_cases, [:status, "inserted_at ASC"])
|
||||
create index(:moderation_cases, [:repository_id, :status])
|
||||
create index(:moderation_cases, [:subject_type, :subject_id])
|
||||
create index(:moderation_cases, [:reporter_id, :inserted_at])
|
||||
create index(:moderation_cases, [:subject_owner_id])
|
||||
|
||||
create unique_index(
|
||||
:moderation_cases,
|
||||
[:reporter_id, :subject_type, :subject_id, :reason],
|
||||
where: "status IN ('open', 'in_review')",
|
||||
name: :moderation_cases_one_open_report_index
|
||||
)
|
||||
|
||||
create constraint(:moderation_cases, :moderation_cases_subject_type_must_be_valid,
|
||||
check:
|
||||
"subject_type IN ('repository', 'account', 'scan', 'finding', 'review_task', 'contribution')"
|
||||
)
|
||||
|
||||
create constraint(:moderation_cases, :moderation_cases_reason_must_be_valid,
|
||||
check:
|
||||
"reason IN ('spam', 'unsafe_disclosure', 'harassment', 'plagiarism', 'malicious_instructions', 'fabricated_evidence', 'secrets_or_pii', 'other')"
|
||||
)
|
||||
|
||||
create constraint(:moderation_cases, :moderation_cases_status_must_be_valid,
|
||||
check: "status IN ('open', 'in_review', 'resolved', 'dismissed')"
|
||||
)
|
||||
|
||||
create table(:moderation_actions) do
|
||||
add :moderation_case_id, references(:moderation_cases, on_delete: :delete_all), null: false
|
||||
add :actor_id, references(:accounts, on_delete: :restrict), null: false
|
||||
add :action, :string, null: false
|
||||
add :reason, :text, null: false
|
||||
add :metadata, :map, null: false, default: %{}
|
||||
|
||||
timestamps(type: :utc_datetime_usec, updated_at: false)
|
||||
end
|
||||
|
||||
create index(:moderation_actions, [:moderation_case_id, "inserted_at ASC"])
|
||||
create index(:moderation_actions, [:actor_id])
|
||||
|
||||
create constraint(:moderation_actions, :moderation_actions_action_must_be_valid,
|
||||
check:
|
||||
"action IN ('assign', 'quarantine', 'redact', 'restore', 'resolve', 'dismiss', 'suspend_account', 'restore_account')"
|
||||
)
|
||||
|
||||
create table(:moderation_appeals) do
|
||||
add :moderation_case_id, references(:moderation_cases, on_delete: :delete_all), null: false
|
||||
add :appellant_id, references(:accounts, on_delete: :restrict), null: false
|
||||
add :reason, :text, null: false
|
||||
add :status, :string, null: false, default: "open"
|
||||
add :decided_by_id, references(:accounts, on_delete: :restrict)
|
||||
add :decision_reason, :text
|
||||
add :decided_at, :utc_datetime_usec
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create unique_index(:moderation_appeals, [:moderation_case_id, :appellant_id])
|
||||
create index(:moderation_appeals, [:status, "inserted_at ASC"])
|
||||
|
||||
create constraint(:moderation_appeals, :moderation_appeals_status_must_be_valid,
|
||||
check: "status IN ('open', 'upheld', 'denied')"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
defmodule Tarakan.Repo.Migrations.HardenModerationIntegrity do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
drop_if_exists index(:moderation_cases, [], name: :moderation_cases_one_open_report_index)
|
||||
|
||||
create unique_index(
|
||||
:moderation_cases,
|
||||
[:reporter_id, :subject_type, :subject_id],
|
||||
where: "status IN ('open', 'in_review')",
|
||||
name: :moderation_cases_one_open_report_index
|
||||
)
|
||||
|
||||
drop constraint(:moderation_cases, :moderation_cases_status_must_be_valid)
|
||||
|
||||
create constraint(:moderation_cases, :moderation_cases_status_must_be_valid,
|
||||
check: "status IN ('open', 'in_review', 'resolved', 'dismissed', 'overturned')"
|
||||
)
|
||||
|
||||
create constraint(
|
||||
:moderation_cases,
|
||||
:moderation_cases_resolution_state_must_be_consistent,
|
||||
check: """
|
||||
(
|
||||
status IN ('open', 'in_review')
|
||||
AND resolved_by_id IS NULL
|
||||
AND resolution IS NULL
|
||||
AND resolved_at IS NULL
|
||||
) OR (
|
||||
status IN ('resolved', 'dismissed', 'overturned')
|
||||
AND resolved_by_id IS NOT NULL
|
||||
AND resolution IS NOT NULL
|
||||
AND resolved_at IS NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
create constraint(
|
||||
:moderation_cases,
|
||||
:moderation_cases_assignment_state_must_be_consistent,
|
||||
check: """
|
||||
(status = 'open' AND assigned_to_id IS NULL)
|
||||
OR (status <> 'open' AND assigned_to_id IS NOT NULL)
|
||||
"""
|
||||
)
|
||||
|
||||
drop constraint(:moderation_actions, :moderation_actions_action_must_be_valid)
|
||||
|
||||
create constraint(:moderation_actions, :moderation_actions_action_must_be_valid,
|
||||
check: """
|
||||
action IN (
|
||||
'assign', 'quarantine', 'redact', 'restore', 'resolve', 'dismiss',
|
||||
'suspend_account', 'restore_account', 'appeal_upheld', 'appeal_denied'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
create constraint(
|
||||
:moderation_appeals,
|
||||
:moderation_appeals_decision_state_must_be_consistent,
|
||||
check: """
|
||||
(
|
||||
status = 'open'
|
||||
AND decided_by_id IS NULL
|
||||
AND decision_reason IS NULL
|
||||
AND decided_at IS NULL
|
||||
) OR (
|
||||
status IN ('upheld', 'denied')
|
||||
AND decided_by_id IS NOT NULL
|
||||
AND decision_reason IS NOT NULL
|
||||
AND decided_at IS NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
execute """
|
||||
ALTER TABLE moderation_cases
|
||||
DROP CONSTRAINT moderation_cases_repository_id_fkey
|
||||
"""
|
||||
|
||||
execute """
|
||||
ALTER TABLE moderation_cases
|
||||
ADD CONSTRAINT moderation_cases_repository_id_fkey
|
||||
FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE SET NULL
|
||||
"""
|
||||
|
||||
execute """
|
||||
CREATE FUNCTION tarakan_prevent_moderation_action_mutation()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'moderation_actions are append-only';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
"""
|
||||
|
||||
execute """
|
||||
CREATE TRIGGER moderation_actions_append_only
|
||||
BEFORE UPDATE OR DELETE ON moderation_actions
|
||||
FOR EACH ROW EXECUTE FUNCTION tarakan_prevent_moderation_action_mutation()
|
||||
"""
|
||||
|
||||
execute """
|
||||
CREATE FUNCTION tarakan_prevent_moderation_history_deletion()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'moderation history cannot be deleted';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
"""
|
||||
|
||||
execute """
|
||||
CREATE TRIGGER moderation_cases_preserve_history
|
||||
BEFORE DELETE ON moderation_cases
|
||||
FOR EACH ROW EXECUTE FUNCTION tarakan_prevent_moderation_history_deletion()
|
||||
"""
|
||||
|
||||
execute """
|
||||
CREATE TRIGGER moderation_appeals_preserve_history
|
||||
BEFORE DELETE ON moderation_appeals
|
||||
FOR EACH ROW EXECUTE FUNCTION tarakan_prevent_moderation_history_deletion()
|
||||
"""
|
||||
end
|
||||
|
||||
def down do
|
||||
execute "DROP TRIGGER IF EXISTS moderation_appeals_preserve_history ON moderation_appeals"
|
||||
execute "DROP TRIGGER IF EXISTS moderation_cases_preserve_history ON moderation_cases"
|
||||
execute "DROP FUNCTION IF EXISTS tarakan_prevent_moderation_history_deletion()"
|
||||
execute "DROP TRIGGER IF EXISTS moderation_actions_append_only ON moderation_actions"
|
||||
execute "DROP FUNCTION IF EXISTS tarakan_prevent_moderation_action_mutation()"
|
||||
|
||||
execute "UPDATE moderation_cases SET status = 'resolved' WHERE status = 'overturned'"
|
||||
|
||||
execute """
|
||||
DELETE FROM moderation_actions
|
||||
WHERE action IN ('appeal_upheld', 'appeal_denied')
|
||||
"""
|
||||
|
||||
execute """
|
||||
ALTER TABLE moderation_cases
|
||||
DROP CONSTRAINT moderation_cases_repository_id_fkey
|
||||
"""
|
||||
|
||||
execute """
|
||||
ALTER TABLE moderation_cases
|
||||
ADD CONSTRAINT moderation_cases_repository_id_fkey
|
||||
FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE
|
||||
"""
|
||||
|
||||
drop constraint(
|
||||
:moderation_appeals,
|
||||
:moderation_appeals_decision_state_must_be_consistent
|
||||
)
|
||||
|
||||
drop constraint(:moderation_actions, :moderation_actions_action_must_be_valid)
|
||||
|
||||
create constraint(:moderation_actions, :moderation_actions_action_must_be_valid,
|
||||
check: """
|
||||
action IN (
|
||||
'assign', 'quarantine', 'redact', 'restore', 'resolve', 'dismiss',
|
||||
'suspend_account', 'restore_account'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
drop constraint(:moderation_cases, :moderation_cases_assignment_state_must_be_consistent)
|
||||
drop constraint(:moderation_cases, :moderation_cases_resolution_state_must_be_consistent)
|
||||
drop constraint(:moderation_cases, :moderation_cases_status_must_be_valid)
|
||||
|
||||
create constraint(:moderation_cases, :moderation_cases_status_must_be_valid,
|
||||
check: "status IN ('open', 'in_review', 'resolved', 'dismissed')"
|
||||
)
|
||||
|
||||
drop_if_exists index(:moderation_cases, [], name: :moderation_cases_one_open_report_index)
|
||||
|
||||
create unique_index(
|
||||
:moderation_cases,
|
||||
[:reporter_id, :subject_type, :subject_id, :reason],
|
||||
where: "status IN ('open', 'in_review')",
|
||||
name: :moderation_cases_one_open_report_index
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddRepositoryListingStatus do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:repositories) do
|
||||
add :listing_status, :string, null: false, default: "pending"
|
||||
end
|
||||
|
||||
execute "UPDATE repositories SET listing_status = 'listed'"
|
||||
|
||||
create constraint(:repositories, :repositories_listing_status_must_be_valid,
|
||||
check: "listing_status IN ('pending', 'listed', 'quarantined')"
|
||||
)
|
||||
|
||||
create index(:repositories, [:listing_status, :inserted_at])
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:repositories) do
|
||||
remove :listing_status
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddReviewTaskDisclosureControls do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:review_tasks) do
|
||||
add :visibility, :string, null: false, default: "restricted"
|
||||
add :disclosed_at, :utc_datetime_usec
|
||||
add :disclosed_by_id, references(:accounts, on_delete: :restrict)
|
||||
add :sensitive_data_reviewed_at, :utc_datetime_usec
|
||||
add :sensitive_data_reviewed_by_id, references(:accounts, on_delete: :restrict)
|
||||
end
|
||||
|
||||
# Keep existing queue entries discoverable, but quarantine every legacy
|
||||
# accepted result until a new, attributable disclosure decision is made.
|
||||
execute("""
|
||||
UPDATE review_tasks
|
||||
SET visibility = CASE
|
||||
WHEN status IN ('open', 'claimed') AND EXISTS (
|
||||
SELECT 1 FROM review_task_decisions
|
||||
WHERE review_task_id = review_tasks.id AND action = 'publish'
|
||||
) THEN 'public_summary'
|
||||
ELSE 'restricted'
|
||||
END,
|
||||
disclosed_at = CASE
|
||||
WHEN status IN ('open', 'claimed') AND EXISTS (
|
||||
SELECT 1 FROM review_task_decisions
|
||||
WHERE review_task_id = review_tasks.id AND action = 'publish'
|
||||
) THEN published_at
|
||||
ELSE NULL
|
||||
END,
|
||||
disclosed_by_id = CASE
|
||||
WHEN status IN ('open', 'claimed') AND EXISTS (
|
||||
SELECT 1 FROM review_task_decisions
|
||||
WHERE review_task_id = review_tasks.id AND action = 'publish'
|
||||
) THEN (
|
||||
SELECT account_id
|
||||
FROM review_task_decisions
|
||||
WHERE review_task_id = review_tasks.id AND action = 'publish'
|
||||
ORDER BY inserted_at DESC, id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
ELSE NULL
|
||||
END
|
||||
""")
|
||||
|
||||
create index(:review_tasks, [:repository_id, :status, :visibility])
|
||||
create index(:review_tasks, [:disclosed_by_id])
|
||||
create index(:review_tasks, [:sensitive_data_reviewed_by_id])
|
||||
|
||||
create constraint(:review_tasks, :review_tasks_visibility_must_be_valid,
|
||||
check: "visibility IN ('restricted', 'public_summary', 'public')"
|
||||
)
|
||||
|
||||
create constraint(:review_tasks, :review_tasks_visibility_must_match_state,
|
||||
check:
|
||||
"visibility = 'restricted' OR " <>
|
||||
"(visibility = 'public_summary' AND status IN ('open', 'claimed', 'accepted')) OR " <>
|
||||
"(visibility = 'public' AND status = 'accepted')"
|
||||
)
|
||||
|
||||
create constraint(:review_tasks, :review_tasks_disclosure_must_be_attributed,
|
||||
check:
|
||||
"visibility = 'restricted' OR (disclosed_at IS NOT NULL AND disclosed_by_id IS NOT NULL)"
|
||||
)
|
||||
|
||||
create constraint(:review_tasks, :review_tasks_full_disclosure_requires_sensitive_review,
|
||||
check:
|
||||
"visibility != 'public' OR " <>
|
||||
"(sensitive_data_reviewed_at IS NOT NULL AND sensitive_data_reviewed_by_id IS NOT NULL)"
|
||||
)
|
||||
|
||||
drop constraint(:review_task_decisions, :review_task_decisions_action_must_be_valid)
|
||||
|
||||
create constraint(:review_task_decisions, :review_task_decisions_action_must_be_valid,
|
||||
check:
|
||||
"action IN ('publish', 'accept', 'request_changes', 'reject', 'cancel', 'disclose')"
|
||||
)
|
||||
end
|
||||
|
||||
def down do
|
||||
drop constraint(:review_task_decisions, :review_task_decisions_action_must_be_valid)
|
||||
|
||||
create constraint(:review_task_decisions, :review_task_decisions_action_must_be_valid,
|
||||
check: "action IN ('publish', 'accept', 'request_changes', 'reject', 'cancel')"
|
||||
)
|
||||
|
||||
drop constraint(
|
||||
:review_tasks,
|
||||
:review_tasks_full_disclosure_requires_sensitive_review
|
||||
)
|
||||
|
||||
drop constraint(:review_tasks, :review_tasks_disclosure_must_be_attributed)
|
||||
drop constraint(:review_tasks, :review_tasks_visibility_must_match_state)
|
||||
drop constraint(:review_tasks, :review_tasks_visibility_must_be_valid)
|
||||
|
||||
alter table(:review_tasks) do
|
||||
remove :sensitive_data_reviewed_by_id
|
||||
remove :sensitive_data_reviewed_at
|
||||
remove :disclosed_by_id
|
||||
remove :disclosed_at
|
||||
remove :visibility
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
defmodule Tarakan.Repo.Migrations.AllowReportApiCredentials do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
drop constraint(:api_credentials, :api_credentials_scopes_must_be_valid)
|
||||
|
||||
create constraint(:api_credentials, :api_credentials_scopes_must_be_valid,
|
||||
check:
|
||||
"scopes <@ ARRAY['tasks:read', 'tasks:claim', 'contributions:write', 'findings:submit', 'reports:write']::varchar[]"
|
||||
)
|
||||
end
|
||||
|
||||
def down do
|
||||
drop constraint(:api_credentials, :api_credentials_scopes_must_be_valid)
|
||||
|
||||
create constraint(:api_credentials, :api_credentials_scopes_must_be_valid,
|
||||
check:
|
||||
"scopes <@ ARRAY['tasks:read', 'tasks:claim', 'contributions:write', 'findings:submit']::varchar[]"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
defmodule Tarakan.Repo.Migrations.BoundScanFindingLines do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
drop constraint(:scan_findings, :scan_findings_lines_must_be_ordered)
|
||||
|
||||
create constraint(:scan_findings, :scan_findings_lines_must_be_ordered,
|
||||
check:
|
||||
"(line_start IS NULL AND line_end IS NULL) OR " <>
|
||||
"(line_start BETWEEN 1 AND 1000000 AND " <>
|
||||
"line_end BETWEEN line_start AND 1000000)"
|
||||
)
|
||||
end
|
||||
|
||||
def down do
|
||||
drop constraint(:scan_findings, :scan_findings_lines_must_be_ordered)
|
||||
|
||||
create constraint(:scan_findings, :scan_findings_lines_must_be_ordered,
|
||||
check:
|
||||
"(line_start IS NULL) OR " <>
|
||||
"(line_start >= 1 AND (line_end IS NULL OR line_end >= line_start))"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddPublicIdsToScanFindings do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:scan_findings) do
|
||||
add :public_id, :uuid, null: false, default: fragment("gen_random_uuid()")
|
||||
end
|
||||
|
||||
create unique_index(:scan_findings, [:public_id])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddNodeIdToRepositories do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:repositories) do
|
||||
add :node_id, :string
|
||||
end
|
||||
|
||||
create unique_index(:repositories, [:node_id], where: "node_id IS NOT NULL")
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddObanJobsTable do
|
||||
use Ecto.Migration
|
||||
|
||||
def up, do: Oban.Migration.up()
|
||||
|
||||
def down, do: Oban.Migration.down(version: 1)
|
||||
end
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddEvidenceToScanConfirmations do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:scan_confirmations) do
|
||||
add :evidence, :text
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
defmodule Tarakan.Repo.Migrations.AllowReviewVerifierCredentials do
|
||||
use Ecto.Migration
|
||||
|
||||
@with_reviews "scopes <@ ARRAY['tasks:read', 'tasks:claim', 'contributions:write', 'findings:submit', 'reports:write', 'reviews:read', 'reviews:verify']::varchar[]"
|
||||
@without_reviews "scopes <@ ARRAY['tasks:read', 'tasks:claim', 'contributions:write', 'findings:submit', 'reports:write']::varchar[]"
|
||||
|
||||
def up do
|
||||
drop constraint(:api_credentials, :api_credentials_scopes_must_be_valid)
|
||||
|
||||
create constraint(:api_credentials, :api_credentials_scopes_must_be_valid,
|
||||
check: @with_reviews
|
||||
)
|
||||
end
|
||||
|
||||
def down do
|
||||
drop constraint(:api_credentials, :api_credentials_scopes_must_be_valid)
|
||||
|
||||
create constraint(:api_credentials, :api_credentials_scopes_must_be_valid,
|
||||
check: @without_reviews
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddHostedRepositoryFields do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:repositories) do
|
||||
add :pushed_at, :utc_datetime_usec
|
||||
add :disk_size_bytes, :bigint, null: false, default: 0
|
||||
end
|
||||
|
||||
# Audit history is append-only and must survive a hosted repository's
|
||||
# deletion; the event keeps subject_type/subject_id and its metadata.
|
||||
alter table(:audit_events) do
|
||||
modify :repository_id, references(:repositories, on_delete: :nilify_all),
|
||||
from: references(:repositories)
|
||||
end
|
||||
|
||||
# The append-only trigger must allow exactly one mutation: the FK above
|
||||
# detaching repository_id when the referenced repository is deleted.
|
||||
# Every other column must be untouched or the write is still rejected.
|
||||
execute """
|
||||
CREATE OR REPLACE FUNCTION tarakan_prevent_audit_event_mutation()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'UPDATE'
|
||||
AND OLD.repository_id IS NOT NULL
|
||||
AND NEW.repository_id IS NULL
|
||||
AND ROW(NEW.id, NEW.actor_id, NEW.token_id, NEW.action, NEW.subject_type,
|
||||
NEW.subject_id, NEW.from_state, NEW.to_state, NEW.reason_code,
|
||||
NEW.request_id, NEW.client_version, NEW.metadata, NEW.inserted_at)
|
||||
IS NOT DISTINCT FROM
|
||||
ROW(OLD.id, OLD.actor_id, OLD.token_id, OLD.action, OLD.subject_type,
|
||||
OLD.subject_id, OLD.from_state, OLD.to_state, OLD.reason_code,
|
||||
OLD.request_id, OLD.client_version, OLD.metadata, OLD.inserted_at) THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION 'audit_events are append-only';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
""",
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION tarakan_prevent_audit_event_mutation()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'audit_events are append-only';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
defmodule Tarakan.Repo.Migrations.AllowRepoCredentialScopes do
|
||||
use Ecto.Migration
|
||||
|
||||
@with_repo "scopes <@ ARRAY['tasks:read', 'tasks:claim', 'contributions:write', 'findings:submit', 'reports:write', 'reviews:read', 'reviews:verify', 'repo:read', 'repo:write']::varchar[]"
|
||||
@without_repo "scopes <@ ARRAY['tasks:read', 'tasks:claim', 'contributions:write', 'findings:submit', 'reports:write', 'reviews:read', 'reviews:verify']::varchar[]"
|
||||
|
||||
def up do
|
||||
drop constraint(:api_credentials, :api_credentials_scopes_must_be_valid)
|
||||
|
||||
create constraint(:api_credentials, :api_credentials_scopes_must_be_valid, check: @with_repo)
|
||||
end
|
||||
|
||||
def down do
|
||||
drop constraint(:api_credentials, :api_credentials_scopes_must_be_valid)
|
||||
|
||||
create constraint(:api_credentials, :api_credentials_scopes_must_be_valid,
|
||||
check: @without_repo
|
||||
)
|
||||
end
|
||||
end
|
||||
21
priv/repo/migrations/20260711192216_create_ssh_keys.exs
Normal file
21
priv/repo/migrations/20260711192216_create_ssh_keys.exs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
defmodule Tarakan.Repo.Migrations.CreateSshKeys do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:ssh_keys) do
|
||||
add :account_id, references(:accounts, on_delete: :delete_all), null: false
|
||||
add :name, :string, null: false
|
||||
add :key_type, :string, null: false
|
||||
add :public_key, :text, null: false
|
||||
add :fingerprint_sha256, :string, null: false
|
||||
add :last_used_at, :utc_datetime_usec
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
# Global uniqueness is load-bearing: SSH authentication resolves a
|
||||
# presented key to exactly one account by fingerprint.
|
||||
create unique_index(:ssh_keys, [:fingerprint_sha256])
|
||||
create index(:ssh_keys, [:account_id])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
defmodule Tarakan.Repo.Migrations.AutoPublicDisclosure do
|
||||
use Ecto.Migration
|
||||
|
||||
@moduledoc """
|
||||
Disclosure is automatic: reviews, findings, tasks, and repository records
|
||||
are public the moment they exist. Verification and moderation still run,
|
||||
but they only change status labels. `restricted` visibility and repository
|
||||
quarantine remain as explicit moderation takedowns.
|
||||
"""
|
||||
|
||||
def up do
|
||||
drop constraint(:scans, :scans_publication_requires_acceptance)
|
||||
|
||||
alter table(:scans) do
|
||||
modify :visibility, :string, null: false, default: "public"
|
||||
end
|
||||
|
||||
execute "UPDATE scans SET visibility = 'public'"
|
||||
|
||||
alter table(:repositories) do
|
||||
modify :listing_status, :string, null: false, default: "listed"
|
||||
end
|
||||
|
||||
execute "UPDATE repositories SET listing_status = 'listed' WHERE listing_status = 'pending'"
|
||||
|
||||
drop constraint(:review_tasks, :review_tasks_visibility_must_match_state)
|
||||
drop constraint(:review_tasks, :review_tasks_disclosure_must_be_attributed)
|
||||
drop constraint(:review_tasks, :review_tasks_full_disclosure_requires_sensitive_review)
|
||||
|
||||
alter table(:review_tasks) do
|
||||
modify :visibility, :string, null: false, default: "public"
|
||||
end
|
||||
|
||||
# Cancelled tasks stay restricted: moderation quarantine lands in that
|
||||
# state and cannot be told apart from an ordinary cancellation here.
|
||||
execute "UPDATE review_tasks SET visibility = 'public' WHERE status != 'cancelled'"
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:review_tasks) do
|
||||
modify :visibility, :string, null: false, default: "restricted"
|
||||
end
|
||||
|
||||
# The state-coupled disclosure constraints cannot be recreated without
|
||||
# re-restricting already-public rows; reset visibility first.
|
||||
execute "UPDATE review_tasks SET visibility = 'restricted'"
|
||||
|
||||
create constraint(:review_tasks, :review_tasks_visibility_must_match_state,
|
||||
check:
|
||||
"visibility = 'restricted' OR " <>
|
||||
"(visibility = 'public_summary' AND status IN ('open', 'claimed', 'accepted')) OR " <>
|
||||
"(visibility = 'public' AND status = 'accepted')"
|
||||
)
|
||||
|
||||
create constraint(:review_tasks, :review_tasks_disclosure_must_be_attributed,
|
||||
check:
|
||||
"visibility = 'restricted' OR (disclosed_at IS NOT NULL AND disclosed_by_id IS NOT NULL)"
|
||||
)
|
||||
|
||||
create constraint(:review_tasks, :review_tasks_full_disclosure_requires_sensitive_review,
|
||||
check:
|
||||
"visibility != 'public' OR " <>
|
||||
"(sensitive_data_reviewed_at IS NOT NULL AND sensitive_data_reviewed_by_id IS NOT NULL)"
|
||||
)
|
||||
|
||||
alter table(:repositories) do
|
||||
modify :listing_status, :string, null: false, default: "pending"
|
||||
end
|
||||
|
||||
alter table(:scans) do
|
||||
modify :visibility, :string, null: false, default: "restricted"
|
||||
end
|
||||
|
||||
# Already-published rows stay public; re-restricting them here would
|
||||
# claim a disclosure decision that never happened.
|
||||
execute """
|
||||
UPDATE scans SET visibility = 'restricted'
|
||||
WHERE review_status != 'accepted'
|
||||
"""
|
||||
|
||||
create constraint(:scans, :scans_publication_requires_acceptance,
|
||||
check: "visibility = 'restricted' OR review_status = 'accepted'"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddRawDocumentToScans do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:scans) do
|
||||
# The Tarakan Scan Format document exactly as the harness submitted it.
|
||||
# Findings are parsed into scan_findings rows for querying; this keeps
|
||||
# the model's raw report on the record for human review.
|
||||
add :raw_document, :text
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
defmodule Tarakan.Repo.Migrations.CreateFindingComments do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:finding_comments) do
|
||||
add :finding_id, references(:scan_findings, on_delete: :delete_all), null: false
|
||||
# Denormalized so authorization, moderation, and PubSub scoping never
|
||||
# need to walk finding -> scan -> repository.
|
||||
add :repository_id, references(:repositories, on_delete: :delete_all), null: false
|
||||
add :account_id, references(:accounts, on_delete: :restrict), null: false
|
||||
add :parent_id, references(:finding_comments, on_delete: :delete_all)
|
||||
add :body, :text, null: false
|
||||
|
||||
# Moderation is takedown, not a visibility gate: a removed comment keeps
|
||||
# its place in the thread and renders as a placeholder.
|
||||
add :removed_at, :utc_datetime_usec
|
||||
add :removed_by_id, references(:accounts, on_delete: :nilify_all)
|
||||
add :removed_reason, :string
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create index(:finding_comments, [:finding_id, :inserted_at])
|
||||
create index(:finding_comments, [:parent_id])
|
||||
create index(:finding_comments, [:account_id])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
defmodule Tarakan.Repo.Migrations.LinkReviewTasksAndScans do
|
||||
@moduledoc """
|
||||
Additive FKs for the Review/Request domain collapse (PR 1a).
|
||||
|
||||
- `review_tasks.linked_review_id` → the latest Review created when completing a Request
|
||||
- `scans.source_request_id` → the Request that produced this Review (history of attempts)
|
||||
|
||||
No application behavior change in this migration. Both columns are nullable so
|
||||
existing rows and ad-hoc Reviews remain valid.
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:scans) do
|
||||
add :source_request_id, references(:review_tasks, on_delete: :nilify_all)
|
||||
end
|
||||
|
||||
alter table(:review_tasks) do
|
||||
add :linked_review_id, references(:scans, on_delete: :nilify_all)
|
||||
end
|
||||
|
||||
create index(:scans, [:source_request_id])
|
||||
create index(:review_tasks, [:linked_review_id])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddTargetReviewIdToReviewTasks do
|
||||
@moduledoc """
|
||||
PR5.1: verify_findings Requests point at the Review they are verifying.
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:review_tasks) do
|
||||
add :target_review_id, references(:scans, on_delete: :nilify_all)
|
||||
end
|
||||
|
||||
create index(:review_tasks, [:target_review_id])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
defmodule Tarakan.Repo.Migrations.ExpandApiCredentialScopesForReviews do
|
||||
use Ecto.Migration
|
||||
|
||||
@with_all """
|
||||
scopes <@ ARRAY[
|
||||
'tasks:read', 'tasks:claim', 'contributions:write',
|
||||
'findings:submit', 'findings:verify', 'findings:read',
|
||||
'reviews:submit', 'reviews:read', 'reviews:verify',
|
||||
'reports:write', 'repo:read', 'repo:write', 'discussion:write',
|
||||
'requests:read', 'requests:claim'
|
||||
]::varchar[]
|
||||
"""
|
||||
|
||||
@previous """
|
||||
scopes <@ ARRAY[
|
||||
'tasks:read', 'tasks:claim', 'contributions:write', 'findings:submit',
|
||||
'reports:write', 'reviews:read', 'reviews:verify', 'repo:read', 'repo:write'
|
||||
]::varchar[]
|
||||
"""
|
||||
|
||||
def up do
|
||||
drop constraint(:api_credentials, :api_credentials_scopes_must_be_valid)
|
||||
|
||||
create constraint(:api_credentials, :api_credentials_scopes_must_be_valid, check: @with_all)
|
||||
end
|
||||
|
||||
def down do
|
||||
drop constraint(:api_credentials, :api_credentials_scopes_must_be_valid)
|
||||
|
||||
create constraint(:api_credentials, :api_credentials_scopes_must_be_valid, check: @previous)
|
||||
end
|
||||
end
|
||||
22
priv/repo/migrations/20260712040215_create_votes.exs
Normal file
22
priv/repo/migrations/20260712040215_create_votes.exs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
defmodule Tarakan.Repo.Migrations.CreateVotes do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:votes) do
|
||||
add :account_id, references(:accounts, on_delete: :delete_all), null: false
|
||||
# Polymorphic by (subject_type, subject_id): "finding" -> scan_findings,
|
||||
# "comment" -> finding_comments. Kept generic so new votable kinds don't
|
||||
# need a new table.
|
||||
add :subject_type, :string, null: false
|
||||
add :subject_id, :bigint, null: false
|
||||
add :value, :integer, null: false
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
# One standing vote per account per item; recasting updates value.
|
||||
create unique_index(:votes, [:account_id, :subject_type, :subject_id])
|
||||
create index(:votes, [:subject_type, :subject_id])
|
||||
create constraint(:votes, :votes_value_must_be_unit, check: "value IN (-1, 1)")
|
||||
end
|
||||
end
|
||||
19
priv/repo/migrations/20260712043533_create_review_stakes.exs
Normal file
19
priv/repo/migrations/20260712043533_create_review_stakes.exs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
defmodule Tarakan.Repo.Migrations.CreateReviewStakes do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:review_stakes) do
|
||||
add :scan_id, references(:scans, on_delete: :delete_all), null: false
|
||||
# Denormalized submitter so reputation queries don't join back to scans.
|
||||
add :account_id, references(:accounts, on_delete: :delete_all), null: false
|
||||
add :amount, :integer, null: false
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
# One standing stake per review.
|
||||
create unique_index(:review_stakes, [:scan_id])
|
||||
create index(:review_stakes, [:account_id])
|
||||
create constraint(:review_stakes, :review_stakes_amount_positive, check: "amount >= 0")
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddCanonicalFindingMemory do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
execute "CREATE EXTENSION IF NOT EXISTS pgcrypto"
|
||||
|
||||
alter table(:scans) do
|
||||
add :run_id, :string
|
||||
end
|
||||
|
||||
drop_if_exists index(
|
||||
:scans,
|
||||
[
|
||||
:repository_id,
|
||||
:submitted_by_id,
|
||||
:commit_sha,
|
||||
:provenance,
|
||||
:review_kind,
|
||||
"COALESCE(model, '')",
|
||||
"COALESCE(prompt_version, '')"
|
||||
],
|
||||
name: :scans_unique_submission_index
|
||||
)
|
||||
|
||||
create unique_index(:scans, [:submitted_by_id, :run_id],
|
||||
where: "run_id IS NOT NULL",
|
||||
name: :scans_unique_run_index
|
||||
)
|
||||
|
||||
create table(:canonical_findings) do
|
||||
add :public_id, :uuid, null: false, default: fragment("gen_random_uuid()")
|
||||
add :repository_id, references(:repositories, on_delete: :delete_all), null: false
|
||||
add :fingerprint, :string, null: false, size: 64
|
||||
add :file_path, :string, null: false, size: 500
|
||||
add :line_start, :integer
|
||||
add :line_end, :integer
|
||||
add :severity, :string, null: false
|
||||
add :title, :string, null: false
|
||||
add :description, :text, null: false
|
||||
add :status, :string, null: false, default: "open"
|
||||
add :first_seen_commit_sha, :string, null: false
|
||||
add :last_seen_commit_sha, :string, null: false
|
||||
add :detections_count, :integer, null: false, default: 0
|
||||
add :distinct_submitters_count, :integer, null: false, default: 0
|
||||
add :distinct_models_count, :integer, null: false, default: 0
|
||||
add :confirmations_count, :integer, null: false, default: 0
|
||||
add :disputes_count, :integer, null: false, default: 0
|
||||
add :verified_at, :utc_datetime_usec
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create unique_index(:canonical_findings, [:public_id])
|
||||
create unique_index(:canonical_findings, [:repository_id, :fingerprint])
|
||||
create index(:canonical_findings, [:repository_id, :status])
|
||||
|
||||
create constraint(:canonical_findings, :canonical_findings_status_must_be_valid,
|
||||
check: "status IN ('open', 'verified', 'disputed', 'fixed')"
|
||||
)
|
||||
|
||||
create constraint(:canonical_findings, :canonical_findings_counts_must_be_nonnegative,
|
||||
check:
|
||||
"detections_count >= 0 AND distinct_submitters_count >= 0 AND distinct_models_count >= 0 AND confirmations_count >= 0 AND disputes_count >= 0"
|
||||
)
|
||||
|
||||
alter table(:scan_findings) do
|
||||
add :canonical_finding_id, references(:canonical_findings, on_delete: :restrict)
|
||||
add :fingerprint, :string, size: 64
|
||||
add :disposition, :string, null: false, default: "new"
|
||||
add :claimed_canonical_public_id, :uuid
|
||||
end
|
||||
|
||||
create index(:scan_findings, [:canonical_finding_id])
|
||||
create index(:scan_findings, [:fingerprint])
|
||||
|
||||
create constraint(:scan_findings, :scan_findings_disposition_must_be_valid,
|
||||
check: "disposition IN ('new', 'matches_existing', 'regression', 'not_reproduced')"
|
||||
)
|
||||
|
||||
create table(:canonical_finding_checks) do
|
||||
add :canonical_finding_id, references(:canonical_findings, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :scan_finding_id, references(:scan_findings, on_delete: :nilify_all)
|
||||
add :account_id, references(:accounts, on_delete: :restrict), null: false
|
||||
add :commit_sha, :string, null: false
|
||||
add :verdict, :string, null: false
|
||||
add :provenance, :string, null: false, default: "human"
|
||||
add :notes, :text, null: false
|
||||
add :evidence, :text
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create unique_index(
|
||||
:canonical_finding_checks,
|
||||
[:canonical_finding_id, :account_id, :commit_sha],
|
||||
name: :canonical_finding_checks_unique_actor_commit_index
|
||||
)
|
||||
|
||||
create index(:canonical_finding_checks, [:account_id])
|
||||
|
||||
create constraint(:canonical_finding_checks, :canonical_finding_checks_verdict_must_be_valid,
|
||||
check: "verdict IN ('confirmed', 'disputed', 'fixed')"
|
||||
)
|
||||
|
||||
create constraint(
|
||||
:canonical_finding_checks,
|
||||
:canonical_finding_checks_provenance_must_be_valid,
|
||||
check: "provenance IN ('agent', 'human', 'hybrid')"
|
||||
)
|
||||
|
||||
execute """
|
||||
WITH source AS (
|
||||
SELECT sf.*, s.repository_id, s.commit_sha, s.model, s.submitted_by_id,
|
||||
s.verified_at AS scan_verified_at,
|
||||
encode(digest(concat_ws(E'\\x1f', lower(trim(sf.file_path)),
|
||||
coalesce(sf.line_start::text, ''), coalesce(sf.line_end::text, ''),
|
||||
regexp_replace(lower(trim(sf.title)), '\\s+', ' ', 'g')), 'sha256'), 'hex') AS fp
|
||||
FROM scan_findings sf
|
||||
JOIN scans s ON s.id = sf.scan_id
|
||||
), representatives AS (
|
||||
SELECT DISTINCT ON (repository_id, fp) *
|
||||
FROM source
|
||||
ORDER BY repository_id, fp, inserted_at ASC, id ASC
|
||||
)
|
||||
INSERT INTO canonical_findings (
|
||||
public_id, repository_id, fingerprint, file_path, line_start, line_end,
|
||||
severity, title, description, status, first_seen_commit_sha,
|
||||
last_seen_commit_sha, detections_count, distinct_submitters_count,
|
||||
distinct_models_count, confirmations_count, disputes_count, verified_at,
|
||||
inserted_at, updated_at
|
||||
)
|
||||
SELECT gen_random_uuid(), representative.repository_id, representative.fp,
|
||||
representative.file_path, representative.line_start, representative.line_end,
|
||||
representative.severity, representative.title, representative.description,
|
||||
CASE WHEN bool_or(source.scan_verified_at IS NOT NULL) THEN 'verified' ELSE 'open' END,
|
||||
(array_agg(source.commit_sha ORDER BY source.inserted_at ASC, source.id ASC))[1],
|
||||
(array_agg(source.commit_sha ORDER BY source.inserted_at DESC, source.id DESC))[1],
|
||||
count(source.id), count(DISTINCT source.submitted_by_id),
|
||||
count(DISTINCT source.model) FILTER (WHERE source.model IS NOT NULL),
|
||||
0, 0, min(source.scan_verified_at), now(), now()
|
||||
FROM representatives representative
|
||||
JOIN source ON source.repository_id = representative.repository_id AND source.fp = representative.fp
|
||||
GROUP BY representative.repository_id, representative.fp, representative.file_path,
|
||||
representative.line_start, representative.line_end, representative.severity,
|
||||
representative.title, representative.description
|
||||
"""
|
||||
|
||||
execute """
|
||||
UPDATE scan_findings sf
|
||||
SET fingerprint = source.fp,
|
||||
canonical_finding_id = canonical.id,
|
||||
disposition = CASE WHEN canonical.detections_count > 1 THEN 'matches_existing' ELSE 'new' END
|
||||
FROM (
|
||||
SELECT sf2.id, s.repository_id,
|
||||
encode(digest(concat_ws(E'\\x1f', lower(trim(sf2.file_path)),
|
||||
coalesce(sf2.line_start::text, ''), coalesce(sf2.line_end::text, ''),
|
||||
regexp_replace(lower(trim(sf2.title)), '\\s+', ' ', 'g')), 'sha256'), 'hex') AS fp
|
||||
FROM scan_findings sf2
|
||||
JOIN scans s ON s.id = sf2.scan_id
|
||||
) source
|
||||
JOIN canonical_findings canonical
|
||||
ON canonical.repository_id = source.repository_id AND canonical.fingerprint = source.fp
|
||||
WHERE sf.id = source.id
|
||||
"""
|
||||
|
||||
execute """
|
||||
INSERT INTO canonical_finding_checks (
|
||||
canonical_finding_id, scan_finding_id, account_id, commit_sha,
|
||||
verdict, provenance, notes, evidence, inserted_at, updated_at
|
||||
)
|
||||
SELECT DISTINCT ON (sf.canonical_finding_id, confirmation.account_id, scan.commit_sha)
|
||||
sf.canonical_finding_id, sf.id, confirmation.account_id, scan.commit_sha,
|
||||
confirmation.verdict, confirmation.provenance, confirmation.notes,
|
||||
confirmation.evidence, confirmation.inserted_at, confirmation.inserted_at
|
||||
FROM scan_confirmations confirmation
|
||||
JOIN scans scan ON scan.id = confirmation.scan_id
|
||||
JOIN scan_findings sf ON sf.scan_id = scan.id
|
||||
WHERE sf.canonical_finding_id IS NOT NULL
|
||||
ORDER BY sf.canonical_finding_id, confirmation.account_id, scan.commit_sha,
|
||||
confirmation.inserted_at ASC, confirmation.id ASC
|
||||
ON CONFLICT (canonical_finding_id, account_id, commit_sha) DO NOTHING
|
||||
"""
|
||||
end
|
||||
|
||||
def down do
|
||||
drop table(:canonical_finding_checks)
|
||||
|
||||
alter table(:scan_findings) do
|
||||
remove :claimed_canonical_public_id
|
||||
remove :disposition
|
||||
remove :fingerprint
|
||||
remove :canonical_finding_id
|
||||
end
|
||||
|
||||
drop table(:canonical_findings)
|
||||
drop index(:scans, [:submitted_by_id, :run_id], name: :scans_unique_run_index)
|
||||
|
||||
alter table(:scans) do
|
||||
remove :run_id
|
||||
end
|
||||
|
||||
create unique_index(
|
||||
:scans,
|
||||
[
|
||||
:repository_id,
|
||||
:submitted_by_id,
|
||||
:commit_sha,
|
||||
:provenance,
|
||||
:review_kind,
|
||||
"COALESCE(model, '')",
|
||||
"COALESCE(prompt_version, '')"
|
||||
],
|
||||
name: :scans_unique_submission_index
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
defmodule Tarakan.Repo.Migrations.ConsolidateCanonicalFindingVotes do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
execute """
|
||||
INSERT INTO votes (
|
||||
account_id, subject_type, subject_id, value, inserted_at, updated_at
|
||||
)
|
||||
SELECT DISTINCT ON (vote.account_id, finding.canonical_finding_id)
|
||||
vote.account_id, 'canonical_finding', finding.canonical_finding_id,
|
||||
vote.value, vote.inserted_at, vote.updated_at
|
||||
FROM votes vote
|
||||
JOIN scan_findings finding ON finding.id = vote.subject_id
|
||||
WHERE vote.subject_type = 'finding' AND finding.canonical_finding_id IS NOT NULL
|
||||
ORDER BY vote.account_id, finding.canonical_finding_id,
|
||||
vote.updated_at DESC, vote.id DESC
|
||||
ON CONFLICT (account_id, subject_type, subject_id)
|
||||
DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at
|
||||
"""
|
||||
|
||||
execute "DELETE FROM votes WHERE subject_type = 'finding'"
|
||||
end
|
||||
|
||||
def down do
|
||||
execute """
|
||||
INSERT INTO votes (
|
||||
account_id, subject_type, subject_id, value, inserted_at, updated_at
|
||||
)
|
||||
SELECT vote.account_id, 'finding', occurrence.id, vote.value,
|
||||
vote.inserted_at, vote.updated_at
|
||||
FROM votes vote
|
||||
JOIN LATERAL (
|
||||
SELECT finding.id
|
||||
FROM scan_findings finding
|
||||
JOIN scans scan ON scan.id = finding.scan_id
|
||||
WHERE finding.canonical_finding_id = vote.subject_id
|
||||
ORDER BY scan.inserted_at ASC, finding.id ASC
|
||||
LIMIT 1
|
||||
) occurrence ON true
|
||||
WHERE vote.subject_type = 'canonical_finding'
|
||||
ON CONFLICT (account_id, subject_type, subject_id)
|
||||
DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at
|
||||
"""
|
||||
|
||||
execute "DELETE FROM votes WHERE subject_type = 'canonical_finding'"
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
defmodule Tarakan.Repo.Migrations.CreateRegistryShouts do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:registry_shouts) do
|
||||
add :body, :string, null: false, size: 280
|
||||
add :account_id, references(:accounts, on_delete: :restrict), null: false
|
||||
add :removed_at, :utc_datetime_usec
|
||||
add :removed_reason, :string, size: 100
|
||||
add :removed_by_id, references(:accounts, on_delete: :nilify_all)
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create index(:registry_shouts, [:inserted_at, :id])
|
||||
create index(:registry_shouts, [:account_id])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
defmodule Tarakan.Repo.Migrations.RetallyPlatformReviewerChecks do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
execute """
|
||||
WITH tallies AS (
|
||||
SELECT canonical.id,
|
||||
count(check_record.id) FILTER (
|
||||
WHERE check_record.verdict = 'confirmed'
|
||||
AND check_record.provenance IN ('human', 'hybrid')
|
||||
AND account.state IN ('probation', 'active')
|
||||
AND (
|
||||
account.trust_tier = 'reviewer'
|
||||
OR account.platform_role IN ('moderator', 'admin')
|
||||
OR membership.id IS NOT NULL
|
||||
)
|
||||
) AS confirmed,
|
||||
count(check_record.id) FILTER (
|
||||
WHERE check_record.verdict = 'disputed'
|
||||
AND check_record.provenance IN ('human', 'hybrid')
|
||||
AND account.state IN ('probation', 'active')
|
||||
AND (
|
||||
account.trust_tier = 'reviewer'
|
||||
OR account.platform_role IN ('moderator', 'admin')
|
||||
OR membership.id IS NOT NULL
|
||||
)
|
||||
) AS disputed,
|
||||
count(check_record.id) FILTER (
|
||||
WHERE check_record.verdict = 'fixed'
|
||||
AND check_record.provenance IN ('human', 'hybrid')
|
||||
AND account.state IN ('probation', 'active')
|
||||
AND (
|
||||
account.trust_tier = 'reviewer'
|
||||
OR account.platform_role IN ('moderator', 'admin')
|
||||
OR membership.id IS NOT NULL
|
||||
)
|
||||
) AS fixed
|
||||
FROM canonical_findings canonical
|
||||
LEFT JOIN canonical_finding_checks check_record
|
||||
ON check_record.canonical_finding_id = canonical.id
|
||||
AND check_record.commit_sha = canonical.last_seen_commit_sha
|
||||
LEFT JOIN accounts account ON account.id = check_record.account_id
|
||||
LEFT JOIN repository_memberships membership
|
||||
ON membership.repository_id = canonical.repository_id
|
||||
AND membership.account_id = account.id
|
||||
AND membership.status = 'verified'
|
||||
AND membership.role IN ('reviewer', 'steward')
|
||||
GROUP BY canonical.id
|
||||
), resolved AS (
|
||||
SELECT tallies.*,
|
||||
CASE
|
||||
WHEN fixed - disputed >= 2 THEN 'fixed'
|
||||
WHEN confirmed - disputed >= 2 THEN 'verified'
|
||||
WHEN disputed > confirmed THEN 'disputed'
|
||||
ELSE 'open'
|
||||
END AS status
|
||||
FROM tallies
|
||||
)
|
||||
UPDATE canonical_findings canonical
|
||||
SET confirmations_count = resolved.confirmed,
|
||||
disputes_count = resolved.disputed,
|
||||
status = resolved.status,
|
||||
verified_at = CASE
|
||||
WHEN resolved.status IN ('verified', 'fixed')
|
||||
THEN coalesce(canonical.verified_at, now())
|
||||
ELSE NULL
|
||||
END,
|
||||
updated_at = now()
|
||||
FROM resolved
|
||||
WHERE canonical.id = resolved.id
|
||||
"""
|
||||
|
||||
execute """
|
||||
WITH public_canonical AS (
|
||||
SELECT DISTINCT scan.repository_id, finding.canonical_finding_id
|
||||
FROM scan_findings finding
|
||||
JOIN scans scan ON scan.id = finding.scan_id
|
||||
WHERE scan.visibility IN ('public_summary', 'public')
|
||||
AND finding.canonical_finding_id IS NOT NULL
|
||||
), metrics AS (
|
||||
SELECT repository.id,
|
||||
count(public_canonical.canonical_finding_id) FILTER (
|
||||
WHERE canonical.status != 'fixed'
|
||||
) AS open_count,
|
||||
count(public_canonical.canonical_finding_id) FILTER (
|
||||
WHERE canonical.status = 'verified'
|
||||
) AS verified_count
|
||||
FROM repositories repository
|
||||
LEFT JOIN public_canonical ON public_canonical.repository_id = repository.id
|
||||
LEFT JOIN canonical_findings canonical
|
||||
ON canonical.id = public_canonical.canonical_finding_id
|
||||
GROUP BY repository.id
|
||||
)
|
||||
UPDATE repositories repository
|
||||
SET open_findings_count = metrics.open_count,
|
||||
verified_findings_count = metrics.verified_count,
|
||||
status = CASE
|
||||
WHEN repository.scan_count = 0 THEN 'unscanned'
|
||||
WHEN metrics.open_count > 0 THEN 'findings'
|
||||
ELSE 'reviewed'
|
||||
END,
|
||||
updated_at = now()
|
||||
FROM metrics
|
||||
WHERE repository.id = metrics.id
|
||||
"""
|
||||
end
|
||||
|
||||
def down do
|
||||
# Data was previously under-counted; restoring incorrect tallies would be destructive.
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
defmodule Tarakan.Repo.Migrations.CreateClientAuthorizations do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:client_authorizations) do
|
||||
add :device_code_hash, :binary, null: false
|
||||
add :user_code, :string, null: false
|
||||
add :client_name, :string, null: false
|
||||
add :scopes, {:array, :string}, null: false, default: []
|
||||
add :status, :string, null: false, default: "pending"
|
||||
add :expires_at, :utc_datetime_usec, null: false
|
||||
add :approved_at, :utc_datetime_usec
|
||||
add :consumed_at, :utc_datetime_usec
|
||||
|
||||
add :account_id, references(:accounts, on_delete: :nilify_all)
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create unique_index(:client_authorizations, [:device_code_hash])
|
||||
create unique_index(:client_authorizations, [:user_code])
|
||||
create index(:client_authorizations, [:expires_at])
|
||||
|
||||
create constraint(:client_authorizations, :client_authorizations_status_must_be_valid,
|
||||
check: "status IN ('pending', 'approved', 'denied', 'consumed', 'expired')"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
defmodule Tarakan.Repo.Migrations.HashExistingSessionTokens do
|
||||
use Ecto.Migration
|
||||
import Ecto.Query
|
||||
|
||||
# Session tokens moved from raw storage to SHA-256 hashes (matching how email
|
||||
# and magic-link tokens were always stored). Convert existing "session" rows
|
||||
# in place so live sessions and remember-me cookies survive the deploy instead
|
||||
# of being silently logged out - and so the old raw rows don't linger
|
||||
# undeletable (logout now deletes by hash and would never match them).
|
||||
#
|
||||
# Safe because migrations run before the new app boots: every "session" row
|
||||
# present here was written by the old code and still holds a raw token.
|
||||
def up do
|
||||
from(t in "accounts_tokens", where: t.context == "session", select: {t.id, t.token})
|
||||
|> repo().all()
|
||||
|> Enum.each(fn {id, token} when is_binary(token) ->
|
||||
hashed = :crypto.hash(:sha256, token)
|
||||
repo().update_all(from(t in "accounts_tokens", where: t.id == ^id), set: [token: hashed])
|
||||
end)
|
||||
end
|
||||
|
||||
# Hashing is one-way; raw tokens cannot be restored.
|
||||
def down, do: :ok
|
||||
end
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
defmodule Tarakan.Repo.Migrations.AbuseHardeningRateLimitsAndCheckIp do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:rate_limit_buckets, primary_key: false) do
|
||||
add :key, :text, null: false, primary_key: true
|
||||
add :bucket, :bigint, null: false, primary_key: true
|
||||
add :count, :integer, null: false, default: 0
|
||||
add :expires_at, :bigint, null: false
|
||||
end
|
||||
|
||||
create index(:rate_limit_buckets, [:expires_at])
|
||||
|
||||
alter table(:canonical_finding_checks) do
|
||||
add :client_ip_hash, :binary
|
||||
end
|
||||
|
||||
create index(:canonical_finding_checks, [:client_ip_hash],
|
||||
where: "client_ip_hash IS NOT NULL"
|
||||
)
|
||||
|
||||
alter table(:scan_confirmations) do
|
||||
add :client_ip_hash, :binary
|
||||
end
|
||||
|
||||
create index(:scan_confirmations, [:client_ip_hash], where: "client_ip_hash IS NOT NULL")
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddPatternKeyToCanonicalFindings do
|
||||
use Ecto.Migration
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
def up do
|
||||
alter table(:canonical_findings) do
|
||||
add :pattern_key, :string, size: 64
|
||||
end
|
||||
|
||||
create index(:canonical_findings, [:pattern_key])
|
||||
create index(:canonical_findings, [:fingerprint])
|
||||
|
||||
flush()
|
||||
backfill_pattern_keys()
|
||||
end
|
||||
|
||||
def down do
|
||||
drop_if_exists index(:canonical_findings, [:fingerprint])
|
||||
drop_if_exists index(:canonical_findings, [:pattern_key])
|
||||
|
||||
alter table(:canonical_findings) do
|
||||
remove :pattern_key
|
||||
end
|
||||
end
|
||||
|
||||
defp backfill_pattern_keys do
|
||||
# Keep migration self-contained (no app module dependency at migrate time).
|
||||
normalize = fn value ->
|
||||
value
|
||||
|> to_string()
|
||||
|> String.downcase()
|
||||
|> String.replace(
|
||||
~r/^(verified|hypothesis(?:\/low)?|unverified|likely|possible|confirmed)\s*:\s*/iu,
|
||||
""
|
||||
)
|
||||
|> String.replace(~r/[^\p{L}\p{N}\s\/\.\-_]/u, " ")
|
||||
|> String.replace(~r/\s+/u, " ")
|
||||
|> String.trim()
|
||||
end
|
||||
|
||||
pattern_key = fn title ->
|
||||
title
|
||||
|> normalize.()
|
||||
|> then(&:crypto.hash(:sha256, &1))
|
||||
|> Base.encode16(case: :lower)
|
||||
end
|
||||
|
||||
rows =
|
||||
repo().all(
|
||||
from(c in "canonical_findings",
|
||||
where: is_nil(c.pattern_key),
|
||||
select: {c.id, c.title}
|
||||
)
|
||||
)
|
||||
|
||||
Enum.each(rows, fn {id, title} ->
|
||||
repo().query!(
|
||||
"UPDATE canonical_findings SET pattern_key = $1 WHERE id = $2",
|
||||
[pattern_key.(title), id]
|
||||
)
|
||||
end)
|
||||
end
|
||||
end
|
||||
156
priv/repo/migrations/20260719012603_create_epidemic_rollups.exs
Normal file
156
priv/repo/migrations/20260719012603_create_epidemic_rollups.exs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
defmodule Tarakan.Repo.Migrations.CreateEpidemicRollups do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:epidemic_patterns, primary_key: false) do
|
||||
add :pattern_key, :string, size: 64, primary_key: true
|
||||
add :title, :string, null: false
|
||||
add :severity, :string
|
||||
add :sample_file_path, :string
|
||||
add :sample_occurrence_public_id, :binary_id
|
||||
|
||||
add :repo_count, :integer, null: false, default: 0
|
||||
add :instance_count, :integer, null: false, default: 0
|
||||
add :open_count, :integer, null: false, default: 0
|
||||
add :verified_count, :integer, null: false, default: 0
|
||||
add :fixed_count, :integer, null: false, default: 0
|
||||
add :disputed_count, :integer, null: false, default: 0
|
||||
|
||||
add :first_seen_at, :utc_datetime_usec
|
||||
add :last_seen_at, :utc_datetime_usec
|
||||
|
||||
# Windowed aggregates for 7 / 30 / 90 / 365 day list semantics
|
||||
add :repo_count_7d, :integer, null: false, default: 0
|
||||
add :instance_count_7d, :integer, null: false, default: 0
|
||||
add :open_count_7d, :integer, null: false, default: 0
|
||||
add :verified_count_7d, :integer, null: false, default: 0
|
||||
add :fixed_count_7d, :integer, null: false, default: 0
|
||||
add :disputed_count_7d, :integer, null: false, default: 0
|
||||
add :last_seen_at_7d, :utc_datetime_usec
|
||||
|
||||
add :repo_count_30d, :integer, null: false, default: 0
|
||||
add :instance_count_30d, :integer, null: false, default: 0
|
||||
add :open_count_30d, :integer, null: false, default: 0
|
||||
add :verified_count_30d, :integer, null: false, default: 0
|
||||
add :fixed_count_30d, :integer, null: false, default: 0
|
||||
add :disputed_count_30d, :integer, null: false, default: 0
|
||||
add :last_seen_at_30d, :utc_datetime_usec
|
||||
|
||||
add :repo_count_90d, :integer, null: false, default: 0
|
||||
add :instance_count_90d, :integer, null: false, default: 0
|
||||
add :open_count_90d, :integer, null: false, default: 0
|
||||
add :verified_count_90d, :integer, null: false, default: 0
|
||||
add :fixed_count_90d, :integer, null: false, default: 0
|
||||
add :disputed_count_90d, :integer, null: false, default: 0
|
||||
add :last_seen_at_90d, :utc_datetime_usec
|
||||
|
||||
add :repo_count_365d, :integer, null: false, default: 0
|
||||
add :instance_count_365d, :integer, null: false, default: 0
|
||||
add :open_count_365d, :integer, null: false, default: 0
|
||||
add :verified_count_365d, :integer, null: false, default: 0
|
||||
add :fixed_count_365d, :integer, null: false, default: 0
|
||||
add :disputed_count_365d, :integer, null: false, default: 0
|
||||
add :last_seen_at_365d, :utc_datetime_usec
|
||||
|
||||
add :refreshed_at, :utc_datetime_usec, null: false
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create index(:epidemic_patterns, [:repo_count_7d, :last_seen_at_7d],
|
||||
name: :epidemic_patterns_rank_7d_idx,
|
||||
where: "repo_count_7d >= 2"
|
||||
)
|
||||
|
||||
create index(:epidemic_patterns, [:repo_count_30d, :last_seen_at_30d],
|
||||
name: :epidemic_patterns_rank_30d_idx,
|
||||
where: "repo_count_30d >= 2"
|
||||
)
|
||||
|
||||
create index(:epidemic_patterns, [:repo_count_90d, :last_seen_at_90d],
|
||||
name: :epidemic_patterns_rank_90d_idx,
|
||||
where: "repo_count_90d >= 2"
|
||||
)
|
||||
|
||||
create index(:epidemic_patterns, [:repo_count_365d, :last_seen_at_365d],
|
||||
name: :epidemic_patterns_rank_365d_idx,
|
||||
where: "repo_count_365d >= 2"
|
||||
)
|
||||
|
||||
create table(:epidemic_pattern_repos, primary_key: false) do
|
||||
add :pattern_key, :string, size: 64, primary_key: true
|
||||
|
||||
add :repository_id, references(:repositories, on_delete: :delete_all),
|
||||
null: false,
|
||||
primary_key: true
|
||||
|
||||
add :instance_count, :integer, null: false, default: 0
|
||||
add :open_count, :integer, null: false, default: 0
|
||||
add :verified_count, :integer, null: false, default: 0
|
||||
add :fixed_count, :integer, null: false, default: 0
|
||||
add :disputed_count, :integer, null: false, default: 0
|
||||
add :primary_status, :string, null: false, default: "open"
|
||||
add :severity, :string
|
||||
add :title, :string
|
||||
add :sample_occurrence_public_id, :binary_id
|
||||
add :first_seen_at, :utc_datetime_usec
|
||||
add :last_seen_at, :utc_datetime_usec
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create index(:epidemic_pattern_repos, [:repository_id])
|
||||
|
||||
create index(:epidemic_pattern_repos, [:pattern_key, :last_seen_at, :repository_id],
|
||||
name: :epidemic_pattern_repos_pattern_recent_idx
|
||||
)
|
||||
|
||||
create table(:epidemic_pattern_instances, primary_key: false) do
|
||||
add :canonical_finding_id, references(:canonical_findings, on_delete: :delete_all),
|
||||
primary_key: true
|
||||
|
||||
add :pattern_key, :string, size: 64, null: false
|
||||
|
||||
add :repository_id, references(:repositories, on_delete: :delete_all), null: false
|
||||
|
||||
add :status, :string, null: false
|
||||
add :severity, :string
|
||||
add :title, :string, null: false
|
||||
add :file_path, :string
|
||||
add :sample_occurrence_public_id, :binary_id
|
||||
add :inserted_at, :utc_datetime_usec, null: false
|
||||
add :updated_at, :utc_datetime_usec, null: false
|
||||
end
|
||||
|
||||
create unique_index(:epidemic_pattern_instances, [:pattern_key, :canonical_finding_id])
|
||||
create index(:epidemic_pattern_instances, [:pattern_key, :updated_at])
|
||||
create index(:epidemic_pattern_instances, [:status, :updated_at, :pattern_key])
|
||||
create index(:epidemic_pattern_instances, [:repository_id])
|
||||
|
||||
# Supporting source indexes (non-concurrent; fine for current table sizes)
|
||||
create index(:canonical_findings, [:pattern_key, :repository_id],
|
||||
name: :canonical_findings_pattern_repo_idx,
|
||||
where: "pattern_key IS NOT NULL AND pattern_key <> ''"
|
||||
)
|
||||
|
||||
create index(:canonical_findings, [:pattern_key, :status],
|
||||
name: :canonical_findings_pattern_status_idx,
|
||||
where: "pattern_key IS NOT NULL AND pattern_key <> ''"
|
||||
)
|
||||
|
||||
create index(:canonical_findings, [:pattern_key, :updated_at],
|
||||
name: :canonical_findings_pattern_updated_idx,
|
||||
where: "pattern_key IS NOT NULL AND pattern_key <> ''"
|
||||
)
|
||||
|
||||
create index(:repositories, [:id],
|
||||
name: :repositories_listed_id_idx,
|
||||
where: "listing_status = 'listed'"
|
||||
)
|
||||
|
||||
create index(:scans, [:repository_id, :id],
|
||||
name: :scans_public_repo_idx,
|
||||
where: "visibility = 'public'"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddDisclosureFieldsToCanonicalFindings do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:canonical_findings) do
|
||||
add :reproduction_steps, :text
|
||||
add :affected_versions, :string, size: 500
|
||||
add :cwe_id, :string
|
||||
add :cve_id, :string
|
||||
add :vendor_notified_at, :utc_datetime_usec
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
defmodule Tarakan.Repo.Migrations.RenameEpidemicTables do
|
||||
use Ecto.Migration
|
||||
|
||||
# Postgres keeps old index/constraint names across ALTER TABLE ... RENAME,
|
||||
# so they are renamed explicitly below.
|
||||
@indexes [
|
||||
{"epidemic_patterns_rank_7d_idx", "infestation_patterns_rank_7d_idx"},
|
||||
{"epidemic_patterns_rank_30d_idx", "infestation_patterns_rank_30d_idx"},
|
||||
{"epidemic_patterns_rank_90d_idx", "infestation_patterns_rank_90d_idx"},
|
||||
{"epidemic_patterns_rank_365d_idx", "infestation_patterns_rank_365d_idx"},
|
||||
{"epidemic_pattern_repos_pattern_recent_idx", "infestation_pattern_repos_pattern_recent_idx"},
|
||||
{"epidemic_pattern_repos_repository_id_index",
|
||||
"infestation_pattern_repos_repository_id_index"},
|
||||
# These two default names already hit the 63-char identifier limit; the new
|
||||
# names stay within it instead of re-truncating.
|
||||
{"epidemic_pattern_instances_pattern_key_canonical_finding_id_index",
|
||||
"infestation_pattern_instances_pattern_canonical_idx"},
|
||||
{"epidemic_pattern_instances_pattern_key_updated_at_index",
|
||||
"infestation_pattern_instances_pattern_key_updated_at_index"},
|
||||
{"epidemic_pattern_instances_status_updated_at_pattern_key_index",
|
||||
"infestation_pattern_instances_status_window_idx"},
|
||||
{"epidemic_pattern_instances_repository_id_index",
|
||||
"infestation_pattern_instances_repository_id_index"}
|
||||
]
|
||||
|
||||
@constraints [
|
||||
{"infestation_patterns", "epidemic_patterns_pkey", "infestation_patterns_pkey"},
|
||||
{"infestation_pattern_repos", "epidemic_pattern_repos_pkey",
|
||||
"infestation_pattern_repos_pkey"},
|
||||
{"infestation_pattern_instances", "epidemic_pattern_instances_pkey",
|
||||
"infestation_pattern_instances_pkey"},
|
||||
{"infestation_pattern_repos", "epidemic_pattern_repos_repository_id_fkey",
|
||||
"infestation_pattern_repos_repository_id_fkey"},
|
||||
{"infestation_pattern_instances", "epidemic_pattern_instances_canonical_finding_id_fkey",
|
||||
"infestation_pattern_instances_canonical_finding_id_fkey"},
|
||||
{"infestation_pattern_instances", "epidemic_pattern_instances_repository_id_fkey",
|
||||
"infestation_pattern_instances_repository_id_fkey"}
|
||||
]
|
||||
|
||||
def change do
|
||||
rename table(:epidemic_patterns), to: table(:infestation_patterns)
|
||||
rename table(:epidemic_pattern_instances), to: table(:infestation_pattern_instances)
|
||||
rename table(:epidemic_pattern_repos), to: table(:infestation_pattern_repos)
|
||||
|
||||
for {old, new} <- @indexes do
|
||||
execute "ALTER INDEX #{old} RENAME TO #{new}",
|
||||
"ALTER INDEX #{new} RENAME TO #{old}"
|
||||
end
|
||||
|
||||
for {table, old, new} <- @constraints do
|
||||
execute "ALTER TABLE #{table} RENAME CONSTRAINT #{old} TO #{new}",
|
||||
"ALTER TABLE #{table} RENAME CONSTRAINT #{new} TO #{old}"
|
||||
end
|
||||
end
|
||||
end
|
||||
30
priv/repo/migrations/20260724081002_create_credit_ledger.exs
Normal file
30
priv/repo/migrations/20260724081002_create_credit_ledger.exs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
defmodule Tarakan.Repo.Migrations.CreateCreditLedger do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:credit_entries) do
|
||||
add :account_id, references(:accounts), null: false
|
||||
add :amount, :integer, null: false
|
||||
add :kind, :string, null: false
|
||||
add :subject_type, :string
|
||||
add :subject_id, :bigint
|
||||
add :balance_after, :integer, null: false
|
||||
|
||||
timestamps(type: :utc_datetime_usec, updated_at: false)
|
||||
end
|
||||
|
||||
create index(:credit_entries, [:account_id, :id])
|
||||
|
||||
# Mint idempotency: one entry per (kind, subject, account). Postgres treats
|
||||
# NULLs as distinct, and non-mint entries have no subject, so the index is
|
||||
# partial on rows that carry a subject.
|
||||
create unique_index(:credit_entries, [:kind, :subject_type, :subject_id, :account_id],
|
||||
name: :credit_entries_mint_unique_index,
|
||||
where: "subject_type IS NOT NULL"
|
||||
)
|
||||
|
||||
alter table(:accounts) do
|
||||
add :credit_balance, :integer, null: false, default: 0
|
||||
end
|
||||
end
|
||||
end
|
||||
71
priv/repo/migrations/20260724085814_create_bounties.exs
Normal file
71
priv/repo/migrations/20260724085814_create_bounties.exs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
defmodule Tarakan.Repo.Migrations.CreateBounties do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:bounties) do
|
||||
add :public_id, :uuid, null: false, default: fragment("gen_random_uuid()")
|
||||
add :sponsor_account_id, references(:accounts), null: false
|
||||
add :target_type, :string, null: false
|
||||
add :repository_id, references(:repositories)
|
||||
add :pattern_key, :string
|
||||
add :canonical_finding_id, references(:canonical_findings)
|
||||
add :title, :string, null: false
|
||||
add :description, :string, null: false
|
||||
add :funding, :string, null: false
|
||||
add :amount_cents, :integer
|
||||
add :credit_amount, :integer
|
||||
add :take_rate, :decimal, precision: 4, scale: 3, null: false
|
||||
add :status, :string, null: false, default: "pending_funding"
|
||||
add :stripe_checkout_session_id, :string
|
||||
add :winner_account_id, references(:accounts)
|
||||
add :expires_at, :utc_datetime_usec
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create unique_index(:bounties, [:public_id])
|
||||
create index(:bounties, [:status])
|
||||
create index(:bounties, [:repository_id])
|
||||
create index(:bounties, [:pattern_key])
|
||||
create index(:bounties, [:canonical_finding_id])
|
||||
create index(:bounties, [:sponsor_account_id])
|
||||
create index(:bounties, [:stripe_checkout_session_id])
|
||||
|
||||
# Target shape (exactly one ref matching target_type) is validated in the
|
||||
# Bounty changeset; see Tarakan.Market.Bounty.validate_target/1.
|
||||
create constraint(:bounties, :bounties_target_type_must_be_valid,
|
||||
check: "target_type IN ('repository','infestation','finding')"
|
||||
)
|
||||
|
||||
create constraint(:bounties, :bounties_funding_must_be_valid,
|
||||
check: "funding IN ('fiat','credits')"
|
||||
)
|
||||
|
||||
create constraint(:bounties, :bounties_status_must_be_valid,
|
||||
check:
|
||||
"status IN ('pending_funding','open','claimed','fulfilled','payout_pending','paid','cancelled','expired')"
|
||||
)
|
||||
|
||||
create table(:bounty_claims) do
|
||||
add :bounty_id, references(:bounties), null: false
|
||||
add :account_id, references(:accounts), null: false
|
||||
add :review_task_id, references(:review_tasks)
|
||||
add :status, :string, null: false, default: "claimed"
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create index(:bounty_claims, [:bounty_id])
|
||||
create index(:bounty_claims, [:account_id])
|
||||
|
||||
create constraint(:bounty_claims, :bounty_claims_status_must_be_valid,
|
||||
check: "status IN ('claimed','submitted','accepted','rejected','withdrawn')"
|
||||
)
|
||||
|
||||
# One active claim per hunter per bounty.
|
||||
create unique_index(:bounty_claims, [:bounty_id, :account_id],
|
||||
name: :bounty_claims_active_unique_index,
|
||||
where: "status IN ('claimed','submitted')"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
defmodule Tarakan.Repo.Migrations.CreateSubscriptionsAndWatchlists do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:subscriptions) do
|
||||
# One subscription per account; upgraded/downgraded in place.
|
||||
add :account_id, references(:accounts), null: false
|
||||
add :plan, :string, null: false
|
||||
add :status, :string, null: false, default: "incomplete"
|
||||
add :stripe_customer_id, :string
|
||||
add :stripe_subscription_id, :string
|
||||
add :current_period_end, :utc_datetime_usec
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create unique_index(:subscriptions, [:account_id])
|
||||
create unique_index(:subscriptions, [:stripe_subscription_id])
|
||||
create index(:subscriptions, [:stripe_customer_id])
|
||||
|
||||
create constraint(:subscriptions, :subscriptions_plan_must_be_valid,
|
||||
check: "plan IN ('team','enterprise')"
|
||||
)
|
||||
|
||||
create constraint(:subscriptions, :subscriptions_status_must_be_valid,
|
||||
check: "status IN ('active','past_due','canceled','incomplete')"
|
||||
)
|
||||
|
||||
create table(:watchlists) do
|
||||
add :account_id, references(:accounts), null: false
|
||||
add :name, :string, null: false
|
||||
# Repo refs in "owner/name" shape; entry shape/count validated in the
|
||||
# Watchlist changeset (Tarakan.Billing.Watchlist).
|
||||
add :entries, {:array, :string}, null: false, default: []
|
||||
add :notify, :boolean, null: false, default: true
|
||||
add :last_notified_at, :utc_datetime_usec
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create index(:watchlists, [:account_id])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddManagedDisclosureToRepositories do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:repositories) do
|
||||
# Staff-managed disclosure handling for the repository's vendor;
|
||||
# handling only - the public record is unchanged.
|
||||
add :managed_disclosure, :boolean, null: false, default: false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddFixLifecycleToCanonicalFindings do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:canonical_findings) do
|
||||
add :fixed_at, :utc_datetime_usec
|
||||
add :fixed_commit_sha, :string
|
||||
add :regressions_count, :integer, null: false, default: 0
|
||||
add :last_regressed_at, :utc_datetime_usec
|
||||
end
|
||||
|
||||
# Existing fixed rows predate the stamp; `updated_at` is the closest
|
||||
# honest approximation of when they settled.
|
||||
execute """
|
||||
UPDATE canonical_findings
|
||||
SET fixed_at = updated_at,
|
||||
fixed_commit_sha = last_seen_commit_sha
|
||||
WHERE status = 'fixed'
|
||||
"""
|
||||
|
||||
# Time-to-fix reads verified/fixed pairs per repository.
|
||||
create index(:canonical_findings, [:repository_id, :fixed_at],
|
||||
where: "fixed_at IS NOT NULL",
|
||||
name: :canonical_findings_repository_fixed_at_index
|
||||
)
|
||||
|
||||
# The suppression corpus reads disputed rows by pattern.
|
||||
create index(:canonical_findings, [:pattern_key, :status],
|
||||
where: "pattern_key IS NOT NULL",
|
||||
name: :canonical_findings_pattern_status_index
|
||||
)
|
||||
end
|
||||
|
||||
def down do
|
||||
drop index(:canonical_findings, [:repository_id, :fixed_at],
|
||||
name: :canonical_findings_repository_fixed_at_index
|
||||
)
|
||||
|
||||
drop index(:canonical_findings, [:pattern_key, :status],
|
||||
name: :canonical_findings_pattern_status_index
|
||||
)
|
||||
|
||||
alter table(:canonical_findings) do
|
||||
remove :fixed_at
|
||||
remove :fixed_commit_sha
|
||||
remove :regressions_count
|
||||
remove :last_regressed_at
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
defmodule Tarakan.Repo.Migrations.CreateFindingRegressions do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:finding_regressions) do
|
||||
add :canonical_finding_id, references(:canonical_findings, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :repository_id, references(:repositories, on_delete: :delete_all), null: false
|
||||
# The commit the finding was last believed fixed at, and when.
|
||||
add :fixed_commit_sha, :string
|
||||
add :fixed_at, :utc_datetime_usec
|
||||
# The commit the finding was seen at again.
|
||||
add :detected_commit_sha, :string, null: false
|
||||
add :detected_by_scan_id, references(:scans, on_delete: :nilify_all)
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
# One regression row per (finding, reappearance commit) keeps repeated
|
||||
# scans of the same commit from stacking duplicates. Named explicitly
|
||||
# because the derived name exceeds Postgres's 63-character identifier cap.
|
||||
create unique_index(:finding_regressions, [:canonical_finding_id, :detected_commit_sha],
|
||||
name: :finding_regressions_unique_detection_index
|
||||
)
|
||||
|
||||
create index(:finding_regressions, [:repository_id, :inserted_at])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
defmodule Tarakan.Repo.Migrations.CreateFixPropagation do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
# A fix that settled on one repository, captured so the same pattern can be
|
||||
# closed on every other repository carrying it.
|
||||
create table(:fix_templates) do
|
||||
add :pattern_key, :string, null: false
|
||||
|
||||
add :source_canonical_finding_id, references(:canonical_findings, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :source_repository_id, references(:repositories, on_delete: :delete_all), null: false
|
||||
add :fix_commit_sha, :string
|
||||
add :title, :string, null: false
|
||||
add :summary, :text, null: false
|
||||
# Unified diff of the fixing commit when the repository is mirrored;
|
||||
# null when only narrative evidence is available.
|
||||
add :diff, :text
|
||||
add :diff_truncated, :boolean, null: false, default: false
|
||||
add :created_by_id, references(:accounts, on_delete: :nilify_all)
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create unique_index(:fix_templates, [:source_canonical_finding_id])
|
||||
create index(:fix_templates, [:pattern_key, :inserted_at])
|
||||
|
||||
# One row per repository the template was carried to.
|
||||
create table(:fix_propagations) do
|
||||
add :fix_template_id, references(:fix_templates, on_delete: :delete_all), null: false
|
||||
|
||||
add :canonical_finding_id, references(:canonical_findings, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :repository_id, references(:repositories, on_delete: :delete_all), null: false
|
||||
add :review_task_id, references(:review_tasks, on_delete: :nilify_all)
|
||||
add :status, :string, null: false, default: "open"
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create unique_index(:fix_propagations, [:fix_template_id, :canonical_finding_id])
|
||||
create index(:fix_propagations, [:repository_id, :status])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddReproductionToFindingChecks do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:canonical_finding_checks) do
|
||||
# Client-attested reproduction. The worker runs the PoC on its own machine
|
||||
# and reports the outcome; the artifact and runtime are stored verbatim so
|
||||
# a server-side runner can later re-execute the identical thing without a
|
||||
# schema change.
|
||||
add :repro_status, :string
|
||||
add :repro_runtime, :string
|
||||
add :repro_artifact, :text
|
||||
add :repro_transcript, :text
|
||||
end
|
||||
|
||||
create constraint(:canonical_finding_checks, :canonical_finding_checks_repro_status_valid,
|
||||
check: "repro_status IS NULL OR repro_status IN ('pass','fail','inconclusive')"
|
||||
)
|
||||
|
||||
# A reproduction is only meaningful with something to re-run.
|
||||
create constraint(:canonical_finding_checks, :canonical_finding_checks_repro_needs_artifact,
|
||||
check: "repro_status IS NULL OR repro_artifact IS NOT NULL"
|
||||
)
|
||||
|
||||
create index(:canonical_finding_checks, [:canonical_finding_id, :repro_status])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddCalibrationRefutationEmbeddingToCanonicalFindings do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:canonical_findings) do
|
||||
# Severity calibration. The submitter's claim stays in `severity` and is
|
||||
# never overwritten; this is a second opinion scored against a rubric, so
|
||||
# the two can be compared and the rubric can be re-run on old findings.
|
||||
add :calibrated_severity, :string
|
||||
add :calibrated_at, :utc_datetime_usec
|
||||
add :severity_rubric, :string
|
||||
|
||||
# Adversarial verification. `refutations_count` is attempts made,
|
||||
# `survived_refutations_count` is attempts that failed to break it.
|
||||
# Surviving attacks is a stronger claim than collecting agreements.
|
||||
add :refutations_count, :integer, null: false, default: 0
|
||||
add :survived_refutations_count, :integer, null: false, default: 0
|
||||
|
||||
# Client-submitted embedding of the finding's code context, and the
|
||||
# semantic cluster derived from it. Kept separate from `pattern_key`
|
||||
# (a hash of the normalized title) so the two can disagree and the
|
||||
# title-based grouping keeps working while clusters fill in.
|
||||
add :embedding, {:array, :float}
|
||||
add :embedding_model, :string
|
||||
add :code_pattern_key, :string
|
||||
end
|
||||
|
||||
create constraint(:canonical_findings, :canonical_findings_calibrated_severity_valid,
|
||||
check:
|
||||
"calibrated_severity IS NULL OR " <>
|
||||
"calibrated_severity IN ('critical','high','medium','low','info')"
|
||||
)
|
||||
|
||||
create constraint(:canonical_findings, :canonical_findings_refutations_non_negative,
|
||||
check: "refutations_count >= 0 AND survived_refutations_count >= 0"
|
||||
)
|
||||
|
||||
create constraint(:canonical_findings, :canonical_findings_survived_within_attempts,
|
||||
check: "survived_refutations_count <= refutations_count"
|
||||
)
|
||||
|
||||
create index(:canonical_findings, [:code_pattern_key])
|
||||
create index(:canonical_findings, [:calibrated_severity])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddBaseCommitToReviewTasks do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:review_tasks) do
|
||||
# For diff_review: the range is base_commit_sha..commit_sha. Null for every
|
||||
# other kind, which reviews the snapshot at commit_sha.
|
||||
add :base_commit_sha, :string
|
||||
end
|
||||
|
||||
create constraint(:review_tasks, :review_tasks_base_commit_sha_format,
|
||||
check: "base_commit_sha IS NULL OR base_commit_sha ~ '^[0-9a-f]{40}$'"
|
||||
)
|
||||
|
||||
create constraint(:review_tasks, :review_tasks_diff_review_needs_base,
|
||||
check: "kind <> 'diff_review' OR base_commit_sha IS NOT NULL"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddFindingTargetedTaskKinds do
|
||||
use Ecto.Migration
|
||||
|
||||
@old ~w(code_review threat_model privacy_review business_logic verify_findings write_fix)
|
||||
@new @old ++ ~w(diff_review refute_finding reproduce_finding calibrate_severity)
|
||||
|
||||
def up do
|
||||
alter table(:review_tasks) do
|
||||
# refute_finding / reproduce_finding / calibrate_severity all act on one
|
||||
# canonical finding rather than a tree, so they need a finding to point at.
|
||||
add :target_canonical_finding_id,
|
||||
references(:canonical_findings, on_delete: :delete_all)
|
||||
end
|
||||
|
||||
create index(:review_tasks, [:target_canonical_finding_id])
|
||||
|
||||
drop constraint(:review_tasks, :review_tasks_kind_must_be_valid)
|
||||
create constraint(:review_tasks, :review_tasks_kind_must_be_valid, check: kind_check(@new))
|
||||
|
||||
alter table(:canonical_finding_checks) do
|
||||
# An adversarial check was asked to break the finding, not to agree with it.
|
||||
# Reusing the existing verdicts keeps the vocabulary small: "confirmed" on
|
||||
# an adversarial check means the attack failed and the finding held.
|
||||
add :adversarial, :boolean, null: false, default: false
|
||||
end
|
||||
|
||||
create index(:canonical_finding_checks, [:canonical_finding_id, :adversarial])
|
||||
end
|
||||
|
||||
def down do
|
||||
drop index(:canonical_finding_checks, [:canonical_finding_id, :adversarial])
|
||||
|
||||
alter table(:canonical_finding_checks) do
|
||||
remove :adversarial
|
||||
end
|
||||
|
||||
drop constraint(:review_tasks, :review_tasks_kind_must_be_valid)
|
||||
create constraint(:review_tasks, :review_tasks_kind_must_be_valid, check: kind_check(@old))
|
||||
|
||||
drop index(:review_tasks, [:target_canonical_finding_id])
|
||||
|
||||
alter table(:review_tasks) do
|
||||
remove :target_canonical_finding_id
|
||||
end
|
||||
end
|
||||
|
||||
defp kind_check(kinds) do
|
||||
list = Enum.map_join(kinds, ", ", &"'#{&1}'")
|
||||
"kind IN (#{list})"
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
defmodule Tarakan.Repo.Migrations.AddReproductionCountersToCanonicalFindings do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:canonical_findings) do
|
||||
# Denormalised the same way confirmations_count is, so findings backed by a
|
||||
# working reproduction can be ranked and filtered without joining checks.
|
||||
add :reproductions_count, :integer, null: false, default: 0
|
||||
add :reproduced_at, :utc_datetime_usec
|
||||
end
|
||||
|
||||
create constraint(:canonical_findings, :canonical_findings_reproductions_non_negative,
|
||||
check: "reproductions_count >= 0"
|
||||
)
|
||||
|
||||
create index(:canonical_findings, [:reproduced_at])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
defmodule Tarakan.Repo.Migrations.CreateCodePatternRules do
|
||||
use Ecto.Migration
|
||||
|
||||
@kinds ~w(code_review threat_model privacy_review business_logic verify_findings write_fix
|
||||
diff_review refute_finding reproduce_finding calibrate_severity)
|
||||
@with_synthesis @kinds ++ ~w(synthesize_rule)
|
||||
|
||||
def up do
|
||||
create table(:code_pattern_rules) do
|
||||
# One rule per code cluster. The cluster is a string key rather than a row,
|
||||
# so this table is where a cluster first becomes a thing you can own.
|
||||
add :code_pattern_key, :string, null: false
|
||||
add :engine, :string, null: false, default: "semgrep"
|
||||
add :language, :string
|
||||
add :rule_yaml, :text, null: false
|
||||
|
||||
# Validation is done by the worker, which has both the rule engine and the
|
||||
# code. A rule is only servable if it demonstrably matched the instances it
|
||||
# claims to cover: `matched_count` of `checked_count`, with the finding ids
|
||||
# it hit recorded so the claim can be re-tested later.
|
||||
add :checked_count, :integer, null: false, default: 0
|
||||
add :matched_count, :integer, null: false, default: 0
|
||||
add :matched_finding_ids, {:array, :binary_id}, null: false, default: []
|
||||
add :validated_at, :utc_datetime_usec
|
||||
|
||||
add :author_account_id, references(:accounts, on_delete: :nilify_all)
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
# Newest validated rule per cluster wins; only one row per author per cluster
|
||||
# so a resubmission updates rather than piles up.
|
||||
create unique_index(:code_pattern_rules, [:code_pattern_key, :author_account_id])
|
||||
create index(:code_pattern_rules, [:code_pattern_key, :validated_at])
|
||||
|
||||
create constraint(:code_pattern_rules, :code_pattern_rules_counts_sane,
|
||||
check: "checked_count >= 0 AND matched_count >= 0 AND matched_count <= checked_count"
|
||||
)
|
||||
|
||||
# A rule that matched nothing is not evidence of a pattern, it is a guess.
|
||||
create constraint(:code_pattern_rules, :code_pattern_rules_validated_needs_a_match,
|
||||
check: "validated_at IS NULL OR matched_count > 0"
|
||||
)
|
||||
|
||||
alter table(:review_tasks) do
|
||||
add :target_code_pattern_key, :string
|
||||
end
|
||||
|
||||
create index(:review_tasks, [:target_code_pattern_key])
|
||||
|
||||
drop constraint(:review_tasks, :review_tasks_kind_must_be_valid)
|
||||
|
||||
create constraint(:review_tasks, :review_tasks_kind_must_be_valid,
|
||||
check: kind_check(@with_synthesis)
|
||||
)
|
||||
end
|
||||
|
||||
def down do
|
||||
drop constraint(:review_tasks, :review_tasks_kind_must_be_valid)
|
||||
create constraint(:review_tasks, :review_tasks_kind_must_be_valid, check: kind_check(@kinds))
|
||||
|
||||
drop index(:review_tasks, [:target_code_pattern_key])
|
||||
|
||||
alter table(:review_tasks) do
|
||||
remove :target_code_pattern_key
|
||||
end
|
||||
|
||||
drop table(:code_pattern_rules)
|
||||
end
|
||||
|
||||
defp kind_check(kinds) do
|
||||
"kind IN (" <> Enum.map_join(kinds, ", ", &"'#{&1}'") <> ")"
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
defmodule Tarakan.Repo.Migrations.IndexUnindexedForeignKeys do
|
||||
use Ecto.Migration
|
||||
|
||||
# Postgres does not index a foreign key for you. Without one, deleting a
|
||||
# parent row sequentially scans the whole child table to enforce the
|
||||
# constraint - so removing a single account or repository degrades with the
|
||||
# size of every table that references it, and the cascades declared on these
|
||||
# columns make that certain rather than hypothetical.
|
||||
#
|
||||
# Created plainly because these tables are still small. Adding an index to a
|
||||
# large, live table wants `concurrently: true` with
|
||||
# `@disable_ddl_transaction true`, which is a different migration.
|
||||
@columns [
|
||||
{:bounties, :winner_account_id},
|
||||
{:bounty_claims, :review_task_id},
|
||||
{:canonical_finding_checks, :scan_finding_id},
|
||||
{:client_authorizations, :account_id},
|
||||
{:code_pattern_rules, :author_account_id},
|
||||
{:finding_comments, :removed_by_id},
|
||||
{:finding_comments, :repository_id},
|
||||
{:finding_regressions, :detected_by_scan_id},
|
||||
{:fix_propagations, :canonical_finding_id},
|
||||
{:fix_propagations, :review_task_id},
|
||||
{:fix_templates, :created_by_id},
|
||||
{:fix_templates, :source_repository_id},
|
||||
{:moderation_appeals, :appellant_id},
|
||||
{:moderation_appeals, :decided_by_id},
|
||||
{:moderation_cases, :assigned_to_id},
|
||||
{:moderation_cases, :resolved_by_id},
|
||||
{:registry_shouts, :removed_by_id},
|
||||
{:repository_memberships, :verified_by_account_id}
|
||||
]
|
||||
|
||||
def change do
|
||||
for {table, column} <- @columns do
|
||||
create_if_not_exists index(table, [column])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
defmodule Tarakan.Repo.Migrations.CreateInfestationSwarms do
|
||||
use Ecto.Migration
|
||||
|
||||
# One row per swarm run. The cooldown reads the newest row for a pattern, and
|
||||
# the rows double as a record of who fanned work out across other people's
|
||||
# agents - the cost of a swarm lands on them, not on the moderator clicking.
|
||||
def change do
|
||||
create table(:infestation_swarms) do
|
||||
add :pattern_key, :string, null: false
|
||||
add :account_id, references(:accounts, on_delete: :nilify_all)
|
||||
add :jobs_opened, :integer, null: false, default: 0
|
||||
add :candidates_seen, :integer, null: false, default: 0
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create index(:infestation_swarms, [:pattern_key, :inserted_at],
|
||||
name: :infestation_swarms_pattern_recent_index
|
||||
)
|
||||
|
||||
create index(:infestation_swarms, [:account_id])
|
||||
end
|
||||
end
|
||||
195
priv/repo/seed_infestations.exs
Normal file
195
priv/repo/seed_infestations.exs
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
# Demo infestation data: multi-repo patterns for the infestation map.
|
||||
#
|
||||
# mix run priv/repo/seed_infestations.exs
|
||||
#
|
||||
# This script fabricates security findings. It therefore refuses to run outside
|
||||
# :dev, and it only ever writes against repositories it creates itself, under
|
||||
# the reserved `infestation-demo-*` owner prefix.
|
||||
#
|
||||
# It must never adopt repositories that already exist. An earlier version
|
||||
# selected every listed repository, which meant a run against a database
|
||||
# containing real projects published invented "verified critical" findings
|
||||
# attributed to them by name.
|
||||
#
|
||||
# Idempotent enough for demo: uses stable run_ids; re-running may hit unique
|
||||
# constraints on run_id and skip duplicates.
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Tarakan.Accounts.Account
|
||||
alias Tarakan.Infestations
|
||||
alias Tarakan.Repo
|
||||
alias Tarakan.Repositories.Repository
|
||||
alias Tarakan.Scans
|
||||
|
||||
if Mix.env() != :dev do
|
||||
Mix.shell().error("""
|
||||
Refusing to run: this seeder fabricates public security findings and is
|
||||
dev-only. Current environment: #{Mix.env()}.
|
||||
""")
|
||||
|
||||
System.halt(1)
|
||||
end
|
||||
|
||||
demo_owner_prefix = "infestation-demo"
|
||||
demo_repo_count = 8
|
||||
|
||||
account =
|
||||
Repo.get_by(Account, email: "mod@example.com") ||
|
||||
Repo.one(from a in Account, where: a.platform_role in ["moderator", "admin"], limit: 1) ||
|
||||
Repo.one!(from a in Account, where: a.state == "active", limit: 1)
|
||||
|
||||
# Create our own targets. Never touch anything already in the registry: a real
|
||||
# project must not appear in fabricated data under any circumstances.
|
||||
repos =
|
||||
for i <- 1..demo_repo_count do
|
||||
owner = "#{demo_owner_prefix}-#{i}"
|
||||
name = "service"
|
||||
|
||||
case Repo.get_by(Repository, host: "github.com", owner: owner, name: name) do
|
||||
%Repository{} = existing ->
|
||||
existing
|
||||
|
||||
nil ->
|
||||
%Repository{}
|
||||
|> Ecto.Changeset.change(%{
|
||||
host: "github.com",
|
||||
owner: owner,
|
||||
name: name,
|
||||
listing_status: "listed",
|
||||
submitted_by_id: account.id
|
||||
})
|
||||
|> Repo.insert!()
|
||||
end
|
||||
end
|
||||
|
||||
# Belt and braces: if anything above ever returns a repository we did not make,
|
||||
# stop rather than write findings against it.
|
||||
Enum.each(repos, fn repo ->
|
||||
unless String.starts_with?(repo.owner, demo_owner_prefix <> "-") do
|
||||
Mix.shell().error("Refusing to seed against non-demo repository #{repo.owner}/#{repo.name}")
|
||||
System.halt(1)
|
||||
end
|
||||
end)
|
||||
|
||||
# {title, severity, status cycle per instance} - length of status list = repo fan-out
|
||||
patterns = [
|
||||
{"SQL injection via string concatenation", "critical", ~w(open open verified open open)},
|
||||
{"Hardcoded credentials in config loader", "high", ~w(open fixed open verified open)},
|
||||
{"Command injection through shell interpolation", "critical", ~w(open open open open verified)},
|
||||
{"Path traversal in archive extract", "high", ~w(open open fixed open)},
|
||||
{"SSRF via user-controlled URL fetch", "high", ~w(open verified open open open)},
|
||||
{"JWT accepts alg=none", "critical", ~w(open open disputed open)},
|
||||
{"Missing authorization on admin API route", "critical", ~w(open open open verified open open)},
|
||||
{"Insecure deserialization of user input", "high", ~w(open fixed open open)},
|
||||
{"Open redirect after login", "medium", ~w(open open verified fixed open)},
|
||||
{"Prototype pollution in deep merge", "high", ~w(open open open open)},
|
||||
{"Debug endpoint left enabled", "medium", ~w(open fixed open open open)},
|
||||
{"Weak random token generation", "medium", ~w(open open verified open)},
|
||||
{"CORS reflects arbitrary Origin", "medium", ~w(open open open open open)},
|
||||
{"Timing-unsafe secret comparison", "high", ~w(open verified open open)},
|
||||
{"Unsanitized HTML rendered as markup", "high", ~w(open open open fixed verified open open)},
|
||||
{"XML external entity in parser", "high", ~w(open open verified)},
|
||||
{"LDAP injection in directory search", "high", ~w(open open open fixed)},
|
||||
{"Mass assignment on privileged fields", "medium", ~w(open verified open open open)},
|
||||
{"Broken access control on object ID", "critical", ~w(open open open open verified open)},
|
||||
{"Insecure direct object reference", "high", ~w(open open fixed open open)}
|
||||
]
|
||||
|
||||
sha = fn i, repo_id, title ->
|
||||
:crypto.hash(:sha256, "seed-epi-v2-#{repo_id}-#{i}-#{title}")
|
||||
|> Base.encode16(case: :lower)
|
||||
|> String.slice(0, 40)
|
||||
end
|
||||
|
||||
{ok_count, skip_count, err_count} =
|
||||
Enum.reduce(patterns, {0, 0, 0}, fn {title, severity, status_cycle}, {ok, skip, err} ->
|
||||
n = length(status_cycle)
|
||||
# Deterministic rotation so re-runs hit the same repos
|
||||
offset = :erlang.phash2(title, length(repos))
|
||||
|
||||
selected =
|
||||
for i <- 0..(n - 1) do
|
||||
Enum.at(repos, rem(offset + i, length(repos)))
|
||||
end
|
||||
|
||||
Enum.reduce(Enum.with_index(selected), {ok, skip, err}, fn {repo, i}, {ok, skip, err} ->
|
||||
status = Enum.at(status_cycle, i)
|
||||
path = "lib/seed/infestation_#{:erlang.phash2(title, 10_000)}/mod_#{i}.ex"
|
||||
run_id = "epi-seed-v2-#{:erlang.phash2({title, repo.id, i}, 1_000_000_000)}"
|
||||
|
||||
findings_json =
|
||||
Jason.encode!(%{
|
||||
"tarakan_scan_format" => 1,
|
||||
"findings" => [
|
||||
%{
|
||||
"file" => path,
|
||||
"line_start" => 10 + i * 3,
|
||||
"line_end" => 14 + i * 3,
|
||||
"severity" => severity,
|
||||
"title" => title,
|
||||
"description" =>
|
||||
"Demo infestation seed: #{title} observed in #{repo.owner}/#{repo.name}."
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
attrs = %{
|
||||
"repository_id" => repo.id,
|
||||
"submitted_by_id" => account.id,
|
||||
"commit_sha" => sha.(i, repo.id, title),
|
||||
"model" => "seed-agent",
|
||||
"prompt_version" => "infestation-demo/v2",
|
||||
"run_id" => run_id,
|
||||
"provenance" => "agent",
|
||||
"review_kind" => "code_review",
|
||||
"findings_json" => findings_json,
|
||||
"visibility" => "public"
|
||||
}
|
||||
|
||||
case Scans.stage_review_insert(attrs) do
|
||||
{:ok, scan} ->
|
||||
scan = Repo.preload(scan, findings: :canonical_finding)
|
||||
|
||||
Enum.each(scan.findings, fn f ->
|
||||
if f.canonical_finding do
|
||||
f.canonical_finding
|
||||
|> Ecto.Changeset.change(%{status: status})
|
||||
|> Repo.update!()
|
||||
end
|
||||
end)
|
||||
|
||||
Scans.recalculate_repository_metrics(repo.id)
|
||||
{ok + 1, skip, err}
|
||||
|
||||
{:error, %Ecto.Changeset{} = cs} ->
|
||||
if Keyword.has_key?(cs.errors, :run_id) do
|
||||
{ok, skip + 1, err}
|
||||
else
|
||||
Mix.shell().error("FAIL #{title} @ #{repo.owner}/#{repo.name}: #{inspect(cs.errors)}")
|
||||
{ok, skip, err + 1}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Mix.shell().error("FAIL #{title} @ #{repo.owner}/#{repo.name}: #{inspect(reason)}")
|
||||
{ok, skip, err + 1}
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
# Project rollups so atlas reads (read_from_rollup) see seeded data immediately.
|
||||
Mix.shell().info("Refreshing infestation rollups…")
|
||||
Tarakan.Infestations.Backfill.run_sync!()
|
||||
|
||||
eps = Infestations.list_infestations(min_repos: 2, days: 365, limit: 50)
|
||||
|
||||
Mix.shell().info(
|
||||
"Infestation seed: #{ok_count} inserted, #{skip_count} skipped, #{err_count} failed. " <>
|
||||
"#{length(eps)} multi-repo patterns visible."
|
||||
)
|
||||
|
||||
Enum.each(eps, fn e ->
|
||||
Mix.shell().info(
|
||||
" #{e.repo_count} repos · open=#{e.open_count} ver=#{e.verified_count} fix=#{e.fixed_count} · #{e.title}"
|
||||
)
|
||||
end)
|
||||
14
priv/repo/seeds.exs
Normal file
14
priv/repo/seeds.exs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Script for populating the database. You can run it as:
|
||||
#
|
||||
# mix run priv/repo/seeds.exs
|
||||
#
|
||||
# Inside the script, you can read and write to any of your
|
||||
# repositories directly:
|
||||
#
|
||||
# Tarakan.Repo.insert!(%Tarakan.SomeSchema{})
|
||||
#
|
||||
# We recommend using the bang functions (`insert!`, `update!`
|
||||
# and so on) as they will fail if something goes wrong.
|
||||
|
||||
# Infestation demo patterns (needs listed repos + an active account already present).
|
||||
Code.require_file("seed_infestations.exs", __DIR__)
|
||||
Loading…
Add table
Add a link
Reference in a new issue