Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions SECURITY.md
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)
27 changes: 25 additions & 2 deletions SECURITY_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
| --- | -------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2.1 | Schema uses `postgresql` provider | ✅ | `apps/api/prisma/schema.prisma` line 6. |
| 2.2 | TLS enforced on DB connections in production | 🔒 | `server.ts` startup guard rejects `DATABASE_URL` without `sslmode=require\|verify-full\|verify-ca` when `NODE_ENV=production`. |
| 2.3 | Encryption at rest on DB volume | 📋 | Must be verified on the hosting provider (Render, AWS RDS, Supabase, etc.). All major providers support this — enable it. |
| 2.3 | Encryption at rest on DB volume | 📋 | Must be verified on the hosting provider (Render, AWS RDS, Supabase, etc.). Capture evidence using `docs/ops/db-security-evidence.md` and store the exported proof in private compliance storage. |
| 2.4 | Separate DB credentials per environment | 📋 | Production, staging, and development must use distinct credentials with least-privilege grants. |
| 2.5 | DB user has minimal required permissions | 📋 | Production DB user should have `SELECT, INSERT, UPDATE` only — no `DROP`, `CREATE`, or superuser. Prisma Migrate should use a separate privileged user. |
| 2.6 | Connection pooling configured | 📋 | Use PgBouncer or Prisma Accelerate for connection management in production. |
Expand Down Expand Up @@ -94,6 +94,29 @@ These cannot be verified in code and require manual confirmation:
| 7.7 | **Separate staging/prod credentials** | Ops | Create distinct DB users and API keys per environment |
| 7.8 | **Pre-commit secret scanning** | Dev | Install `git-secrets` or `trufflehog` as pre-commit hook (since GitHub secret scanning requires Enterprise) |

### 7.A Rotation Evidence And Cadence

Rotation policy:

- rotate exposed or suspected-exposed secrets immediately
- rotate standing secrets at least every 90 days unless a stricter provider or customer obligation applies
- record the operator, timestamp, and validation outcome for every rotation event

Store rotation evidence in:

- Vanta
- private compliance storage
- a private audit repository

Recommended evidence bundle for each rotated secret:

| Secret | Cadence | Evidence Required | Evidence Location |
| --- | --- | --- | --- |
| `ATTOM_API_KEY` | Immediate if exposed, otherwise every 90 days | provider rotation log, redacted screenshot, post-rotation smoke test result | Vanta or private audit repository |
| `OPENAI_API_KEY` | Immediate if exposed, otherwise every 90 days | provider rotation log, redacted screenshot, post-rotation smoke test result | Vanta or private audit repository |
| `PRIVATE_KEY` | Immediate if exposed, otherwise on key-management schedule | key replacement record, redeploy confirmation, receipt verification sample | private audit repository |
| `DATABASE_URL` / DB password | Immediate if exposed, otherwise every 90 days | password rotation record, redeploy confirmation, database connectivity proof | Vanta or private audit repository |

---

_Last updated: 2026-02-18T17:25 CST by security remediation session._
_Last updated: 2026-03-20T00:00 CST by SOC 2 remediation session._
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");
21 changes: 21 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,27 @@ model VerificationRecord {
@@index([apiKeyId, createdAt])
}

model WorkflowEvent {
id String @id @default(cuid())
workflowId String
timestamp DateTime @default(now())
operator String
action String
bundleId String?
decision String?
receiptId String?
eventType String
runId String?
artifactId String?
packageId String?
classification String?
reason String?
payload Json
createdAt DateTime @default(now())

@@index([workflowId, timestamp])
}

model Receipt {
id String @id @default(uuid())
receiptHash String
Expand Down
20 changes: 20 additions & 0 deletions apps/api/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,24 @@ export async function ensureDatabase(prisma: PrismaClient) {
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" TIMESTAMP(3)
)`,
`CREATE TABLE IF NOT EXISTS "WorkflowEvent" (
"id" TEXT PRIMARY KEY,
"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
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS "RegistryCache_sourceId_subjectHash_key"
ON "RegistryCache" ("sourceId", "subjectHash")`,
`CREATE INDEX IF NOT EXISTS "RegistryCache_expiresAt_idx"
Expand All @@ -103,6 +121,8 @@ export async function ensureDatabase(prisma: PrismaClient) {
ON "RegistryOracleJob" ("sourceId", "createdAt")`,
`CREATE INDEX IF NOT EXISTS "RegistryOracleJob_status_idx"
ON "RegistryOracleJob" ("status")`,
`CREATE INDEX IF NOT EXISTS "WorkflowEvent_workflowId_timestamp_idx"
ON "WorkflowEvent" ("workflowId", "timestamp")`,
`CREATE INDEX IF NOT EXISTS "RegistrySource_active_idx"
ON "RegistrySource" ("active")`
];
Expand Down
74 changes: 74 additions & 0 deletions apps/api/src/env.ts
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;
}
42 changes: 41 additions & 1 deletion apps/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);

Expand Down Expand Up @@ -803,6 +809,7 @@ class BlockchainVerifier {
type BuildServerOptions = {
fetchImpl?: typeof fetch;
logger?: boolean | Record<string, unknown>;
workflowEventSink?: WorkflowEventSink;
};

type VerifyRouteInput = BundleInput & {
Expand All @@ -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
Expand Down Expand Up @@ -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
});
Comment on lines +933 to +940
Copy link

Copilot AI Mar 20, 2026

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 WorkflowEventPrismaDelegate shape. This bypasses Prisma’s generated types (especially around payload: Json and orderBy options) 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 real findMany args), which would also allow multi-column orderBy without widening casts.

Copilot uses AI. Check for mistakes.

const dbOptionalRoutes = new Set([
'/api/v1/health',
'/api/v1/status',
Expand All @@ -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',
Expand Down Expand Up @@ -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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow event lookup when workflow state is not in memory

This handler blocks retrieval of persisted audit events by requiring workflowService.getWorkflowState(...) to exist first, but WorkflowService uses an in-memory store, so any server restart (or querying from another API instance) causes valid WorkflowEvent rows to return 404 workflow_not_found even though they were successfully written to Postgres. Because this commit’s main feature is durable workflow audit history, this guard makes historical evidence effectively inaccessible in normal multi-instance/restart scenarios.

Useful? React with 👍 / 👎.


Comment on lines +1079 to +1083
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The events endpoint currently returns 404 unless the workflow exists in the in-memory WorkflowService store. That means persisted audit events in Prisma become non-queryable after a process restart (or if the workflow was created by another instance), which undermines the purpose of persisting the audit trail. Consider removing the in-memory state check here, or validating existence via persisted data (e.g., WorkflowEvent rows or a persisted Workflow table) and returning an empty list when no events exist.

Suggested change
const state = workflowService.getWorkflowState(parsed.data.workflowId);
if (!state) {
return reply.code(404).send({ error: 'workflow_not_found' });
}

Copilot uses AI. Check for mistakes.
const events = await workflowEventSink.listByWorkflow(parsed.data.workflowId);
return reply.send({
workflowId: parsed.data.workflowId,
events
});
});

app.get('/api/v1/workflows/:workflowId/evidence-package', {
preHandler: [requireApiKeyScope(securityConfig, 'read')],
config: { rateLimit: perApiKeyRateLimit }
Expand Down
58 changes: 58 additions & 0 deletions apps/api/src/workflow.events.test.ts
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'
});
});
});
Loading
Loading