Files

926 lines
35 KiB
PL/PgSQL

CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-----------------------
-- FILES
-----------------------
CREATE TABLE IF NOT EXISTS files (
object_id UUID PRIMARY KEY,
agent_id VARCHAR(255),
source VARCHAR(1000),
project VARCHAR(255),
timestamp TIMESTAMP WITH TIME ZONE,
expiration TIMESTAMP WITH TIME ZONE,
path TEXT,
originating_object_id UUID,
originating_container_id UUID, -- for large container processing
nesting_level INTEGER,
file_creation_time TIMESTAMP WITH TIME ZONE,
file_access_time TIMESTAMP WITH TIME ZONE,
file_modification_time TIMESTAMP WITH TIME ZONE,
security_info JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-----------------------
-- ENRICHED FILES
-----------------------
CREATE TABLE IF NOT EXISTS files_enriched (
object_id UUID PRIMARY KEY,
agent_id VARCHAR(255),
source VARCHAR(1000),
project VARCHAR(255),
timestamp TIMESTAMP WITH TIME ZONE,
expiration TIMESTAMP WITH TIME ZONE,
path TEXT,
file_name VARCHAR(255),
extension VARCHAR(50),
size BIGINT,
magic_type TEXT,
mime_type TEXT,
is_plaintext BOOLEAN,
is_container BOOLEAN,
originating_object_id UUID,
originating_container_id UUID, -- for large container processing
nesting_level INTEGER,
file_creation_time TIMESTAMP WITH TIME ZONE,
file_access_time TIMESTAMP WITH TIME ZONE,
file_modification_time TIMESTAMP WITH TIME ZONE,
security_info JSONB,
hashes JSONB,
file_tags TEXT[] DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS enrichments (
enrichment_id BIGSERIAL PRIMARY KEY,
object_id UUID NOT NULL,
module_name VARCHAR(255) NOT NULL,
result_data JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (object_id) REFERENCES files_enriched(object_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS transforms (
transform_id BIGSERIAL PRIMARY KEY,
object_id UUID NOT NULL,
type VARCHAR(255) NOT NULL,
transform_object_id UUID NOT NULL,
metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (object_id) REFERENCES files_enriched(object_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS file_tags (
tag_name VARCHAR(255) PRIMARY KEY
);
-- findings are a bit further down
CREATE TABLE IF NOT EXISTS files_view_history (
id BIGSERIAL PRIMARY KEY,
object_id UUID NOT NULL,
username VARCHAR(255) NOT NULL,
automated BOOLEAN,
timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS files_feedback (
object_id UUID PRIMARY KEY,
username VARCHAR(255), -- last person who left the feedback
automated BOOLEAN,
missing_parser BOOLEAN,
missing_file_viewer BOOLEAN,
sensitive_info_not_detected BOOLEAN,
comments TEXT,
alert_sent BOOLEAN DEFAULT false,
timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (object_id) REFERENCES files_enriched(object_id) ON DELETE CASCADE
);
-----------------------
-- Extracted File Features
-----------------------
CREATE TABLE IF NOT EXISTS files_enriched_dataset (
object_id UUID PRIMARY KEY,
agent_id VARCHAR(255),
source VARCHAR(1000),
project VARCHAR(255),
timestamp TIMESTAMP WITH TIME ZONE,
expiration TIMESTAMP WITH TIME ZONE,
path TEXT,
file_creation_time TIMESTAMP WITH TIME ZONE,
file_access_time TIMESTAMP WITH TIME ZONE,
file_modification_time TIMESTAMP WITH TIME ZONE,
features_version VARCHAR(255),
individual_features JSONB, -- individual features
sibling_features JSONB, -- features based on files in the same folder (and same agent)
agent_population_features JSONB, -- population features segmented per agent_id
global_population_features JSONB, -- global population features
labels JSONB,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Create a GiST index for path matching
CREATE INDEX idx_files_enriched_dataset_path_trgm ON files_enriched_dataset USING gist (path gist_trgm_ops);
-- Create indexes for finding siblings (composite index with agent_id and path)
CREATE INDEX idx_files_enriched_dataset_siblings ON files_enriched_dataset(agent_id, path);
-- helper for pulling out 'sibling' files
CREATE OR REPLACE FUNCTION get_parent_path(path text)
RETURNS text AS $$
BEGIN
-- Handle both Windows and Unix paths
RETURN regexp_replace(
path,
'[\\/][^\\/]*$',
''
);
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- Then you can find siblings with queries like:
-- SELECT * FROM files_enriched_dataset
-- WHERE agent_id = $1
-- AND get_parent_path(path) = get_parent_path($2);
-----------------------
-- FINDINGS
-----------------------
CREATE TABLE IF NOT EXISTS findings (
finding_id BIGSERIAL PRIMARY KEY,
finding_name VARCHAR(255) NOT NULL,
category VARCHAR(255) NOT NULL,
severity INTEGER NOT NULL,
object_id UUID NOT NULL,
origin_type VARCHAR(255) NOT NULL,
origin_name VARCHAR(255) NOT NULL,
raw_data JSONB NOT NULL,
data JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
triage_id BIGINT
);
CREATE TABLE IF NOT EXISTS findings_triage_history (
id BIGSERIAL PRIMARY KEY,
finding_id BIGINT NOT NULL,
username VARCHAR(255) NOT NULL,
automated BOOLEAN,
value VARCHAR(255) NOT NULL,
explanation VARCHAR(5000),
confidence REAL,
true_positive_context VARCHAR(5000),
timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Add foreign key constraints if they don't exist
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_files_view_history_object_id') THEN
ALTER TABLE files_view_history
ADD CONSTRAINT fk_files_view_history_object_id
FOREIGN KEY (object_id)
REFERENCES files_enriched(object_id)
ON DELETE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_findings_triage_id') THEN
ALTER TABLE findings
ADD CONSTRAINT fk_findings_triage_id
FOREIGN KEY (triage_id)
REFERENCES findings_triage_history(id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_findings_object_id') THEN
ALTER TABLE findings
ADD CONSTRAINT fk_findings_object_id
FOREIGN KEY (object_id)
REFERENCES files_enriched(object_id)
ON DELETE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_findings_triage_history_finding_id') THEN
ALTER TABLE findings_triage_history
ADD CONSTRAINT fk_findings_triage_history_finding_id
FOREIGN KEY (finding_id)
REFERENCES findings(finding_id)
ON DELETE CASCADE;
END IF;
END $$;
-- Create indexes if they don't exist
CREATE INDEX IF NOT EXISTS idx_files_enriched_agent_id ON files_enriched(agent_id);
CREATE INDEX IF NOT EXISTS idx_files_enriched_hashes ON files_enriched USING GIN (hashes);
CREATE INDEX IF NOT EXISTS idx_files_enriched_path_trgm ON files_enriched USING gist (path gist_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_findings_data_gin ON findings USING GIN (data);
CREATE INDEX IF NOT EXISTS idx_files_view_history_username ON files_view_history(username);
CREATE INDEX IF NOT EXISTS idx_files_view_history_composite ON files_view_history(object_id, username, timestamp);
CREATE INDEX IF NOT EXISTS idx_files_enriched_dataset_agent_id ON files_enriched_dataset(agent_id);
-- Performance indexes for findings and workflow tracking
-- Used by GraphQL subscription ordering (FindingsList.jsx: order_by created_at desc)
CREATE INDEX IF NOT EXISTS idx_findings_created_at ON findings(created_at DESC);
-- Used by publish_findings.py for alert generation queries
CREATE INDEX IF NOT EXISTS idx_findings_object_id ON findings(object_id);
-- Composite index for finding triage history joins (finding_triage_histories with order_by timestamp)
CREATE INDEX IF NOT EXISTS idx_findings_triage_history_composite ON findings_triage_history(finding_id, timestamp DESC);
-----------------------
-- PLAINTEXT CONTENT
-----------------------
CREATE TABLE IF NOT EXISTS plaintext_content (
object_id UUID REFERENCES files_enriched(object_id) ON DELETE CASCADE,
chunk_number INTEGER,
content TEXT,
-- Use simple configuration to preserve all terms without stemming
content_vector tsvector GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (object_id, chunk_number)
);
-- Create GIN index for full-text search
CREATE INDEX IF NOT EXISTS idx_plaintext_content_vector ON plaintext_content USING GIN (content_vector);
--- CREATE INDEX IF NOT EXISTS idx_plaintext_content_vector ON plaintext_content USING GIN (content_vector gin_trgm_ops); --- trigram-based searches, which can be useful for partial-word matching?
-----------------------
-- SEARCH VIEW
-----------------------
-- Drop any existing function/view/type
DROP FUNCTION IF EXISTS public.search_documents CASCADE;
DROP TYPE IF EXISTS public.search_result CASCADE;
DROP VIEW IF EXISTS public.document_search_results CASCADE;
-- Create the view
CREATE OR REPLACE VIEW public.document_search_results AS
SELECT
pc.object_id,
pc.chunk_number,
pc.content,
pc.content_vector,
ef.file_name,
ef.path,
ef.extension,
ef.project,
ef.agent_id,
ef.source,
ef."timestamp"::timestamp with time zone
FROM plaintext_content pc
JOIN files_enriched ef ON pc.object_id = ef.object_id;
-- Create a simple search function that uses the view
CREATE OR REPLACE FUNCTION public.search_documents(
search_query text,
path_pattern text DEFAULT NULL,
agent_pattern text DEFAULT NULL,
project_name text DEFAULT NULL,
start_date timestamp with time zone DEFAULT NULL,
end_date timestamp with time zone DEFAULT NULL,
max_results integer DEFAULT 100,
source_pattern text DEFAULT NULL
) RETURNS SETOF document_search_results
STABLE
LANGUAGE sql
AS $$
WITH ranked_chunks AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY object_id
ORDER BY chunk_number ASC
) as rn
FROM document_search_results
WHERE
content_vector @@ plainto_tsquery('simple', search_query)
AND (path_pattern IS NULL OR path LIKE path_pattern)
AND (agent_pattern IS NULL OR agent_id LIKE agent_pattern)
AND (project_name IS NULL OR project = project_name)
AND (start_date IS NULL OR "timestamp" >= start_date)
AND (end_date IS NULL OR "timestamp" <= end_date)
AND (source_pattern IS NULL OR source IS NULL OR source LIKE source_pattern)
)
SELECT
object_id,
chunk_number,
content,
content_vector,
file_name,
path,
extension,
project,
agent_id,
source,
"timestamp"
FROM ranked_chunks
WHERE rn = 1
ORDER BY "timestamp" DESC
LIMIT max_results;
$$;
-- compression
ALTER TABLE plaintext_content
ALTER COLUMN content SET STORAGE EXTENDED;
-- Add work_mem adjustment for text search operations
SET work_mem = '256MB';
-----------------------
-- Yara
-----------------------
CREATE TABLE IF NOT EXISTS yara_rules (
name VARCHAR(255) NOT NULL PRIMARY KEY,
content TEXT NOT NULL,
source VARCHAR(255),
enabled BOOLEAN NOT NULL DEFAULT true,
alert_enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-----------------------
-- Agent Prompts
-----------------------
CREATE TABLE IF NOT EXISTS agent_prompts (
name VARCHAR(255) NOT NULL PRIMARY KEY,
description TEXT,
prompt TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-----------------------
-- Alert Settings
-----------------------
CREATE TABLE IF NOT EXISTS alert_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
alerting_enabled BOOLEAN NOT NULL DEFAULT true,
minimum_severity INTEGER NOT NULL DEFAULT 4 CHECK (minimum_severity >= 0 AND minimum_severity <= 10),
category_excluded TEXT[] DEFAULT '{}',
category_included TEXT[] DEFAULT '{}',
file_path_excluded_regex TEXT[] DEFAULT '{}',
file_path_included_regex TEXT[] DEFAULT '{}',
llm_triage_values_to_alert TEXT[] DEFAULT '{"true_positive"}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT single_row_check CHECK (id = 1)
);
-----------------------
-- FILE LINKINGS
-----------------------
CREATE TABLE IF NOT EXISTS file_linkings (
linking_id BIGSERIAL PRIMARY KEY,
source VARCHAR(1000) NOT NULL,
file_path_1 TEXT NOT NULL,
file_path_2 TEXT NOT NULL,
link_type VARCHAR(255), -- Optional: to specify the type of relationship
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(source, file_path_1, file_path_2)
);
-- Create indexes for efficient lookups in both directions
CREATE INDEX IF NOT EXISTS idx_file_linkings_file_1 ON file_linkings(file_path_1);
CREATE INDEX IF NOT EXISTS idx_file_linkings_file_2 ON file_linkings(file_path_2);
CREATE INDEX IF NOT EXISTS idx_file_linkings_source ON file_linkings(source);
-----------------------
-- FILE LISTINGS
-----------------------
CREATE TABLE IF NOT EXISTS file_listings (
listing_id BIGSERIAL PRIMARY KEY,
source VARCHAR(1000) NOT NULL,
path TEXT NOT NULL,
object_id UUID,
status VARCHAR(50) NOT NULL CHECK (status IN ('needs_to_be_collected', 'not_exists', 'collected', 'not_wanted')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
path_lower TEXT GENERATED ALWAYS AS (LOWER(path)) STORED,
UNIQUE(source, path_lower),
FOREIGN KEY (object_id) REFERENCES files_enriched(object_id) ON DELETE CASCADE
);
-- Create indexes for efficient queries
CREATE INDEX IF NOT EXISTS idx_file_listings_source ON file_listings(source);
CREATE INDEX IF NOT EXISTS idx_file_listings_status ON file_listings(status);
CREATE INDEX IF NOT EXISTS idx_file_listings_object_id ON file_listings(object_id);
-- Create composite index for efficient path prefix queries
CREATE INDEX IF NOT EXISTS idx_file_listings_source_path ON file_listings(source, path);
-- Create trigram index for path pattern matching
CREATE INDEX IF NOT EXISTS idx_file_listings_path_trgm ON file_listings USING gist (path gist_trgm_ops);
-- Helper functions for file browser hierarchical navigation
CREATE OR REPLACE FUNCTION get_path_depth(file_path text)
RETURNS integer AS $$
BEGIN
-- Count forward slashes to determine depth
-- Root files (no slash) are depth 0, /folder/file is depth 1, etc.
IF file_path = '' OR file_path IS NULL THEN
RETURN 0;
END IF;
RETURN array_length(string_to_array(file_path, '/'), 1) - 1;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
CREATE OR REPLACE FUNCTION get_path_parent(file_path text)
RETURNS text AS $$
BEGIN
-- Return parent path, handling edge cases
IF file_path IS NULL OR file_path = '' OR position('/' in file_path) = 0 THEN
RETURN '';
END IF;
RETURN regexp_replace(file_path, '/[^/]*$', '');
END;
$$ LANGUAGE plpgsql IMMUTABLE;
CREATE OR REPLACE FUNCTION get_path_filename(file_path text)
RETURNS text AS $$
BEGIN
-- Extract just the filename/folder name from full path
IF file_path IS NULL OR file_path = '' THEN
RETURN '';
END IF;
RETURN regexp_replace(file_path, '^.*/', '');
END;
$$ LANGUAGE plpgsql IMMUTABLE;
CREATE OR REPLACE FUNCTION is_file_path(file_path text)
RETURNS boolean AS $$
BEGIN
-- Simple heuristic: if path has an extension, it's likely a file
-- This isn't perfect but works for most cases
IF file_path IS NULL OR file_path = '' THEN
RETURN false;
END IF;
-- Check if the last part after the final slash contains a dot
RETURN get_path_filename(file_path) LIKE '%.%';
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- View for hierarchical file browser queries
-- This creates virtual folder entries for efficient navigation
CREATE OR REPLACE VIEW file_listings_hierarchy AS
WITH RECURSIVE folder_paths AS (
-- Get all unique folder paths from file paths
SELECT DISTINCT
source,
get_path_parent(path) as folder_path,
get_path_depth(get_path_parent(path)) as depth
FROM file_listings
WHERE get_path_parent(path) != ''
UNION
-- Add parent folders recursively
SELECT
source,
get_path_parent(folder_path) as folder_path,
get_path_depth(get_path_parent(folder_path)) as depth
FROM folder_paths
WHERE get_path_parent(folder_path) != '' AND get_path_parent(folder_path) != folder_path
)
SELECT
source,
folder_path as path,
'folder' as item_type,
null::uuid as object_id,
'folder' as status,
depth,
get_path_parent(folder_path) as parent_path,
get_path_filename(folder_path) as name
FROM folder_paths
WHERE folder_path != ''
UNION ALL
SELECT
source,
path,
'file' as item_type,
object_id,
status,
get_path_depth(path) as depth,
get_path_parent(path) as parent_path,
get_path_filename(path) as name
FROM file_listings;
-----------------------
-- CREATE UPDATE FIELD TRIGGER
-----------------------
-- Updates the "updated_at" value for tables we update
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
IF row(NEW.*) IS DISTINCT FROM row(OLD.*) THEN
NEW.updated_at = CURRENT_TIMESTAMP;
END IF;
RETURN NEW;
END;
$$ language 'plpgsql';
-- Create triggers for each table
CREATE OR REPLACE TRIGGER update_files_updated_at
BEFORE UPDATE ON files
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE OR REPLACE TRIGGER update_files_enriched_updated_at
BEFORE UPDATE ON files_enriched
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE OR REPLACE TRIGGER update_yara_rules_updated_at
BEFORE UPDATE ON yara_rules
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE OR REPLACE TRIGGER update_agent_prompts_updated_at
BEFORE UPDATE ON agent_prompts
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE OR REPLACE TRIGGER update_alert_settings_updated_at
BEFORE UPDATE ON alert_settings
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE OR REPLACE TRIGGER update_enrichments_updated_at
BEFORE UPDATE ON enrichments
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE OR REPLACE TRIGGER update_enrichments_updated_at
BEFORE UPDATE ON transforms
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE OR REPLACE TRIGGER update_findings_updated_at
BEFORE UPDATE ON findings
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE OR REPLACE TRIGGER update_files_enriched_dataset_updated_at
BEFORE UPDATE ON files_enriched_dataset
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE OR REPLACE TRIGGER update_file_linkings_updated_at
BEFORE UPDATE ON file_linkings
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE OR REPLACE TRIGGER update_file_listings_updated_at
BEFORE UPDATE ON file_listings
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-----------------------
-- Workflow tracking
-----------------------
CREATE TABLE IF NOT EXISTS workflows (
wf_id VARCHAR(255) NOT NULL PRIMARY KEY,
object_id UUID,
filename VARCHAR(255),
workflow_type VARCHAR(50) DEFAULT 'file_enrichment',
enrichments_success TEXT[] DEFAULT '{}',
enrichments_failure TEXT[] DEFAULT '{}',
enrichments_skipped TEXT[] DEFAULT '{}',
status TEXT NOT NULL CHECK (status IN ('SCHEDULED', 'RUNNING', 'COMPLETED', 'FAILED', 'TIMEOUT')),
runtime_seconds REAL,
start_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_purged BOOLEAN NOT NULL DEFAULT false
);
-- Used by subscription handlers (noseyparker, dotnet) for workflow updates by object_id
-- Partial index: only indexes active workflows to reduce size and write overhead
CREATE INDEX IF NOT EXISTS idx_workflows_object_id_active ON workflows(object_id) WHERE status IN ('SCHEDULED', 'RUNNING');
-- Index for workflow purger queries (workflow_purger.py)
-- Optimizes queries that find non-scheduled workflows needing purge verification
-- Partial index: only indexes non-scheduled workflows to minimize size and write overhead
CREATE INDEX IF NOT EXISTS idx_workflows_purge_candidates ON workflows(status, is_purged, start_time) WHERE status != 'SCHEDULED';
-----------------------
-- DAPR State Store
-----------------------
-- Dapr state store table for workflow state management
-- This table experiences high churn with frequent inserts and deletes
CREATE TABLE IF NOT EXISTS dapr_state (
key TEXT PRIMARY KEY,
value BYTEA NOT NULL,
etag UUID NOT NULL DEFAULT gen_random_uuid(),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE,
expires_at TIMESTAMP WITH TIME ZONE
);
-- Index for expiration-based queries
CREATE INDEX IF NOT EXISTS dapr_state_expires_at_idx ON dapr_state(expires_at);
-- Dapr metadata table
CREATE TABLE IF NOT EXISTS dapr_metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Configure aggressive autovacuum for dapr_state due to high insert/delete churn
-- This prevents table bloat during bursty workloads
ALTER TABLE dapr_state SET (
autovacuum_vacuum_scale_factor = 0.02, -- Vacuum when 2% of rows change (default: 20%)
autovacuum_vacuum_cost_delay = 2, -- Aggressive vacuuming with minimal delay
autovacuum_vacuum_cost_limit = 2000, -- Allow more vacuum work per round (default: 200)
autovacuum_analyze_scale_factor = 0.05 -- Update statistics when 5% of rows change
);
-----------------------
-- Container Processing Tracking
-----------------------
CREATE TABLE IF NOT EXISTS container_processing (
container_id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
container_type VARCHAR(50) NOT NULL,
original_filename VARCHAR(255),
original_size BIGINT,
agent_id VARCHAR(255),
source VARCHAR(1000),
project VARCHAR(255),
status VARCHAR(50) NOT NULL DEFAULT 'submitted',
total_files_extracted INTEGER DEFAULT 0,
total_bytes_extracted BIGINT DEFAULT 0,
submitted_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
expiration TIMESTAMP WITH TIME ZONE,
processing_started_at TIMESTAMP WITH TIME ZONE,
processing_completed_at TIMESTAMP WITH TIME ZONE,
error_message TEXT,
workflows_completed INTEGER DEFAULT 0,
workflows_failed INTEGER DEFAULT 0,
workflows_total INTEGER DEFAULT 0,
total_bytes_processed BIGINT DEFAULT 0
);
-- Create phoenix database
CREATE DATABASE phoenix;
-----------------------
-- Chromium schema/tables
-----------------------
CREATE SCHEMA chromium;
-- "urls" table in "History" file
CREATE TABLE IF NOT EXISTS chromium.history (
id SERIAL PRIMARY KEY,
originating_object_id UUID,
agent_id VARCHAR(255),
source VARCHAR(1000),
project VARCHAR(255),
username TEXT, -- username extracted from user data directory, if applicable
browser TEXT, -- browser name extracted from user data directory, if applicable
url TEXT, -- extracted from the Chromium DB
title TEXT, -- extracted from the Chromium DB
visit_count INTEGER, -- extracted from the Chromium DB
last_visit_time TIMESTAMP WITH TIME ZONE, -- extracted from the Chromium DB
FOREIGN KEY (originating_object_id) REFERENCES files_enriched(object_id) ON DELETE CASCADE,
UNIQUE (source, username, browser, url, title, last_visit_time)
);
-- "downloads" table in "History" file
CREATE TABLE IF NOT EXISTS chromium.downloads (
id SERIAL PRIMARY KEY,
originating_object_id UUID,
agent_id VARCHAR(255),
source VARCHAR(1000),
project VARCHAR(255),
username TEXT, -- username extracted from user data directory, if applicable
browser TEXT, -- browser name extracted from user data directory, if applicable
url TEXT, -- extracted from the Chromium DB
download_path TEXT, -- extracted from the Chromium DB `target_path` field
start_time TIMESTAMP WITH TIME ZONE, -- extracted from the Chromium DB
end_time TIMESTAMP WITH TIME ZONE, -- extracted from the Chromium DB
total_bytes INTEGER, -- extracted from the Chromium DB
FOREIGN KEY (originating_object_id) REFERENCES files_enriched(object_id) ON DELETE CASCADE,
UNIQUE (source, username, browser, url, download_path, start_time)
);
-- extracted from a Chromium "Local State" file
CREATE TABLE chromium.state_keys (
id SERIAL PRIMARY KEY,
originating_object_id UUID,
agent_id VARCHAR(255),
source VARCHAR(1000),
project VARCHAR(255),
username TEXT, -- username extracted from user data directory, if applicable
browser TEXT, -- browser name extracted from user data directory, if applicable
key_masterkey_guid UUID, -- associated masterkey GUID for key_bytes_enc
key_bytes_enc BYTEA, -- os_crypt.encrypted_key in Chromium `Local State` file (pre v127)
key_bytes_dec BYTEA,
key_is_decrypted BOOLEAN,
app_bound_key_enc BYTEA, -- os_crypt.app_bound_encrypted_key in Chromium `Local State` file (post v127)
app_bound_key_system_masterkey_guid UUID, -- associated _system_ masterkey GUID for key_bytes_enc
app_bound_key_system_dec BYTEA, -- intermediate dec value after the SYSTEM key has been used
app_bound_key_user_masterkey_guid UUID, -- associated _user_ masterkey GUID for app_bound_key_system_dec
app_bound_key_user_dec BYTEA, -- intermediate dec value after the USER key has been used (before chromekey for v3)
app_bound_key_dec BYTEA, -- completely dec value
app_bound_key_is_decrypted BOOLEAN,
FOREIGN KEY (originating_object_id) REFERENCES files_enriched(object_id) ON DELETE CASCADE,
UNIQUE (source, username, browser)
);
-- AES keys extracted from a CNG "Google Chromekey1" CNG file from C:\ProgramData\Microsoft\Crypto\SystemKeys\
-- Used in v3 of the Chromium ABE decryption
CREATE TABLE chromium.chrome_keys (
id SERIAL PRIMARY KEY,
originating_object_id UUID,
agent_id VARCHAR(255),
source VARCHAR(1000), -- should only be one key per host/source
project VARCHAR(255),
key_masterkey_guid UUID, -- associated _system_ masterkey GUID for key_bytes_enc
key_bytes_enc BYTEA, -- the raw DPAPI blob bytes from the CNG file
key_bytes_dec BYTEA, -- completely dec AES key value
key_is_decrypted BOOLEAN,
FOREIGN KEY (originating_object_id) REFERENCES files_enriched(object_id) ON DELETE CASCADE,
UNIQUE (key_masterkey_guid)
);
-- Create indexes for masterkey GUID lookups on state_keys
CREATE INDEX IF NOT EXISTS idx_state_keys_key_masterkey_guid ON chromium.state_keys(key_masterkey_guid) WHERE key_is_decrypted = FALSE;
CREATE INDEX IF NOT EXISTS idx_state_keys_app_bound_system_mk_guid ON chromium.state_keys(app_bound_key_system_masterkey_guid) WHERE length(app_bound_key_system_dec) = 0;
CREATE INDEX IF NOT EXISTS idx_state_keys_app_bound_user_mk_guid ON chromium.state_keys(app_bound_key_user_masterkey_guid) WHERE length(app_bound_key_user_dec) = 0;
-- "logins" table in "Login Data" file
CREATE TABLE IF NOT EXISTS chromium.logins (
id SERIAL PRIMARY KEY,
originating_object_id UUID,
agent_id VARCHAR(255),
source VARCHAR(1000),
project VARCHAR(255),
username TEXT, -- username extracted from user data directory, if applicable
browser TEXT, -- browser name extracted from user data directory, if applicable
origin_url TEXT, -- extracted from the Chromium DB
username_value TEXT, -- extracted from the Chromium DB
signon_realm TEXT, -- extracted from the Chromium DB
date_created TIMESTAMP WITH TIME ZONE, -- extracted from the Chromium DB
date_last_used TIMESTAMP WITH TIME ZONE, -- extracted from the Chromium DB
date_password_modified TIMESTAMP WITH TIME ZONE, -- extracted from the Chromium DB
times_used INTEGER, -- extracted from the Chromium DB
encryption_type TEXT, -- carved from the `password_value_enc` bytes - dpapi, key, or abe (app-bound-encryption)
masterkey_guid UUID, -- if encryption_type == dpapi, associated masterkey GUID
state_key_id INTEGER, -- if encryption_type != dpapi, linked to "id" in `chromium.state_keys`
is_decrypted BOOLEAN,
password_value_enc BYTEA, -- extracted from the Chromium DB `password_value` field
password_value_dec TEXT,
FOREIGN KEY (originating_object_id) REFERENCES files_enriched(object_id) ON DELETE CASCADE,
FOREIGN KEY (state_key_id) REFERENCES chromium.state_keys(id) ON DELETE SET NULL,
UNIQUE (source, username, browser, origin_url, username_value)
);
-- "cookies" table in "Cookies" file
CREATE TABLE IF NOT EXISTS chromium.cookies (
id SERIAL PRIMARY KEY,
originating_object_id UUID,
agent_id VARCHAR(255),
source VARCHAR(1000),
project VARCHAR(255),
username TEXT, -- username extracted from user data directory, if applicable
browser TEXT, -- browser name extracted from user data directory, if applicable
host_key TEXT, -- extracted from the Chromium DB
name TEXT, -- extracted from the Chromium DB
path TEXT, -- extracted from the Chromium DB
creation_utc TIMESTAMP WITH TIME ZONE, -- extracted from the Chromium DB
expires_utc TIMESTAMP WITH TIME ZONE, -- extracted from the Chromium DB
last_access_utc TIMESTAMP WITH TIME ZONE, -- extracted from the Chromium DB
last_update_utc TIMESTAMP WITH TIME ZONE, -- extracted from the Chromium DB
is_secure BOOLEAN, -- extracted from the Chromium DB
is_httponly BOOLEAN, -- extracted from the Chromium DB
is_persistent BOOLEAN, -- extracted from the Chromium DB
samesite TEXT, -- extracted from the Chromium DB, translated from int
source_port INTEGER, -- extracted from the Chromium DB
encryption_type TEXT, -- carved from the `encrypted_value` field - dpapi, key, or abe (app-bound-encryption)
masterkey_guid UUID, -- if encryption_type == dpapi, associated masterkey GUID
state_key_id INTEGER, -- if encryption_type != dpapi, linked to "id" in `chromium.state_keys`
is_decrypted BOOLEAN,
value_enc BYTEA, -- extracted from the Chromium DB `encrypted_value` field
value_dec TEXT,
FOREIGN KEY (originating_object_id) REFERENCES files_enriched(object_id) ON DELETE CASCADE,
FOREIGN KEY (state_key_id) REFERENCES chromium.state_keys(id) ON DELETE SET NULL,
UNIQUE (source, username, browser, host_key, name, path)
);
-- DPAPI tables
CREATE SCHEMA dpapi;
CREATE TABLE IF NOT EXISTS dpapi.masterkeys (
id SERIAL PRIMARY KEY,
guid TEXT UNIQUE NOT NULL,
encrypted_key_usercred BYTEA,
encrypted_key_backup BYTEA,
plaintext_key BYTEA,
plaintext_key_sha1 BYTEA,
backup_key_guid TEXT,
masterkey_type TEXT DEFAULT 'unknown',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS dpapi.domain_backup_keys (
id SERIAL PRIMARY KEY,
guid TEXT UNIQUE NOT NULL,
key_data BYTEA NOT NULL,
domain_controller TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS dpapi.system_credentials (
id SERIAL PRIMARY KEY,
user_key BYTEA NOT NULL,
machine_key BYTEA NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_key, machine_key)
);
-- Create triggers for DPAPI tables
CREATE OR REPLACE TRIGGER update_dpapi_masterkeys_updated_at
BEFORE UPDATE ON dpapi.masterkeys
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE OR REPLACE TRIGGER update_dpapi_domain_backup_keys_updated_at
BEFORE UPDATE ON dpapi.domain_backup_keys
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE OR REPLACE TRIGGER update_dpapi_system_credentials_updated_at
BEFORE UPDATE ON dpapi.system_credentials
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-----------------------
-- CHATBOT READ-ONLY USER
-----------------------
-- Create read-only user for chatbot queries with restricted table access
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'chatbot_readonly') THEN
CREATE USER chatbot_readonly WITH PASSWORD 'chatbot_pass_change_me';
END IF;
END
$$;
-- Grant connection and schema usage
GRANT CONNECT ON DATABASE enrichment TO chatbot_readonly;
GRANT USAGE ON SCHEMA public TO chatbot_readonly;
GRANT USAGE ON SCHEMA chromium TO chatbot_readonly;
-- Grant SELECT on specific tables only (chatbot-accessible tables)
GRANT SELECT ON files_enriched TO chatbot_readonly;
GRANT SELECT ON enrichments TO chatbot_readonly;
GRANT SELECT ON findings TO chatbot_readonly;
GRANT SELECT ON file_linkings TO chatbot_readonly;
GRANT SELECT ON chromium.cookies TO chatbot_readonly;
GRANT SELECT ON chromium.logins TO chatbot_readonly;
-- Explicitly revoke write permissions to ensure read-only access
REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON ALL TABLES IN SCHEMA public FROM chatbot_readonly;
REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON ALL TABLES IN SCHEMA chromium FROM chatbot_readonly;
REVOKE CREATE ON SCHEMA public FROM chatbot_readonly;
REVOKE CREATE ON SCHEMA chromium FROM chatbot_readonly;