-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add soc2 audit trail and response docs #67
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| # Security Policy | ||
|
|
||
| ## Reporting A Vulnerability | ||
|
|
||
| Report suspected vulnerabilities or secret exposure to `security@trustsignal.dev`. | ||
|
|
||
| - Include the affected repository, environment, and any known receipt IDs, workflow IDs, or request IDs. | ||
| - Do not post sensitive findings in public issues. | ||
| - Use private evidence storage for screenshots, logs, or provider console exports. | ||
|
|
||
| ## Response Expectations | ||
|
|
||
| - Acknowledge receipt within 3 business days. | ||
| - Triage severity and containment path before broad disclosure. | ||
| - Coordinate remediation and external communication through the incident response plan. | ||
|
|
||
| ## Related Documentation | ||
|
|
||
| - [Repository security guidance](docs/SECURITY.md) | ||
| - [Incident response plan](docs/INCIDENT_RESPONSE_PLAN.md) | ||
| - [Security workflows](docs/security-workflows.md) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| CREATE TABLE "WorkflowEvent" ( | ||
| "id" TEXT NOT NULL, | ||
| "workflowId" TEXT NOT NULL, | ||
| "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| "operator" TEXT NOT NULL, | ||
| "action" TEXT NOT NULL, | ||
| "bundleId" TEXT, | ||
| "decision" TEXT, | ||
| "receiptId" TEXT, | ||
| "eventType" TEXT NOT NULL, | ||
| "runId" TEXT, | ||
| "artifactId" TEXT, | ||
| "packageId" TEXT, | ||
| "classification" TEXT, | ||
| "reason" TEXT, | ||
| "payload" JSONB NOT NULL, | ||
| "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
|
|
||
| CONSTRAINT "WorkflowEvent_pkey" PRIMARY KEY ("id") | ||
| ); | ||
|
|
||
| CREATE INDEX "WorkflowEvent_workflowId_timestamp_idx" | ||
| ON "WorkflowEvent"("workflowId", "timestamp"); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import { existsSync, readFileSync } from 'node:fs'; | ||
| import path from 'node:path'; | ||
|
|
||
| let runtimeEnvLoaded = false; | ||
|
|
||
| function parseEnvFile(contents: string): Record<string, string> { | ||
| const values: Record<string, string> = {}; | ||
|
|
||
| for (const rawLine of contents.split(/\r?\n/u)) { | ||
| const line = rawLine.trim(); | ||
| if (!line || line.startsWith('#')) { | ||
| continue; | ||
| } | ||
|
|
||
| const separatorIndex = line.indexOf('='); | ||
| if (separatorIndex <= 0) { | ||
| continue; | ||
| } | ||
|
|
||
| const key = line.slice(0, separatorIndex).trim(); | ||
| let value = line.slice(separatorIndex + 1).trim(); | ||
|
|
||
| if ( | ||
| (value.startsWith('"') && value.endsWith('"')) || | ||
| (value.startsWith("'") && value.endsWith("'")) | ||
| ) { | ||
| value = value.slice(1, -1); | ||
| } | ||
|
|
||
| values[key] = value; | ||
| } | ||
|
|
||
| return values; | ||
| } | ||
|
|
||
| export function loadRuntimeEnv(): void { | ||
| if (runtimeEnvLoaded) { | ||
| return; | ||
| } | ||
|
|
||
| const envFiles = [ | ||
| path.resolve(process.cwd(), '.env'), | ||
| path.resolve(process.cwd(), '../../.env') | ||
| ]; | ||
|
|
||
| for (const envFile of envFiles) { | ||
| if (!existsSync(envFile)) { | ||
| continue; | ||
| } | ||
|
|
||
| const parsed = parseEnvFile(readFileSync(envFile, 'utf8')); | ||
| for (const [key, value] of Object.entries(parsed)) { | ||
| if (process.env[key] === undefined) { | ||
| process.env[key] = value; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| runtimeEnvLoaded = true; | ||
| } | ||
|
|
||
| export function resolveDatabaseUrl(env: NodeJS.ProcessEnv = process.env): string | undefined { | ||
| const databaseUrl = | ||
| env.DATABASE_URL || | ||
| env.SUPABASE_DB_URL || | ||
| env.SUPABASE_POOLER_URL || | ||
| env.SUPABASE_DIRECT_URL; | ||
|
|
||
| if (databaseUrl && !env.DATABASE_URL) { | ||
| env.DATABASE_URL = databaseUrl; | ||
| } | ||
|
|
||
| return env.DATABASE_URL; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -60,6 +60,11 @@ import { | |||||||||
| verifyRevocationHeaders | ||||||||||
| } from './security.js'; | ||||||||||
| import { isWorkflowError } from './workflow/errors.js'; | ||||||||||
| import { | ||||||||||
| NoopWorkflowEventSink, | ||||||||||
| PrismaWorkflowEventSink, | ||||||||||
| type WorkflowEventSink | ||||||||||
| } from './workflow/events.js'; | ||||||||||
| import { WorkflowService } from './workflow/service.js'; | ||||||||||
| import { | ||||||||||
| readinessWorkflowRequestSchema, | ||||||||||
|
|
@@ -77,6 +82,7 @@ const REQUEST_START = Symbol('requestStartMs'); | |||||||||
| type RequestTimerState = { | ||||||||||
| [REQUEST_START]?: number; | ||||||||||
| }; | ||||||||||
| type PrismaWorkflowEventDelegate = ConstructorParameters<typeof PrismaWorkflowEventSink>[0]; | ||||||||||
| const NOTARY_STATUSES = ['ACTIVE', 'SUSPENDED', 'REVOKED', 'UNKNOWN'] as const; | ||||||||||
| const registrySourceIdEnum = z.enum(REGISTRY_SOURCE_IDS); | ||||||||||
|
|
||||||||||
|
|
@@ -803,6 +809,7 @@ class BlockchainVerifier { | |||||||||
| type BuildServerOptions = { | ||||||||||
| fetchImpl?: typeof fetch; | ||||||||||
| logger?: boolean | Record<string, unknown>; | ||||||||||
| workflowEventSink?: WorkflowEventSink; | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| type VerifyRouteInput = BundleInput & { | ||||||||||
|
|
@@ -817,7 +824,6 @@ export async function buildServer(options: BuildServerOptions = {}) { | |||||||||
| requireProductionVerifierConfig(); | ||||||||||
| const app = Fastify({ logger: options.logger ?? true }); | ||||||||||
| const securityConfig = buildSecurityConfig(); | ||||||||||
| const workflowService = new WorkflowService(); | ||||||||||
| const propertyApiKey = resolvePropertyApiKey(); | ||||||||||
| const registryAdapterService = createRegistryAdapterService(prisma, { | ||||||||||
| fetchImpl: options.fetchImpl | ||||||||||
|
|
@@ -921,6 +927,18 @@ export async function buildServer(options: BuildServerOptions = {}) { | |||||||||
| ); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const workflowEventSink = | ||||||||||
| options.workflowEventSink ?? | ||||||||||
| (databaseReady | ||||||||||
| ? new PrismaWorkflowEventSink( | ||||||||||
| (prisma as PrismaClient & { workflowEvent: PrismaWorkflowEventDelegate }).workflowEvent, | ||||||||||
| app.log | ||||||||||
| ) | ||||||||||
| : new NoopWorkflowEventSink()); | ||||||||||
| const workflowService = new WorkflowService(undefined, { | ||||||||||
| eventSink: workflowEventSink | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| const dbOptionalRoutes = new Set([ | ||||||||||
| '/api/v1/health', | ||||||||||
| '/api/v1/status', | ||||||||||
|
|
@@ -930,6 +948,7 @@ export async function buildServer(options: BuildServerOptions = {}) { | |||||||||
| '/api/v1/workflows/readiness-audit', | ||||||||||
| '/api/v1/workflows', | ||||||||||
| '/api/v1/workflows/:workflowId', | ||||||||||
| '/api/v1/workflows/:workflowId/events', | ||||||||||
| '/api/v1/workflows/:workflowId/evidence-package', | ||||||||||
| '/api/v1/workflows/:workflowId/artifacts', | ||||||||||
| '/api/v1/workflows/:workflowId/artifacts/:artifactId/verify', | ||||||||||
|
|
@@ -1048,6 +1067,27 @@ export async function buildServer(options: BuildServerOptions = {}) { | |||||||||
| return reply.send(state); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| app.get('/api/v1/workflows/:workflowId/events', { | ||||||||||
| preHandler: [requireApiKeyScope(securityConfig, 'read')], | ||||||||||
| config: { rateLimit: perApiKeyRateLimit } | ||||||||||
| }, async (request, reply) => { | ||||||||||
| const parsed = workflowParamsSchema.safeParse(request.params); | ||||||||||
| if (!parsed.success) { | ||||||||||
| return reply.code(400).send({ error: 'invalid_workflow_id' }); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const state = workflowService.getWorkflowState(parsed.data.workflowId); | ||||||||||
| if (!state) { | ||||||||||
| return reply.code(404).send({ error: 'workflow_not_found' }); | ||||||||||
| } | ||||||||||
|
Comment on lines
+1079
to
+1082
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This handler blocks retrieval of persisted audit events by requiring Useful? React with 👍 / 👎. |
||||||||||
|
|
||||||||||
|
Comment on lines
+1079
to
+1083
|
||||||||||
| const state = workflowService.getWorkflowState(parsed.data.workflowId); | |
| if (!state) { | |
| return reply.code(404).send({ error: 'workflow_not_found' }); | |
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { PrismaWorkflowEventSink, type StoredWorkflowEvent } from './workflow/events.js'; | ||
|
|
||
| describe('PrismaWorkflowEventSink', () => { | ||
| it('persists normalized workflow audit events and returns them in timestamp order', async () => { | ||
| const rows: Array<Record<string, unknown>> = []; | ||
| const create = vi.fn(async ({ data }: { data: Record<string, unknown> }) => { | ||
| const row = { | ||
| id: `event-${rows.length + 1}`, | ||
| ...data | ||
| }; | ||
| rows.push(row); | ||
| return row; | ||
| }); | ||
| const findMany = vi.fn(async () => rows); | ||
|
|
||
| const sink = new PrismaWorkflowEventSink({ | ||
| create, | ||
| findMany | ||
| }); | ||
|
|
||
| sink.record({ | ||
| type: 'workflow.created', | ||
| workflowId: 'workflow-1', | ||
| actor: 'operator@trustsignal.test', | ||
| timestamp: '2026-03-20T05:00:00.000Z' | ||
| }); | ||
| sink.record({ | ||
| type: 'workflow.release.evaluated', | ||
| workflowId: 'workflow-1', | ||
| artifactId: 'artifact-1', | ||
| actor: 'operator@trustsignal.test', | ||
| target: 'customer_shareable', | ||
| timestamp: '2026-03-20T05:00:01.000Z', | ||
| allowed: false | ||
| }); | ||
|
|
||
| const events = await sink.listByWorkflow('workflow-1'); | ||
|
|
||
| expect(create).toHaveBeenCalledTimes(2); | ||
| expect(findMany).toHaveBeenCalledWith({ | ||
| where: { workflowId: 'workflow-1' }, | ||
| orderBy: { timestamp: 'asc' } | ||
| }); | ||
|
|
||
| const [createdEvent, decisionEvent] = events as StoredWorkflowEvent[]; | ||
| expect(createdEvent.action).toBe('workflow.created'); | ||
| expect(createdEvent.operator).toBe('operator@trustsignal.test'); | ||
| expect(createdEvent.bundleId).toBeNull(); | ||
| expect(decisionEvent.bundleId).toBe('artifact-1'); | ||
| expect(decisionEvent.decision).toBe('block'); | ||
| expect(decisionEvent.payload).toMatchObject({ | ||
| type: 'workflow.release.evaluated', | ||
| artifactId: 'artifact-1' | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Prisma delegate is being force-cast to a custom
WorkflowEventPrismaDelegateshape. This bypasses Prisma’s generated types (especially aroundpayload: JsonandorderByoptions) and can hide schema/type mismatches at compile time. Prefer typing the sink against Prisma’s generated types (e.g.,PrismaClient['workflowEvent'],Prisma.WorkflowEventCreateInput, and the realfindManyargs), which would also allow multi-columnorderBywithout widening casts.