diff --git a/src/app/api/internal/security-analysis-callback/[findingId]/route.ts b/src/app/api/internal/security-analysis-callback/[findingId]/route.ts index 196e31cfd..4056958f6 100644 --- a/src/app/api/internal/security-analysis-callback/[findingId]/route.ts +++ b/src/app/api/internal/security-analysis-callback/[findingId]/route.ts @@ -28,6 +28,10 @@ import { kilocode_users } from '@/db/schema'; import { eq } from 'drizzle-orm'; import { sentryLogger } from '@/lib/utils.server'; import type { SecurityFindingAnalysis, SecurityReviewOwner } from '@/lib/security-agent/core/types'; +import { + logSecurityAudit, + SecurityAuditLogAction, +} from '@/lib/security-agent/services/audit-log-service'; const log = sentryLogger('security-agent:callback', 'info'); const warn = sentryLogger('security-agent:callback', 'warning'); @@ -253,6 +257,17 @@ async function handleAnalysisCompleted( const authToken = generateApiToken(user); + logSecurityAudit({ + owner, + actor_id: null, + actor_email: null, + actor_name: null, + action: SecurityAuditLogAction.FindingAnalysisCompleted, + resource_type: 'security_finding', + resource_id: findingId, + metadata: { source: 'system', model, correlationId, triggeredByUserId }, + }); + await finalizeAnalysis( findingId, rawMarkdown, diff --git a/src/db/migrations/0027_unknown_shiva.sql b/src/db/migrations/0027_unknown_shiva.sql new file mode 100644 index 000000000..86e105fa2 --- /dev/null +++ b/src/db/migrations/0027_unknown_shiva.sql @@ -0,0 +1,25 @@ +CREATE TABLE "security_audit_log" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "owned_by_organization_id" uuid, + "owned_by_user_id" text, + "actor_id" text, + "actor_email" text, + "actor_name" text, + "action" text NOT NULL, + "resource_type" text NOT NULL, + "resource_id" text NOT NULL, + "before_state" jsonb, + "after_state" jsonb, + "metadata" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "security_audit_log_owner_check" CHECK (("security_audit_log"."owned_by_user_id" IS NOT NULL AND "security_audit_log"."owned_by_organization_id" IS NULL) OR ("security_audit_log"."owned_by_user_id" IS NULL AND "security_audit_log"."owned_by_organization_id" IS NOT NULL)), + CONSTRAINT "security_audit_log_action_check" CHECK ("security_audit_log"."action" IN ('security.finding.created', 'security.finding.status_change', 'security.finding.dismissed', 'security.finding.auto_dismissed', 'security.finding.analysis_started', 'security.finding.analysis_completed', 'security.finding.deleted', 'security.config.enabled', 'security.config.disabled', 'security.config.updated', 'security.sync.triggered', 'security.sync.completed', 'security.audit_log.exported')) +); +--> statement-breakpoint +ALTER TABLE "security_audit_log" ADD CONSTRAINT "security_audit_log_owned_by_organization_id_organizations_id_fk" FOREIGN KEY ("owned_by_organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "security_audit_log" ADD CONSTRAINT "security_audit_log_owned_by_user_id_kilocode_users_id_fk" FOREIGN KEY ("owned_by_user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "IDX_security_audit_log_org_created" ON "security_audit_log" USING btree ("owned_by_organization_id","created_at");--> statement-breakpoint +CREATE INDEX "IDX_security_audit_log_user_created" ON "security_audit_log" USING btree ("owned_by_user_id","created_at");--> statement-breakpoint +CREATE INDEX "IDX_security_audit_log_resource" ON "security_audit_log" USING btree ("resource_type","resource_id");--> statement-breakpoint +CREATE INDEX "IDX_security_audit_log_actor" ON "security_audit_log" USING btree ("actor_id","created_at");--> statement-breakpoint +CREATE INDEX "IDX_security_audit_log_action" ON "security_audit_log" USING btree ("action","created_at"); \ No newline at end of file diff --git a/src/db/migrations/meta/0021_snapshot.json b/src/db/migrations/meta/0027_snapshot.json similarity index 96% rename from src/db/migrations/meta/0021_snapshot.json rename to src/db/migrations/meta/0027_snapshot.json index 068da1bbe..dd9b6a32f 100644 --- a/src/db/migrations/meta/0021_snapshot.json +++ b/src/db/migrations/meta/0027_snapshot.json @@ -1,6 +1,6 @@ { - "id": "8ad348e9-9c62-4d5a-a0e4-9d660f2a401f", - "prevId": "738259b0-d590-404b-a8ae-842be5ab49cc", + "id": "ef9ddfee-8af7-43fe-a5de-e1bf211acee7", + "prevId": "54c3b34c-3126-4db3-b141-ee663f3e5cdc", "version": "7", "dialect": "postgresql", "tables": { @@ -863,6 +863,13 @@ "type": "text", "primaryKey": false, "notNull": true + }, + "worker_version": { + "name": "worker_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'v1'" } }, "indexes": { @@ -2838,6 +2845,27 @@ "concurrently": false, "method": "btree", "with": {} + }, + "IDX_cli_sessions_v2_user_updated": { + "name": "IDX_cli_sessions_v2_user_updated", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -3025,6 +3053,30 @@ "notNull": false, "default": "'v1'" }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_tokens_in": { + "name": "total_tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_tokens_out": { + "name": "total_tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_cost_musd": { + "name": "total_cost_musd", + "type": "integer", + "primaryKey": false, + "notNull": false + }, "started_at": { "name": "started_at", "type": "timestamp with time zone", @@ -3287,6 +3339,160 @@ }, "isRLSEnabled": false }, + "public.cloud_agent_feedback": { + "name": "cloud_agent_feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_streaming": { + "name": "is_streaming", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "feedback_text": { + "name": "feedback_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recent_messages": { + "name": "recent_messages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_cloud_agent_feedback_created_at": { + "name": "IDX_cloud_agent_feedback_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_feedback_kilo_user_id": { + "name": "IDX_cloud_agent_feedback_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_feedback_cloud_agent_session_id": { + "name": "IDX_cloud_agent_feedback_cloud_agent_session_id", + "columns": [ + { + "expression": "cloud_agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_agent_feedback_kilo_user_id_kilocode_users_id_fk": { + "name": "cloud_agent_feedback_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "cloud_agent_feedback", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "cloud_agent_feedback_organization_id_organizations_id_fk": { + "name": "cloud_agent_feedback_organization_id_organizations_id_fk", + "tableFrom": "cloud_agent_feedback", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.cloud_agent_webhook_triggers": { "name": "cloud_agent_webhook_triggers", "schema": "", @@ -7272,6 +7478,12 @@ "type": "integer", "primaryKey": false, "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false } }, "indexes": { @@ -9541,6 +9753,241 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.security_audit_log": { + "name": "security_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before_state": { + "name": "before_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_state": { + "name": "after_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_security_audit_log_org_created": { + "name": "IDX_security_audit_log_org_created", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_user_created": { + "name": "IDX_security_audit_log_user_created", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_resource": { + "name": "IDX_security_audit_log_resource", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_actor": { + "name": "IDX_security_audit_log_actor", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_action": { + "name": "IDX_security_audit_log_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_audit_log_owned_by_organization_id_organizations_id_fk": { + "name": "security_audit_log_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_audit_log", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_audit_log_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_audit_log_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_audit_log", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_audit_log_owner_check": { + "name": "security_audit_log_owner_check", + "value": "(\"security_audit_log\".\"owned_by_user_id\" IS NOT NULL AND \"security_audit_log\".\"owned_by_organization_id\" IS NULL) OR (\"security_audit_log\".\"owned_by_user_id\" IS NULL AND \"security_audit_log\".\"owned_by_organization_id\" IS NOT NULL)" + }, + "security_audit_log_action_check": { + "name": "security_audit_log_action_check", + "value": "\"security_audit_log\".\"action\" IN ('security.finding.created', 'security.finding.status_change', 'security.finding.dismissed', 'security.finding.auto_dismissed', 'security.finding.analysis_started', 'security.finding.analysis_completed', 'security.finding.deleted', 'security.config.enabled', 'security.config.disabled', 'security.config.updated', 'security.sync.triggered', 'security.sync.completed', 'security.audit_log.exported')" + } + }, + "isRLSEnabled": false + }, "public.security_findings": { "name": "security_findings", "schema": "", @@ -12089,9 +12536,15 @@ "type": "text", "primaryKey": false, "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false } }, - "definition": "\n SELECT\n mu.id,\n mu.kilo_user_id,\n meta.message_id,\n mu.cost,\n mu.input_tokens,\n mu.output_tokens,\n mu.cache_write_tokens,\n mu.cache_hit_tokens,\n mu.created_at,\n ip.http_ip AS http_x_forwarded_for,\n city.vercel_ip_city AS http_x_vercel_ip_city,\n country.vercel_ip_country AS http_x_vercel_ip_country,\n meta.vercel_ip_latitude AS http_x_vercel_ip_latitude,\n meta.vercel_ip_longitude AS http_x_vercel_ip_longitude,\n ja4.ja4_digest AS http_x_vercel_ja4_digest,\n mu.provider,\n mu.model,\n mu.requested_model,\n meta.user_prompt_prefix,\n spp.system_prompt_prefix,\n meta.system_prompt_length,\n ua.http_user_agent,\n mu.cache_discount,\n meta.max_tokens,\n meta.has_middle_out_transform,\n mu.has_error,\n mu.abuse_classification,\n mu.organization_id,\n mu.inference_provider,\n mu.project_id,\n meta.status_code,\n meta.upstream_id,\n frfr.finish_reason,\n meta.latency,\n meta.moderation_latency,\n meta.generation_time,\n meta.is_byok,\n meta.is_user_byok,\n meta.streamed,\n meta.cancelled,\n edit.editor_name,\n meta.has_tools,\n meta.machine_id,\n feat.feature\n FROM \"microdollar_usage\" mu\n LEFT JOIN \"microdollar_usage_metadata\" meta ON mu.id = meta.id\n LEFT JOIN \"http_ip\" ip ON meta.http_ip_id = ip.http_ip_id\n LEFT JOIN \"vercel_ip_city\" city ON meta.vercel_ip_city_id = city.vercel_ip_city_id\n LEFT JOIN \"vercel_ip_country\" country ON meta.vercel_ip_country_id = country.vercel_ip_country_id\n LEFT JOIN \"ja4_digest\" ja4 ON meta.ja4_digest_id = ja4.ja4_digest_id\n LEFT JOIN \"system_prompt_prefix\" spp ON meta.system_prompt_prefix_id = spp.system_prompt_prefix_id\n LEFT JOIN \"http_user_agent\" ua ON meta.http_user_agent_id = ua.http_user_agent_id\n LEFT JOIN \"finish_reason\" frfr ON meta.finish_reason_id = frfr.finish_reason_id\n LEFT JOIN \"editor_name\" edit ON meta.editor_name_id = edit.editor_name_id\n LEFT JOIN \"feature\" feat ON meta.feature_id = feat.feature_id\n", + "definition": "\n SELECT\n mu.id,\n mu.kilo_user_id,\n meta.message_id,\n mu.cost,\n mu.input_tokens,\n mu.output_tokens,\n mu.cache_write_tokens,\n mu.cache_hit_tokens,\n mu.created_at,\n ip.http_ip AS http_x_forwarded_for,\n city.vercel_ip_city AS http_x_vercel_ip_city,\n country.vercel_ip_country AS http_x_vercel_ip_country,\n meta.vercel_ip_latitude AS http_x_vercel_ip_latitude,\n meta.vercel_ip_longitude AS http_x_vercel_ip_longitude,\n ja4.ja4_digest AS http_x_vercel_ja4_digest,\n mu.provider,\n mu.model,\n mu.requested_model,\n meta.user_prompt_prefix,\n spp.system_prompt_prefix,\n meta.system_prompt_length,\n ua.http_user_agent,\n mu.cache_discount,\n meta.max_tokens,\n meta.has_middle_out_transform,\n mu.has_error,\n mu.abuse_classification,\n mu.organization_id,\n mu.inference_provider,\n mu.project_id,\n meta.status_code,\n meta.upstream_id,\n frfr.finish_reason,\n meta.latency,\n meta.moderation_latency,\n meta.generation_time,\n meta.is_byok,\n meta.is_user_byok,\n meta.streamed,\n meta.cancelled,\n edit.editor_name,\n meta.has_tools,\n meta.machine_id,\n feat.feature,\n meta.session_id\n FROM \"microdollar_usage\" mu\n LEFT JOIN \"microdollar_usage_metadata\" meta ON mu.id = meta.id\n LEFT JOIN \"http_ip\" ip ON meta.http_ip_id = ip.http_ip_id\n LEFT JOIN \"vercel_ip_city\" city ON meta.vercel_ip_city_id = city.vercel_ip_city_id\n LEFT JOIN \"vercel_ip_country\" country ON meta.vercel_ip_country_id = country.vercel_ip_country_id\n LEFT JOIN \"ja4_digest\" ja4 ON meta.ja4_digest_id = ja4.ja4_digest_id\n LEFT JOIN \"system_prompt_prefix\" spp ON meta.system_prompt_prefix_id = spp.system_prompt_prefix_id\n LEFT JOIN \"http_user_agent\" ua ON meta.http_user_agent_id = ua.http_user_agent_id\n LEFT JOIN \"finish_reason\" frfr ON meta.finish_reason_id = frfr.finish_reason_id\n LEFT JOIN \"editor_name\" edit ON meta.editor_name_id = edit.editor_name_id\n LEFT JOIN \"feature\" feat ON meta.feature_id = feat.feature_id\n", "name": "microdollar_usage_view", "schema": "public", "isExisting": false, diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 07dd7de9f..d002f9d5c 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -190,6 +190,13 @@ "when": 1771928226112, "tag": "0026_quiet_namorita", "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1771935201251, + "tag": "0027_unknown_shiva", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/db/schema.test.ts b/src/db/schema.test.ts index f401de545..481108f7e 100644 --- a/src/db/schema.test.ts +++ b/src/db/schema.test.ts @@ -76,6 +76,21 @@ describe('database schema', () => { KiloPassAuditLogResult: ['success', 'skipped_idempotent', 'failed'], KiloPassScheduledChangeStatus: ['not_started', 'active', 'completed', 'released', 'canceled'], CliSessionSharedState: ['public', 'organization'], + SecurityAuditLogAction: [ + 'security.finding.created', + 'security.finding.status_change', + 'security.finding.dismissed', + 'security.finding.auto_dismissed', + 'security.finding.analysis_started', + 'security.finding.analysis_completed', + 'security.finding.deleted', + 'security.config.enabled', + 'security.config.disabled', + 'security.config.updated', + 'security.sync.triggered', + 'security.sync.completed', + 'security.audit_log.exported', + ], }; const actualEnumValues: Record = {}; diff --git a/src/db/schema.ts b/src/db/schema.ts index 060fec9c9..f5ba23e4d 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -39,6 +39,7 @@ import type { PlatformRepository, IntegrationPermissions } from '@/lib/integrati import type { BuildStatus, Provider } from '@/lib/user-deployments/types'; import type { CodeReviewAgentConfig } from '@/lib/agent-config/core/types'; import type { DependabotAlertRaw, SecurityFindingAnalysis } from '@/lib/security-agent/core/types'; +import { SecurityAuditLogAction } from '@/lib/security-agent/core/enums'; import type { NormalizedOpenRouterResponse, OpenRouterModel, @@ -93,6 +94,7 @@ export const SCHEMA_CHECK_ENUMS = { KiloPassAuditLogResult, KiloPassScheduledChangeStatus, CliSessionSharedState, + SecurityAuditLogAction, } as const; export const credit_transactions = pgTable( @@ -2431,6 +2433,45 @@ export const security_findings = pgTable( export type SecurityFinding = typeof security_findings.$inferSelect; export type NewSecurityFinding = typeof security_findings.$inferInsert; +// Security Audit Log — SOC2-compliant audit trail for security agent actions +export const security_audit_log = pgTable( + 'security_audit_log', + { + id: idPrimaryKeyColumn, + // XOR ownership: exactly one of owned_by_organization_id or owned_by_user_id must be set. + owned_by_organization_id: uuid().references(() => organizations.id, { onDelete: 'cascade' }), + owned_by_user_id: text().references(() => kilocode_users.id, { onDelete: 'cascade' }), + // actor_id is text to match kilocode_users.id; nullable for system-initiated actions + actor_id: text(), + actor_email: text(), + actor_name: text(), + action: text().$type().notNull(), + resource_type: text().notNull(), + resource_id: text().notNull(), + before_state: jsonb().$type>(), + after_state: jsonb().$type>(), + metadata: jsonb().$type>(), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + }, + table => [ + check( + 'security_audit_log_owner_check', + sql`(${table.owned_by_user_id} IS NOT NULL AND ${table.owned_by_organization_id} IS NULL) OR (${table.owned_by_user_id} IS NULL AND ${table.owned_by_organization_id} IS NOT NULL)` + ), + enumCheck('security_audit_log_action_check', table.action, SecurityAuditLogAction), + index('IDX_security_audit_log_org_created').on( + table.owned_by_organization_id, + table.created_at + ), + index('IDX_security_audit_log_user_created').on(table.owned_by_user_id, table.created_at), + index('IDX_security_audit_log_resource').on(table.resource_type, table.resource_id), + index('IDX_security_audit_log_actor').on(table.actor_id, table.created_at), + index('IDX_security_audit_log_action').on(table.action, table.created_at), + ] +); + +export type SecurityAuditLogEntry = typeof security_audit_log.$inferSelect; + // Slack Bot Request Logs - for admin debugging and statistics export type SlackBotEventType = 'app_mention' | 'message'; export type SlackBotRequestStatus = 'success' | 'error'; diff --git a/src/lib/security-agent/core/enums.ts b/src/lib/security-agent/core/enums.ts new file mode 100644 index 000000000..3e6677f60 --- /dev/null +++ b/src/lib/security-agent/core/enums.ts @@ -0,0 +1,22 @@ +/** + * Actions logged in the security_audit_log table. + * + * Follows a consistent 3-segment `security.entity.verb` pattern. + * Registered in SCHEMA_CHECK_ENUMS (src/db/schema.ts) and enforced + * at the database level via enumCheck. + */ +export enum SecurityAuditLogAction { + FindingCreated = 'security.finding.created', + FindingStatusChange = 'security.finding.status_change', + FindingDismissed = 'security.finding.dismissed', + FindingAutoDismissed = 'security.finding.auto_dismissed', + FindingAnalysisStarted = 'security.finding.analysis_started', + FindingAnalysisCompleted = 'security.finding.analysis_completed', + FindingDeleted = 'security.finding.deleted', + ConfigEnabled = 'security.config.enabled', + ConfigDisabled = 'security.config.disabled', + ConfigUpdated = 'security.config.updated', + SyncTriggered = 'security.sync.triggered', + SyncCompleted = 'security.sync.completed', + AuditLogExported = 'security.audit_log.exported', +} diff --git a/src/lib/security-agent/services/audit-log-service.ts b/src/lib/security-agent/services/audit-log-service.ts new file mode 100644 index 000000000..c6930cfa2 --- /dev/null +++ b/src/lib/security-agent/services/audit-log-service.ts @@ -0,0 +1,103 @@ +/** + * Security Audit Log Service + * + * Provides append-only audit logging for all security agent actions. + * Follows the createAuditLog pattern from src/lib/organizations/organization-audit-logs.ts. + */ + +import 'server-only'; +import { security_audit_log } from '@/db/schema'; +import type { SecurityAuditLogEntry } from '@/db/schema'; +import type { DrizzleTransaction } from '@/lib/drizzle'; +import { db } from '@/lib/drizzle'; +import { SecurityAuditLogAction } from '../core/enums'; +import type { SecurityReviewOwner } from '../core/types'; +import { captureException } from '@sentry/nextjs'; + +export { SecurityAuditLogAction }; + +type CreateSecurityAuditLogParams = { + owner: SecurityReviewOwner; + actor_id: string | null; + actor_email: string | null; + actor_name: string | null; + action: SecurityAuditLogAction; + resource_type: string; + resource_id: string; + before_state?: Record; + after_state?: Record; + metadata?: Record; + tx?: DrizzleTransaction; +}; + +export async function createSecurityAuditLog( + params: CreateSecurityAuditLogParams +): Promise { + const { + owner, + actor_id, + actor_email, + actor_name, + action, + resource_type, + resource_id, + before_state, + after_state, + metadata, + tx, + } = params; + + const owned_by_organization_id = + 'organizationId' in owner ? (owner.organizationId ?? null) : null; + const owned_by_user_id = 'userId' in owner ? (owner.userId ?? null) : null; + + const [entry] = await (tx ?? db) + .insert(security_audit_log) + .values({ + owned_by_organization_id, + owned_by_user_id, + actor_id, + actor_email, + actor_name, + action, + resource_type, + resource_id, + before_state, + after_state, + metadata, + }) + .returning(); + + return entry; +} + +/** + * Fire-and-forget audit log: logs errors to Sentry instead of throwing. + * Use this in paths where audit logging should never block the main operation. + */ +export function logSecurityAudit(params: CreateSecurityAuditLogParams): void { + createSecurityAuditLog(params).catch(error => { + captureException(error, { + tags: { operation: 'createSecurityAuditLog' }, + extra: { action: params.action, resource_type: params.resource_type }, + }); + }); +} + +/** Replace internal Kilo admin actor details with a generic placeholder for non-admin requestors. */ +export function maskKiloAdminActors< + T extends { actor_id: string | null; actor_email: string | null; actor_name: string | null }, +>(logs: T[], isRequestingUserKiloAdmin: boolean): T[] { + if (isRequestingUserKiloAdmin) return logs; + return logs.map(log => { + if (log.actor_email && log.actor_email.endsWith('@kilocode.ai')) { + return { + ...log, + actor_id: '00000000-0000-0000-0000-000000000000', + actor_email: 'admin@kilocode.ai', + actor_name: 'Kilo Admin', + }; + } + return log; + }); +} diff --git a/src/lib/security-agent/services/auto-dismiss-service.ts b/src/lib/security-agent/services/auto-dismiss-service.ts index b51595108..965bd146d 100644 --- a/src/lib/security-agent/services/auto-dismiss-service.ts +++ b/src/lib/security-agent/services/auto-dismiss-service.ts @@ -22,6 +22,7 @@ import { dismissDependabotAlert } from '../github/dependabot-api'; import type { Owner } from '@/lib/code-reviews/core'; import type { SecurityFindingAnalysis, SecurityReviewOwner } from '../core/types'; import { sentryLogger } from '@/lib/utils.server'; +import { logSecurityAudit, SecurityAuditLogAction } from './audit-log-service'; const log = sentryLogger('security-agent:auto-dismiss', 'info'); const logError = sentryLogger('security-agent:auto-dismiss', 'error'); @@ -188,6 +189,23 @@ export async function maybeAutoDismissAnalysis(options: { source: 'sandbox', }); + logSecurityAudit({ + owner, + actor_id: null, + actor_email: null, + actor_name: null, + action: SecurityAuditLogAction.FindingAutoDismissed, + resource_type: 'security_finding', + resource_id: findingId, + after_state: { status: 'ignored' }, + metadata: { + source: 'system', + trigger: 'auto_dismiss_policy', + dismissSource: 'sandbox', + correlationId, + }, + }); + return { dismissed: true, source: 'sandbox' }; } @@ -231,6 +249,24 @@ export async function maybeAutoDismissAnalysis(options: { confidence: triage.confidence, }); + logSecurityAudit({ + owner, + actor_id: null, + actor_email: null, + actor_name: null, + action: SecurityAuditLogAction.FindingAutoDismissed, + resource_type: 'security_finding', + resource_id: findingId, + after_state: { status: 'ignored' }, + metadata: { + source: 'system', + trigger: 'auto_dismiss_policy', + dismissSource: 'triage', + confidence: triage.confidence, + correlationId, + }, + }); + return { dismissed: true, source: 'triage' }; } } diff --git a/src/lib/security-agent/services/sync-service.ts b/src/lib/security-agent/services/sync-service.ts index a51664973..b22e82211 100644 --- a/src/lib/security-agent/services/sync-service.ts +++ b/src/lib/security-agent/services/sync-service.ts @@ -23,6 +23,7 @@ import { } from '../core/types'; import type { Owner } from '@/lib/code-reviews/core'; import { sentryLogger } from '@/lib/utils.server'; +import { logSecurityAudit, SecurityAuditLogAction } from './audit-log-service'; const log = sentryLogger('security-agent:sync', 'info'); const warn = sentryLogger('security-agent:sync', 'warning'); @@ -410,6 +411,27 @@ export async function runFullSync(): Promise<{ }); } } + + const ownerId = + 'organizationId' in config.owner + ? (config.owner.organizationId ?? 'unknown') + : (config.owner.userId ?? 'unknown'); + logSecurityAudit({ + owner: config.owner, + actor_id: null, + actor_email: null, + actor_name: null, + action: SecurityAuditLogAction.SyncCompleted, + resource_type: 'agent_config', + resource_id: ownerId, + metadata: { + source: 'system', + trigger: 'cron', + synced: result.synced, + errors: result.errors, + repoCount: config.repositories.length, + }, + }); } catch (error) { totalErrors++; captureException(error, { diff --git a/src/lib/user.test.ts b/src/lib/user.test.ts index 8c683ec22..08d5cf24e 100644 --- a/src/lib/user.test.ts +++ b/src/lib/user.test.ts @@ -16,6 +16,7 @@ import { organization_user_usage, organization_audit_logs, organization_invitations, + security_audit_log, free_model_usage, organizations, user_feedback, @@ -36,6 +37,7 @@ import { KiloPassIssuanceSource, KiloPassTier, } from '@/lib/kilo-pass/enums'; +import { SecurityAuditLogAction } from '@/lib/security-agent/core/enums'; describe('User', () => { // Shared cleanup for all tests in this suite to prevent data pollution @@ -50,6 +52,7 @@ describe('User', () => { await db.delete(referral_code_usages); await db.delete(referral_codes); await db.delete(organization_audit_logs); + await db.delete(security_audit_log); await db.delete(organization_invitations); await db.delete(organization_user_usage); await db.delete(organization_user_limits); @@ -353,6 +356,39 @@ describe('User', () => { expect(logs[0].message).toBe('User joined org'); // message preserved }); + it('should anonymize security audit logs where user is actor', async () => { + const user = await insertTestUser(); + const orgId = randomUUID(); + await db.insert(organizations).values({ + id: orgId, + name: 'Test Org', + stripe_customer_id: `stripe-org-${orgId}`, + plan: 'teams', + }); + + await db.insert(security_audit_log).values({ + owned_by_organization_id: orgId, + actor_id: user.id, + actor_email: user.google_user_email, + actor_name: user.google_user_name, + action: SecurityAuditLogAction.FindingDismissed, + resource_type: 'security_finding', + resource_id: randomUUID(), + }); + + await softDeleteUser(user.id); + + const logs = await db + .select() + .from(security_audit_log) + .where(eq(security_audit_log.actor_id, user.id)); + expect(logs).toHaveLength(1); + expect(logs[0].actor_email).toBeNull(); + expect(logs[0].actor_name).toBeNull(); + expect(logs[0].actor_id).toBe(user.id); // actor_id preserved + expect(logs[0].action).toBe(SecurityAuditLogAction.FindingDismissed); // action preserved + }); + it('should soft-delete and anonymize payment methods', async () => { const user = await insertTestUser(); const pm = createTestPaymentMethod(user.id); diff --git a/src/lib/user.ts b/src/lib/user.ts index f63075ae6..e1cda2ded 100644 --- a/src/lib/user.ts +++ b/src/lib/user.ts @@ -33,6 +33,7 @@ import { webhook_events, agent_environment_profiles, security_findings, + security_audit_log, auto_triage_tickets, auto_fix_tickets, slack_bot_requests, @@ -526,6 +527,13 @@ export async function softDeleteUser(userId: string) { .set({ actor_email: null, actor_name: null }) .where(eq(organization_audit_logs.actor_id, userId)); + // Security audit logs: keep org-owned entries, strip actor PII + // (user-owned entries are cascade-deleted via owned_by_user_id FK) + await tx + .update(security_audit_log) + .set({ actor_email: null, actor_name: null }) + .where(eq(security_audit_log.actor_id, userId)); + // Payment methods: soft-delete and strip address/name/IP fields await tx .update(payment_methods) diff --git a/src/routers/organizations/organization-router.ts b/src/routers/organizations/organization-router.ts index f26d0dc9b..4b4559a3c 100644 --- a/src/routers/organizations/organization-router.ts +++ b/src/routers/organizations/organization-router.ts @@ -51,6 +51,7 @@ import { organizationCloudAgentRouter } from '@/routers/organizations/organizati import { organizationCloudAgentNextRouter } from '@/routers/organizations/organization-cloud-agent-next-router'; import { organizationAppBuilderRouter } from '@/routers/organizations/organization-app-builder-router'; import { organizationSecurityAgentRouter } from '@/routers/organizations/organization-security-agent-router'; +import { organizationSecurityAuditLogRouter } from '@/routers/organizations/organization-security-audit-log-router'; import { organizationSlackRouter } from '@/routers/organizations/organization-slack-router'; import { organizationAutoTriageRouter } from '@/routers/organizations/organization-auto-triage-router'; import { organizationAutoFixRouter } from '@/routers/organizations/organization-auto-fix-router'; @@ -106,6 +107,7 @@ export const organizationsRouter = createTRPCRouter({ cloudAgentNext: organizationCloudAgentNextRouter, appBuilder: organizationAppBuilderRouter, securityAgent: organizationSecurityAgentRouter, + securityAuditLog: organizationSecurityAuditLogRouter, slack: organizationSlackRouter, autoTriage: organizationAutoTriageRouter, autoFix: organizationAutoFixRouter, diff --git a/src/routers/organizations/organization-security-agent-router.ts b/src/routers/organizations/organization-security-agent-router.ts index 3c23e9f3d..03410c3c9 100644 --- a/src/routers/organizations/organization-security-agent-router.ts +++ b/src/routers/organizations/organization-security-agent-router.ts @@ -65,6 +65,10 @@ import { trackSecurityAgentSync, trackSecurityAgentFindingDismissed, } from '@/lib/security-agent/posthog-tracking'; +import { + logSecurityAudit, + SecurityAuditLogAction, +} from '@/lib/security-agent/services/audit-log-service'; const OrgSaveSecurityConfigInputSchema = OrganizationIdInputSchema.merge( SaveSecurityConfigInputSchema @@ -165,6 +169,23 @@ export const organizationSecurityAgentRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { const owner = { type: 'org' as const, id: input.organizationId, userId: ctx.user.id }; + const existingConfig = await getSecurityAgentConfigWithStatus(owner); + const beforeState = existingConfig + ? { + autoSyncEnabled: existingConfig.config.auto_sync_enabled, + analysisMode: existingConfig.config.analysis_mode, + autoDismissEnabled: existingConfig.config.auto_dismiss_enabled, + autoDismissConfidenceThreshold: existingConfig.config.auto_dismiss_confidence_threshold, + modelSlug: existingConfig.config.model_slug, + repositorySelectionMode: existingConfig.config.repository_selection_mode, + selectedRepositoryIds: existingConfig.config.selected_repository_ids, + slaCriticalDays: existingConfig.config.sla_critical_days, + slaHighDays: existingConfig.config.sla_high_days, + slaMediumDays: existingConfig.config.sla_medium_days, + slaLowDays: existingConfig.config.sla_low_days, + } + : undefined; + await upsertSecurityAgentConfig( owner, { @@ -198,6 +219,30 @@ export const organizationSecurityAgentRouter = createTRPCRouter({ selectedRepoCount: input.selectedRepositoryIds?.length, }); + logSecurityAudit({ + owner: { organizationId: input.organizationId }, + actor_id: ctx.user.id, + actor_email: ctx.user.google_user_email, + actor_name: ctx.user.google_user_name, + action: SecurityAuditLogAction.ConfigUpdated, + resource_type: 'agent_config', + resource_id: input.organizationId, + before_state: beforeState, + after_state: { + autoSyncEnabled: input.autoSyncEnabled, + analysisMode: input.analysisMode, + autoDismissEnabled: input.autoDismissEnabled, + autoDismissConfidenceThreshold: input.autoDismissConfidenceThreshold, + modelSlug: input.modelSlug, + repositorySelectionMode: input.repositorySelectionMode, + selectedRepositoryIds: input.selectedRepositoryIds, + slaCriticalDays: input.slaCriticalDays, + slaHighDays: input.slaHighDays, + slaMediumDays: input.slaMediumDays, + slaLowDays: input.slaLowDays, + }, + }); + return { success: true }; }), @@ -312,6 +357,17 @@ export const organizationSecurityAgentRouter = createTRPCRouter({ syncErrors: syncResult.errors, }); + logSecurityAudit({ + owner: securityOwner, + actor_id: ctx.user.id, + actor_email: ctx.user.google_user_email, + actor_name: ctx.user.google_user_name, + action: SecurityAuditLogAction.ConfigEnabled, + resource_type: 'agent_config', + resource_id: input.organizationId, + after_state: { isEnabled: true, repositorySelectionMode: selectionMode }, + }); + return { success: true, syncResult: { @@ -341,6 +397,19 @@ export const organizationSecurityAgentRouter = createTRPCRouter({ selectedRepoCount: effectiveRepoCount, }); + logSecurityAudit({ + owner: securityOwner, + actor_id: ctx.user.id, + actor_email: ctx.user.google_user_email, + actor_name: ctx.user.google_user_name, + action: input.isEnabled + ? SecurityAuditLogAction.ConfigEnabled + : SecurityAuditLogAction.ConfigDisabled, + resource_type: 'agent_config', + resource_id: input.organizationId, + after_state: { isEnabled: input.isEnabled, repositorySelectionMode: selectionMode }, + }); + return { success: true }; }), @@ -512,6 +581,22 @@ export const organizationSecurityAgentRouter = createTRPCRouter({ errors: result.errors, }); + logSecurityAudit({ + owner: securityOwner, + actor_id: ctx.user.id, + actor_email: ctx.user.google_user_email, + actor_name: ctx.user.google_user_name, + action: SecurityAuditLogAction.SyncTriggered, + resource_type: 'agent_config', + resource_id: input.organizationId, + metadata: { + syncType: 'single_repo', + repoFullName: input.repoFullName, + synced: result.synced, + errors: result.errors, + }, + }); + return { success: true, synced: result.synced, @@ -560,6 +645,22 @@ export const organizationSecurityAgentRouter = createTRPCRouter({ errors: result.errors, }); + logSecurityAudit({ + owner: securityOwner, + actor_id: ctx.user.id, + actor_email: ctx.user.google_user_email, + actor_name: ctx.user.google_user_name, + action: SecurityAuditLogAction.SyncTriggered, + resource_type: 'agent_config', + resource_id: input.organizationId, + metadata: { + syncType: 'all_repos', + repoCount: repositoriesToSync.length, + synced: result.synced, + errors: result.errors, + }, + }); + return { success: true, synced: result.synced, @@ -647,6 +748,19 @@ export const organizationSecurityAgentRouter = createTRPCRouter({ severity: finding.severity, }); + logSecurityAudit({ + owner: { organizationId: input.organizationId }, + actor_id: ctx.user.id, + actor_email: ctx.user.google_user_email, + actor_name: ctx.user.google_user_name, + action: SecurityAuditLogAction.FindingDismissed, + resource_type: 'security_finding', + resource_id: input.findingId, + before_state: { status: finding.status }, + after_state: { status: 'ignored', ignoredReason: input.reason }, + metadata: { source: finding.source, severity: finding.severity }, + }); + return { success: true }; }), @@ -700,9 +814,8 @@ export const organizationSecurityAgentRouter = createTRPCRouter({ const model = input.model || config?.config.model_slug || DEFAULT_SECURITY_AGENT_MODEL; const analysisMode = config?.config.analysis_mode ?? 'auto'; - let result; try { - result = await startSecurityAnalysis({ + const result = await startSecurityAnalysis({ findingId: input.findingId, user: ctx.user, githubRepo: finding.repo_full_name, @@ -712,18 +825,29 @@ export const organizationSecurityAgentRouter = createTRPCRouter({ retrySandboxOnly: input.retrySandboxOnly, organizationId: input.organizationId, }); - } catch (error) { - rethrowAsPaymentRequired(error); - } - if (!result.started) { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: result.error || 'Failed to start analysis', + if (!result.started) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: result.error || 'Failed to start analysis', + }); + } + + logSecurityAudit({ + owner: securityOwner, + actor_id: ctx.user.id, + actor_email: ctx.user.google_user_email, + actor_name: ctx.user.google_user_name, + action: SecurityAuditLogAction.FindingAnalysisStarted, + resource_type: 'security_finding', + resource_id: input.findingId, + metadata: { model, analysisMode, triageOnly: result.triageOnly }, }); - } - return { success: true, triageOnly: result.triageOnly }; + return { success: true, triageOnly: result.triageOnly }; + } catch (error) { + rethrowAsPaymentRequired(error); + } }), /** @@ -825,7 +949,7 @@ export const organizationSecurityAgentRouter = createTRPCRouter({ */ deleteFindingsByRepository: organizationOwnerProcedure .input(OrgDeleteFindingsByRepoInputSchema) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { const securityOwner: SecurityReviewOwner = { organizationId: input.organizationId }; const result = await deleteFindingsByRepository({ @@ -833,6 +957,17 @@ export const organizationSecurityAgentRouter = createTRPCRouter({ repoFullName: input.repoFullName, }); + logSecurityAudit({ + owner: securityOwner, + actor_id: ctx.user.id, + actor_email: ctx.user.google_user_email, + actor_name: ctx.user.google_user_name, + action: SecurityAuditLogAction.FindingDeleted, + resource_type: 'security_finding', + resource_id: input.repoFullName, + metadata: { repoFullName: input.repoFullName, deletedCount: result.deletedCount }, + }); + return { success: true, deletedCount: result.deletedCount, @@ -862,6 +997,22 @@ export const organizationSecurityAgentRouter = createTRPCRouter({ const result = await autoDismissEligibleFindings(securityOwner, ctx.user.id); + logSecurityAudit({ + owner: securityOwner, + actor_id: ctx.user.id, + actor_email: ctx.user.google_user_email, + actor_name: ctx.user.google_user_name, + action: SecurityAuditLogAction.FindingAutoDismissed, + resource_type: 'security_finding', + resource_id: 'bulk', + metadata: { + source: 'bulk', + dismissed: result.dismissed, + skipped: result.skipped, + errors: result.errors, + }, + }); + return { dismissed: result.dismissed, skipped: result.skipped, diff --git a/src/routers/organizations/organization-security-audit-log-router.ts b/src/routers/organizations/organization-security-audit-log-router.ts new file mode 100644 index 000000000..eb6b3f2c6 --- /dev/null +++ b/src/routers/organizations/organization-security-audit-log-router.ts @@ -0,0 +1,254 @@ +import * as z from 'zod'; +import { createTRPCRouter } from '@/lib/trpc/init'; +import { + OrganizationIdInputSchema, + organizationOwnerProcedure, +} from '@/routers/organizations/utils'; +import { security_audit_log } from '@/db/schema'; +import { db } from '@/lib/drizzle'; +import { + and, + eq, + lt, + gt, + desc, + asc, + count, + min, + max, + ilike, + gte, + lte, + inArray, + sql, +} from 'drizzle-orm'; +import { SecurityAuditLogAction } from '@/lib/security-agent/core/enums'; +import { + logSecurityAudit, + maskKiloAdminActors, +} from '@/lib/security-agent/services/audit-log-service'; + +const PAGE_SIZE = 100; +const MAX_EXPORT_ROWS = 10_000; + +const SecurityAuditLogActionSchema = z.nativeEnum(SecurityAuditLogAction); + +const ListSecurityAuditLogsInputSchema = OrganizationIdInputSchema.extend({ + before: z.string().datetime().optional(), + after: z.string().datetime().optional(), + action: z.array(SecurityAuditLogActionSchema).optional(), + actorEmail: z.string().email().optional(), + resourceType: z.string().max(100).optional(), + resourceId: z.string().max(500).optional(), + fuzzySearch: z.string().max(200).optional(), + startTime: z.string().datetime().optional(), + endTime: z.string().datetime().optional(), +}); + +const ExportInputSchema = OrganizationIdInputSchema.extend({ + format: z.enum(['csv', 'json']).default('json'), + startTime: z.string().datetime().optional(), + endTime: z.string().datetime().optional(), + action: z.array(SecurityAuditLogActionSchema).optional(), +}); + +export const organizationSecurityAuditLogRouter = createTRPCRouter({ + list: organizationOwnerProcedure + .input(ListSecurityAuditLogsInputSchema) + .query(async ({ input, ctx }) => { + const { + organizationId, + before, + after, + action, + actorEmail, + resourceType, + resourceId, + fuzzySearch, + startTime, + endTime, + } = input; + + const whereConditions = [eq(security_audit_log.owned_by_organization_id, organizationId)]; + + if (action && action.length > 0) { + if (action.length === 1) { + whereConditions.push(eq(security_audit_log.action, action[0])); + } else { + whereConditions.push(inArray(security_audit_log.action, action)); + } + } + if (actorEmail) whereConditions.push(eq(security_audit_log.actor_email, actorEmail)); + if (resourceType) whereConditions.push(eq(security_audit_log.resource_type, resourceType)); + if (resourceId) whereConditions.push(eq(security_audit_log.resource_id, resourceId)); + if (fuzzySearch) { + const escapedSearch = fuzzySearch.replace(/[%_\\]/g, '\\$&'); + whereConditions.push( + ilike(sql`COALESCE(${security_audit_log.metadata}::text, '')`, `%${escapedSearch}%`) + ); + } + if (startTime) whereConditions.push(gte(security_audit_log.created_at, startTime)); + if (endTime) whereConditions.push(lte(security_audit_log.created_at, endTime)); + + if (before) whereConditions.push(lt(security_audit_log.created_at, before)); + if (after) whereConditions.push(gt(security_audit_log.created_at, after)); + + const orderBy = after + ? asc(security_audit_log.created_at) + : desc(security_audit_log.created_at); + + const logs = await db + .select({ + id: security_audit_log.id, + action: security_audit_log.action, + actor_id: security_audit_log.actor_id, + actor_email: security_audit_log.actor_email, + actor_name: security_audit_log.actor_name, + resource_type: security_audit_log.resource_type, + resource_id: security_audit_log.resource_id, + before_state: security_audit_log.before_state, + after_state: security_audit_log.after_state, + metadata: security_audit_log.metadata, + created_at: security_audit_log.created_at, + }) + .from(security_audit_log) + .where(and(...whereConditions)) + .orderBy(orderBy) + .limit(PAGE_SIZE + 1); + + const hasMore = logs.length > PAGE_SIZE; + const resultLogs = hasMore ? logs.slice(0, PAGE_SIZE) : logs; + + if (after) { + resultLogs.reverse(); + } + + const isKiloAdmin = ctx.user.google_user_email.endsWith('@kilocode.ai'); + const maskedLogs = maskKiloAdminActors(resultLogs, isKiloAdmin); + + const hasNext = hasMore; + const hasPrevious = !!after || (!!before && logs.length > 0); + + const oldestTimestamp = + maskedLogs.length > 0 + ? new Date(maskedLogs[maskedLogs.length - 1].created_at).toISOString() + : null; + const newestTimestamp = + maskedLogs.length > 0 ? new Date(maskedLogs[0].created_at).toISOString() : null; + + return { + logs: maskedLogs, + hasNext, + hasPrevious, + oldestTimestamp, + newestTimestamp, + }; + }), + + getActionTypes: organizationOwnerProcedure.input(OrganizationIdInputSchema).query(async () => { + return Object.values(SecurityAuditLogAction); + }), + + getSummary: organizationOwnerProcedure + .input(OrganizationIdInputSchema) + .query(async ({ input }) => { + const { organizationId } = input; + + const [summary] = await db + .select({ + totalEvents: count(security_audit_log.id), + earliestEvent: min(security_audit_log.created_at), + latestEvent: max(security_audit_log.created_at), + }) + .from(security_audit_log) + .where(eq(security_audit_log.owned_by_organization_id, organizationId)); + + return { + totalEvents: summary.totalEvents || 0, + earliestEvent: summary.earliestEvent, + latestEvent: summary.latestEvent, + }; + }), + + export: organizationOwnerProcedure.input(ExportInputSchema).mutation(async ({ input, ctx }) => { + const { organizationId, format, startTime, endTime, action } = input; + + const whereConditions = [eq(security_audit_log.owned_by_organization_id, organizationId)]; + if (startTime) whereConditions.push(gte(security_audit_log.created_at, startTime)); + if (endTime) whereConditions.push(lte(security_audit_log.created_at, endTime)); + if (action && action.length > 0) { + if (action.length === 1) { + whereConditions.push(eq(security_audit_log.action, action[0])); + } else { + whereConditions.push(inArray(security_audit_log.action, action)); + } + } + + const logs = await db + .select({ + id: security_audit_log.id, + action: security_audit_log.action, + actor_id: security_audit_log.actor_id, + actor_email: security_audit_log.actor_email, + actor_name: security_audit_log.actor_name, + resource_type: security_audit_log.resource_type, + resource_id: security_audit_log.resource_id, + before_state: security_audit_log.before_state, + after_state: security_audit_log.after_state, + metadata: security_audit_log.metadata, + created_at: security_audit_log.created_at, + }) + .from(security_audit_log) + .where(and(...whereConditions)) + .orderBy(desc(security_audit_log.created_at)) + .limit(MAX_EXPORT_ROWS); + + // Log the export action itself + logSecurityAudit({ + owner: { organizationId }, + actor_id: ctx.user.id, + actor_email: ctx.user.google_user_email, + actor_name: ctx.user.google_user_name, + action: SecurityAuditLogAction.AuditLogExported, + resource_type: 'audit_log', + resource_id: organizationId, + metadata: { format, rowCount: logs.length, startTime, endTime }, + }); + + const isKiloAdmin = ctx.user.google_user_email.endsWith('@kilocode.ai'); + const maskedLogs = maskKiloAdminActors(logs, isKiloAdmin); + + if (format === 'csv') { + const header = + 'id,timestamp,action,actor_id,actor_email,actor_name,resource_type,resource_id,before_state,after_state,metadata'; + const rows = maskedLogs.map(log => + [ + log.id, + log.created_at, + log.action, + log.actor_id ?? '', + log.actor_email ?? '', + log.actor_name ?? '', + log.resource_type, + log.resource_id, + log.before_state ? JSON.stringify(log.before_state) : '', + log.after_state ? JSON.stringify(log.after_state) : '', + log.metadata ? JSON.stringify(log.metadata) : '', + ] + .map(field => `"${String(field).replace(/"/g, '""')}"`) + .join(',') + ); + return { + format: 'csv' as const, + data: [header, ...rows].join('\n'), + rowCount: maskedLogs.length, + }; + } + + return { + format: 'json' as const, + data: JSON.stringify(maskedLogs, null, 2), + rowCount: maskedLogs.length, + }; + }), +}); diff --git a/src/routers/root-router.ts b/src/routers/root-router.ts index 7f5930042..f077168fb 100644 --- a/src/routers/root-router.ts +++ b/src/routers/root-router.ts @@ -19,6 +19,7 @@ import { personalReviewAgentRouter } from '@/routers/code-reviews-router'; import { byokRouter } from '@/routers/byok-router'; import { appBuilderRouter } from '@/routers/app-builder-router'; import { securityAgentRouter } from '@/routers/security-agent-router'; +import { securityAuditLogRouter } from '@/routers/security-audit-log-router'; import { autoTriageRouter } from '@/routers/auto-triage/auto-triage-router'; import { personalAutoTriageRouter } from '@/routers/personal-auto-triage-router'; import { autoFixRouter } from '@/routers/auto-fix/auto-fix-router'; @@ -53,6 +54,7 @@ export const rootRouter = createTRPCRouter({ byok: byokRouter, appBuilder: appBuilderRouter, securityAgent: securityAgentRouter, + securityAuditLog: securityAuditLogRouter, autoTriage: autoTriageRouter, personalAutoTriage: personalAutoTriageRouter, autoFix: autoFixRouter, diff --git a/src/routers/security-agent-router.ts b/src/routers/security-agent-router.ts index 7a81b79c0..1807fbe80 100644 --- a/src/routers/security-agent-router.ts +++ b/src/routers/security-agent-router.ts @@ -60,6 +60,10 @@ import { trackSecurityAgentSync, trackSecurityAgentFindingDismissed, } from '@/lib/security-agent/posthog-tracking'; +import { + logSecurityAudit, + SecurityAuditLogAction, +} from '@/lib/security-agent/services/audit-log-service'; /** * Security Agent Router for personal users @@ -146,6 +150,23 @@ export const securityAgentRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { const owner = { type: 'user' as const, id: ctx.user.id, userId: ctx.user.id }; + const existingConfig = await getSecurityAgentConfigWithStatus(owner); + const beforeState = existingConfig + ? { + autoSyncEnabled: existingConfig.config.auto_sync_enabled, + analysisMode: existingConfig.config.analysis_mode, + autoDismissEnabled: existingConfig.config.auto_dismiss_enabled, + autoDismissConfidenceThreshold: existingConfig.config.auto_dismiss_confidence_threshold, + modelSlug: existingConfig.config.model_slug, + repositorySelectionMode: existingConfig.config.repository_selection_mode, + selectedRepositoryIds: existingConfig.config.selected_repository_ids, + slaCriticalDays: existingConfig.config.sla_critical_days, + slaHighDays: existingConfig.config.sla_high_days, + slaMediumDays: existingConfig.config.sla_medium_days, + slaLowDays: existingConfig.config.sla_low_days, + } + : undefined; + await upsertSecurityAgentConfig( owner, { @@ -178,6 +199,30 @@ export const securityAgentRouter = createTRPCRouter({ selectedRepoCount: input.selectedRepositoryIds?.length, }); + logSecurityAudit({ + owner: { userId: ctx.user.id }, + actor_id: ctx.user.id, + actor_email: ctx.user.google_user_email, + actor_name: ctx.user.google_user_name, + action: SecurityAuditLogAction.ConfigUpdated, + resource_type: 'agent_config', + resource_id: ctx.user.id, + before_state: beforeState, + after_state: { + autoSyncEnabled: input.autoSyncEnabled, + analysisMode: input.analysisMode, + autoDismissEnabled: input.autoDismissEnabled, + autoDismissConfidenceThreshold: input.autoDismissConfidenceThreshold, + modelSlug: input.modelSlug, + repositorySelectionMode: input.repositorySelectionMode, + selectedRepositoryIds: input.selectedRepositoryIds, + slaCriticalDays: input.slaCriticalDays, + slaHighDays: input.slaHighDays, + slaMediumDays: input.slaMediumDays, + slaLowDays: input.slaLowDays, + }, + }); + return { success: true }; }), @@ -289,6 +334,17 @@ export const securityAgentRouter = createTRPCRouter({ syncErrors: syncResult.errors, }); + logSecurityAudit({ + owner: securityOwner, + actor_id: ctx.user.id, + actor_email: ctx.user.google_user_email, + actor_name: ctx.user.google_user_name, + action: SecurityAuditLogAction.ConfigEnabled, + resource_type: 'agent_config', + resource_id: ctx.user.id, + after_state: { isEnabled: true, repositorySelectionMode: selectionMode }, + }); + return { success: true, syncResult: { @@ -317,6 +373,19 @@ export const securityAgentRouter = createTRPCRouter({ selectedRepoCount: effectiveRepoCount, }); + logSecurityAudit({ + owner: securityOwner, + actor_id: ctx.user.id, + actor_email: ctx.user.google_user_email, + actor_name: ctx.user.google_user_name, + action: input.isEnabled + ? SecurityAuditLogAction.ConfigEnabled + : SecurityAuditLogAction.ConfigDisabled, + resource_type: 'agent_config', + resource_id: ctx.user.id, + after_state: { isEnabled: input.isEnabled, repositorySelectionMode: selectionMode }, + }); + return { success: true }; }), @@ -482,6 +551,22 @@ export const securityAgentRouter = createTRPCRouter({ errors: result.errors, }); + logSecurityAudit({ + owner: securityOwner, + actor_id: ctx.user.id, + actor_email: ctx.user.google_user_email, + actor_name: ctx.user.google_user_name, + action: SecurityAuditLogAction.SyncTriggered, + resource_type: 'agent_config', + resource_id: ctx.user.id, + metadata: { + syncType: 'single_repo', + repoFullName: input.repoFullName, + synced: result.synced, + errors: result.errors, + }, + }); + return { success: true, synced: result.synced, @@ -527,6 +612,22 @@ export const securityAgentRouter = createTRPCRouter({ errors: result.errors, }); + logSecurityAudit({ + owner: securityOwner, + actor_id: ctx.user.id, + actor_email: ctx.user.google_user_email, + actor_name: ctx.user.google_user_name, + action: SecurityAuditLogAction.SyncTriggered, + resource_type: 'agent_config', + resource_id: ctx.user.id, + metadata: { + syncType: 'all_repos', + repoCount: repositoriesToSync.length, + synced: result.synced, + errors: result.errors, + }, + }); + return { success: true, synced: result.synced, @@ -615,6 +716,19 @@ export const securityAgentRouter = createTRPCRouter({ severity: finding.severity, }); + logSecurityAudit({ + owner: { userId: ctx.user.id }, + actor_id: ctx.user.id, + actor_email: ctx.user.google_user_email, + actor_name: ctx.user.google_user_name, + action: SecurityAuditLogAction.FindingDismissed, + resource_type: 'security_finding', + resource_id: input.findingId, + before_state: { status: finding.status }, + after_state: { status: 'ignored', ignoredReason: input.reason }, + metadata: { source: finding.source, severity: finding.severity }, + }); + return { success: true }; }), @@ -674,9 +788,8 @@ export const securityAgentRouter = createTRPCRouter({ const model = input.model || config?.config.model_slug || DEFAULT_SECURITY_AGENT_MODEL; const analysisMode = config?.config.analysis_mode ?? 'auto'; - let result; try { - result = await startSecurityAnalysis({ + const result = await startSecurityAnalysis({ findingId: input.findingId, user: ctx.user, githubRepo: finding.repo_full_name, @@ -686,18 +799,29 @@ export const securityAgentRouter = createTRPCRouter({ retrySandboxOnly: input.retrySandboxOnly, // Personal user - no organizationId }); - } catch (error) { - rethrowAsPaymentRequired(error); - } - if (!result.started) { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: result.error || 'Failed to start analysis', + if (!result.started) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: result.error || 'Failed to start analysis', + }); + } + + logSecurityAudit({ + owner: securityOwner, + actor_id: ctx.user.id, + actor_email: ctx.user.google_user_email, + actor_name: ctx.user.google_user_name, + action: SecurityAuditLogAction.FindingAnalysisStarted, + resource_type: 'security_finding', + resource_id: input.findingId, + metadata: { model, analysisMode, triageOnly: result.triageOnly }, }); - } - return { success: true, triageOnly: result.triageOnly }; + return { success: true, triageOnly: result.triageOnly }; + } catch (error) { + rethrowAsPaymentRequired(error); + } }), /** @@ -806,6 +930,17 @@ export const securityAgentRouter = createTRPCRouter({ repoFullName: input.repoFullName, }); + logSecurityAudit({ + owner: securityOwner, + actor_id: ctx.user.id, + actor_email: ctx.user.google_user_email, + actor_name: ctx.user.google_user_name, + action: SecurityAuditLogAction.FindingDeleted, + resource_type: 'security_finding', + resource_id: input.repoFullName, + metadata: { repoFullName: input.repoFullName, deletedCount: result.deletedCount }, + }); + return { success: true, deletedCount: result.deletedCount, @@ -827,6 +962,24 @@ export const securityAgentRouter = createTRPCRouter({ */ autoDismissEligible: baseProcedure.mutation(async ({ ctx }) => { const securityOwner: SecurityReviewOwner = { userId: ctx.user.id }; - return await autoDismissEligibleFindings(securityOwner, ctx.user.id); + const result = await autoDismissEligibleFindings(securityOwner, ctx.user.id); + + logSecurityAudit({ + owner: securityOwner, + actor_id: ctx.user.id, + actor_email: ctx.user.google_user_email, + actor_name: ctx.user.google_user_name, + action: SecurityAuditLogAction.FindingAutoDismissed, + resource_type: 'security_finding', + resource_id: 'bulk', + metadata: { + source: 'bulk', + dismissed: result.dismissed, + skipped: result.skipped, + errors: result.errors, + }, + }); + + return result; }), }); diff --git a/src/routers/security-audit-log-router.ts b/src/routers/security-audit-log-router.ts new file mode 100644 index 000000000..f8073099c --- /dev/null +++ b/src/routers/security-audit-log-router.ts @@ -0,0 +1,243 @@ +import * as z from 'zod'; +import { createTRPCRouter, baseProcedure } from '@/lib/trpc/init'; +import { security_audit_log } from '@/db/schema'; +import { db } from '@/lib/drizzle'; +import { + and, + eq, + lt, + gt, + desc, + asc, + count, + min, + max, + ilike, + gte, + lte, + inArray, + sql, +} from 'drizzle-orm'; +import { SecurityAuditLogAction } from '@/lib/security-agent/core/enums'; +import { + logSecurityAudit, + maskKiloAdminActors, +} from '@/lib/security-agent/services/audit-log-service'; + +const PAGE_SIZE = 100; +const MAX_EXPORT_ROWS = 10_000; + +const SecurityAuditLogActionSchema = z.nativeEnum(SecurityAuditLogAction); + +const ListSecurityAuditLogsInputSchema = z.object({ + before: z.string().datetime().optional(), + after: z.string().datetime().optional(), + action: z.array(SecurityAuditLogActionSchema).optional(), + actorEmail: z.string().email().optional(), + resourceType: z.string().max(100).optional(), + resourceId: z.string().max(500).optional(), + fuzzySearch: z.string().max(200).optional(), + startTime: z.string().datetime().optional(), + endTime: z.string().datetime().optional(), +}); + +const ExportInputSchema = z.object({ + format: z.enum(['csv', 'json']).default('json'), + startTime: z.string().datetime().optional(), + endTime: z.string().datetime().optional(), + action: z.array(SecurityAuditLogActionSchema).optional(), +}); + +export const securityAuditLogRouter = createTRPCRouter({ + list: baseProcedure.input(ListSecurityAuditLogsInputSchema).query(async ({ input, ctx }) => { + const { + before, + after, + action, + actorEmail, + resourceType, + resourceId, + fuzzySearch, + startTime, + endTime, + } = input; + + const whereConditions = [eq(security_audit_log.owned_by_user_id, ctx.user.id)]; + + if (action && action.length > 0) { + if (action.length === 1) { + whereConditions.push(eq(security_audit_log.action, action[0])); + } else { + whereConditions.push(inArray(security_audit_log.action, action)); + } + } + if (actorEmail) whereConditions.push(eq(security_audit_log.actor_email, actorEmail)); + if (resourceType) whereConditions.push(eq(security_audit_log.resource_type, resourceType)); + if (resourceId) whereConditions.push(eq(security_audit_log.resource_id, resourceId)); + if (fuzzySearch) { + const escapedSearch = fuzzySearch.replace(/[%_\\]/g, '\\$&'); + whereConditions.push( + ilike(sql`COALESCE(${security_audit_log.metadata}::text, '')`, `%${escapedSearch}%`) + ); + } + if (startTime) whereConditions.push(gte(security_audit_log.created_at, startTime)); + if (endTime) whereConditions.push(lte(security_audit_log.created_at, endTime)); + + if (before) whereConditions.push(lt(security_audit_log.created_at, before)); + if (after) whereConditions.push(gt(security_audit_log.created_at, after)); + + const orderBy = after + ? asc(security_audit_log.created_at) + : desc(security_audit_log.created_at); + + const logs = await db + .select({ + id: security_audit_log.id, + action: security_audit_log.action, + actor_id: security_audit_log.actor_id, + actor_email: security_audit_log.actor_email, + actor_name: security_audit_log.actor_name, + resource_type: security_audit_log.resource_type, + resource_id: security_audit_log.resource_id, + before_state: security_audit_log.before_state, + after_state: security_audit_log.after_state, + metadata: security_audit_log.metadata, + created_at: security_audit_log.created_at, + }) + .from(security_audit_log) + .where(and(...whereConditions)) + .orderBy(orderBy) + .limit(PAGE_SIZE + 1); + + const hasMore = logs.length > PAGE_SIZE; + const resultLogs = hasMore ? logs.slice(0, PAGE_SIZE) : logs; + + if (after) { + resultLogs.reverse(); + } + + const isKiloAdmin = ctx.user.google_user_email.endsWith('@kilocode.ai'); + const maskedLogs = maskKiloAdminActors(resultLogs, isKiloAdmin); + + const hasNext = hasMore; + const hasPrevious = !!after || (!!before && logs.length > 0); + + const oldestTimestamp = + maskedLogs.length > 0 + ? new Date(maskedLogs[maskedLogs.length - 1].created_at).toISOString() + : null; + const newestTimestamp = + maskedLogs.length > 0 ? new Date(maskedLogs[0].created_at).toISOString() : null; + + return { + logs: maskedLogs, + hasNext, + hasPrevious, + oldestTimestamp, + newestTimestamp, + }; + }), + + getActionTypes: baseProcedure.query(async () => { + return Object.values(SecurityAuditLogAction); + }), + + getSummary: baseProcedure.query(async ({ ctx }) => { + const [summary] = await db + .select({ + totalEvents: count(security_audit_log.id), + earliestEvent: min(security_audit_log.created_at), + latestEvent: max(security_audit_log.created_at), + }) + .from(security_audit_log) + .where(eq(security_audit_log.owned_by_user_id, ctx.user.id)); + + return { + totalEvents: summary.totalEvents || 0, + earliestEvent: summary.earliestEvent, + latestEvent: summary.latestEvent, + }; + }), + + export: baseProcedure.input(ExportInputSchema).mutation(async ({ input, ctx }) => { + const { format, startTime, endTime, action } = input; + + const whereConditions = [eq(security_audit_log.owned_by_user_id, ctx.user.id)]; + if (startTime) whereConditions.push(gte(security_audit_log.created_at, startTime)); + if (endTime) whereConditions.push(lte(security_audit_log.created_at, endTime)); + if (action && action.length > 0) { + if (action.length === 1) { + whereConditions.push(eq(security_audit_log.action, action[0])); + } else { + whereConditions.push(inArray(security_audit_log.action, action)); + } + } + + const logs = await db + .select({ + id: security_audit_log.id, + action: security_audit_log.action, + actor_id: security_audit_log.actor_id, + actor_email: security_audit_log.actor_email, + actor_name: security_audit_log.actor_name, + resource_type: security_audit_log.resource_type, + resource_id: security_audit_log.resource_id, + before_state: security_audit_log.before_state, + after_state: security_audit_log.after_state, + metadata: security_audit_log.metadata, + created_at: security_audit_log.created_at, + }) + .from(security_audit_log) + .where(and(...whereConditions)) + .orderBy(desc(security_audit_log.created_at)) + .limit(MAX_EXPORT_ROWS); + + // Log the export action itself + logSecurityAudit({ + owner: { userId: ctx.user.id }, + actor_id: ctx.user.id, + actor_email: ctx.user.google_user_email, + actor_name: ctx.user.google_user_name, + action: SecurityAuditLogAction.AuditLogExported, + resource_type: 'audit_log', + resource_id: ctx.user.id, + metadata: { format, rowCount: logs.length, startTime, endTime }, + }); + + const isKiloAdmin = ctx.user.google_user_email.endsWith('@kilocode.ai'); + const maskedLogs = maskKiloAdminActors(logs, isKiloAdmin); + + if (format === 'csv') { + const header = + 'id,timestamp,action,actor_id,actor_email,actor_name,resource_type,resource_id,before_state,after_state,metadata'; + const rows = maskedLogs.map(log => + [ + log.id, + log.created_at, + log.action, + log.actor_id ?? '', + log.actor_email ?? '', + log.actor_name ?? '', + log.resource_type, + log.resource_id, + log.before_state ? JSON.stringify(log.before_state) : '', + log.after_state ? JSON.stringify(log.after_state) : '', + log.metadata ? JSON.stringify(log.metadata) : '', + ] + .map(field => `"${String(field).replace(/"/g, '""')}"`) + .join(',') + ); + return { + format: 'csv' as const, + data: [header, ...rows].join('\n'), + rowCount: maskedLogs.length, + }; + } + + return { + format: 'json' as const, + data: JSON.stringify(maskedLogs, null, 2), + rowCount: maskedLogs.length, + }; + }), +});