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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ THROTTLE_TTL_MS=60000
THROTTLE_LIMIT=120
# Generate a unique 64-character hex key per environment: openssl rand -hex 32
TOKEN_ENCRYPTION_KEY=REPLACE_WITH_YOUR_OWN_64_HEX_CHARACTER_KEY
WORKLOAD_ATTESTATION_KEY=REPLACE_WITH_A_DISTINCT_64_HEX_WORKLOAD_ATTESTATION_KEY
PREFLIGHT_ATTESTATION_KEY=REPLACE_WITH_A_DISTINCT_64_HEX_PREFLIGHT_ATTESTATION_KEY
CREDENTIAL_LEASE_EXPIRY_INTERVAL_MS=60000
GITHUB_CLIENT_ID=github-client-id
GITHUB_CLIENT_SECRET=github-client-secret
GITHUB_APP_ID=
Expand Down
3 changes: 3 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ THROTTLE_TTL_MS=60000
THROTTLE_LIMIT=120
# Generate a unique 64-character hex key per environment: openssl rand -hex 32
TOKEN_ENCRYPTION_KEY=REPLACE_WITH_YOUR_OWN_64_HEX_CHARACTER_KEY
WORKLOAD_ATTESTATION_KEY=REPLACE_WITH_A_DISTINCT_64_HEX_WORKLOAD_ATTESTATION_KEY
PREFLIGHT_ATTESTATION_KEY=REPLACE_WITH_A_DISTINCT_64_HEX_PREFLIGHT_ATTESTATION_KEY
CREDENTIAL_LEASE_EXPIRY_INTERVAL_MS=60000
GITHUB_CLIENT_ID=github-client-id
GITHUB_CLIENT_SECRET=github-client-secret
GITHUB_APP_ID=
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
CREATE UNIQUE INDEX CONCURRENTLY "RepositoryBinding_id_tenantId_key"
ON "RepositoryBinding"("id", "tenantId");
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
CREATE UNIQUE INDEX CONCURRENTLY "ScanRequest_id_tenantId_repositoryBindingId_key"
ON "ScanRequest"("id", "tenantId", "repositoryBindingId");
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
CREATE TYPE "SastCredentialLeaseStatus" AS ENUM ('RESERVED', 'ISSUED', 'WIPED', 'REVOKED');

CREATE TABLE "SastRepositoryCredentialLease" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"repositoryBindingId" TEXT NOT NULL,
"scanRequestId" TEXT NOT NULL,
"attemptId" TEXT NOT NULL,
"workloadIdentityRef" TEXT NOT NULL,
"commitSha" TEXT NOT NULL,
"credentialFingerprint" TEXT,
"status" "SastCredentialLeaseStatus" NOT NULL DEFAULT 'RESERVED',
"issuedAt" TIMESTAMP(3) NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"wipedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "SastRepositoryCredentialLease_pkey" PRIMARY KEY ("id"),
CONSTRAINT "SastRepositoryCredentialLease_expiry_check"
CHECK ("expiresAt" > "issuedAt"),
CONSTRAINT "SastRepositoryCredentialLease_commit_sha_check"
CHECK ("commitSha" ~ '^([0-9a-f]{40}|[0-9a-f]{64})$'),
CONSTRAINT "SastRepositoryCredentialLease_fingerprint_check"
CHECK (
("status" = 'RESERVED' AND "credentialFingerprint" IS NULL)
OR
("status" IN ('ISSUED', 'WIPED') AND "credentialFingerprint" ~ '^sha256:[0-9a-f]{64}$')
OR
("status" = 'REVOKED' AND (
"credentialFingerprint" IS NULL
OR "credentialFingerprint" ~ '^sha256:[0-9a-f]{64}$'
))
),
CONSTRAINT "SastRepositoryCredentialLease_lifecycle_check"
CHECK (
("status" IN ('RESERVED', 'ISSUED')
AND "wipedAt" IS NULL
AND "revokedAt" IS NULL)
OR
("status" = 'WIPED'
AND "wipedAt" IS NOT NULL
AND "wipedAt" >= "issuedAt"
AND "revokedAt" IS NULL)
OR
("status" = 'REVOKED'
AND "revokedAt" IS NOT NULL
AND "revokedAt" >= "issuedAt"
AND "wipedAt" IS NULL)
)
);

CREATE UNIQUE INDEX "SastRepositoryCredentialLease_tenantId_attemptId_key"
ON "SastRepositoryCredentialLease"("tenantId", "attemptId");

CREATE INDEX "SastRepositoryCredentialLease_tenantId_scanRequestId_status_idx"
ON "SastRepositoryCredentialLease"("tenantId", "scanRequestId", "status");

CREATE INDEX "SastRepositoryCredentialLease_repositoryBindingId_status_idx"
ON "SastRepositoryCredentialLease"("repositoryBindingId", "status");

CREATE INDEX "SastRepositoryCredentialLease_expiresAt_status_idx"
ON "SastRepositoryCredentialLease"("expiresAt", "status");

ALTER TABLE "SastRepositoryCredentialLease"
ADD CONSTRAINT "SastRepositoryCredentialLease_tenantId_fkey"
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;

ALTER TABLE "SastRepositoryCredentialLease"
ADD CONSTRAINT "SastCredentialLease_repository_scope_fkey"
FOREIGN KEY ("repositoryBindingId", "tenantId")
REFERENCES "RepositoryBinding"("id", "tenantId") ON DELETE CASCADE ON UPDATE CASCADE;

ALTER TABLE "SastRepositoryCredentialLease"
ADD CONSTRAINT "SastCredentialLease_scan_scope_fkey"
FOREIGN KEY ("scanRequestId", "tenantId", "repositoryBindingId")
REFERENCES "ScanRequest"("id", "tenantId", "repositoryBindingId")
ON DELETE CASCADE ON UPDATE CASCADE;
73 changes: 56 additions & 17 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ enum ArchitectureScanStatus {
CANCELED
}

enum SastCredentialLeaseStatus {
RESERVED
ISSUED
WIPED
REVOKED
}

enum IsolationClass {
STANDARD
HARDENED
Expand Down Expand Up @@ -269,18 +276,19 @@ model Tenant {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

integrations ScmIntegration[]
repositoryBindings RepositoryBinding[]
scanRequests ScanRequest[]
scannerRuns ScannerRun[]
normalizedFindings NormalizedFinding[]
evidencePacks EvidencePack[]
policyDecisions PolicyDecision[]
aiAdvisoryMetadata AiAdvisoryMetadata[]
waivers Waiver[]
suppressions Suppression[]
auditEvents AuditEvent[]
users User[]
integrations ScmIntegration[]
repositoryBindings RepositoryBinding[]
scanRequests ScanRequest[]
scannerRuns ScannerRun[]
normalizedFindings NormalizedFinding[]
evidencePacks EvidencePack[]
policyDecisions PolicyDecision[]
aiAdvisoryMetadata AiAdvisoryMetadata[]
waivers Waiver[]
suppressions Suppression[]
auditEvents AuditEvent[]
sastCredentialLeases SastRepositoryCredentialLease[]
users User[]
}

model ScmIntegration {
Expand Down Expand Up @@ -316,10 +324,12 @@ model RepositoryBinding {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
integration ScmIntegration @relation(fields: [scmIntegrationId], references: [id], onDelete: Cascade)
scanRequests ScanRequest[]
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
integration ScmIntegration @relation(fields: [scmIntegrationId], references: [id], onDelete: Cascade)
scanRequests ScanRequest[]
sastCredentialLeases SastRepositoryCredentialLease[]

@@unique([id, tenantId])
@@unique([tenantId, scmIntegrationId, providerRepoId])
@@index([tenantId])
@@index([tenantId, status])
Expand All @@ -344,8 +354,8 @@ model ScanRequest {
completedAt DateTime?
updatedAt DateTime @updatedAt

tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId], references: [id], onDelete: Cascade)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId], references: [id], onDelete: Cascade)
scannerRuns ScannerRun[]
findings NormalizedFinding[]
evidencePacks EvidencePack[]
Expand All @@ -354,7 +364,9 @@ model ScanRequest {
suppressions Suppression[]
auditEvents AuditEvent[]
sastQueueReservation SastQueueReservation?
sastCredentialLeases SastRepositoryCredentialLease[]

@@unique([id, tenantId, repositoryBindingId])
@@index([tenantId])
@@index([repositoryBindingId])
@@index([status])
Expand All @@ -376,6 +388,33 @@ model SastQueueLedger {
reservations SastQueueReservation[]
}

model SastRepositoryCredentialLease {
id String @id
tenantId String
repositoryBindingId String
scanRequestId String
attemptId String
workloadIdentityRef String
commitSha String
credentialFingerprint String?
status SastCredentialLeaseStatus @default(RESERVED)
issuedAt DateTime
expiresAt DateTime
wipedAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId, tenantId], references: [id, tenantId], onDelete: Cascade, map: "SastCredentialLease_repository_scope_fkey")
scanRequest ScanRequest @relation(fields: [scanRequestId, tenantId, repositoryBindingId], references: [id, tenantId, repositoryBindingId], onDelete: Cascade, map: "SastCredentialLease_scan_scope_fkey")

@@unique([tenantId, attemptId])
@@index([tenantId, scanRequestId, status])
@@index([repositoryBindingId, status])
@@index([expiresAt, status])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

model SastQueueTenantUsage {
ledgerId String
tenantId String
Expand Down
65 changes: 2 additions & 63 deletions apps/api/src/config/config.module.ts
Original file line number Diff line number Diff line change
@@ -1,72 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { ConfigModule as NestConfigModule } from '@nestjs/config';
import * as Joi from 'joi';

import { ENV_FILE_PATHS } from './config.paths';
import { ENVIRONMENT_VALIDATION_SCHEMA } from './config.schema';
import { ConfigService } from './config.service';

export const ENVIRONMENT_VALIDATION_SCHEMA = Joi.object({
NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
PORT: Joi.number().port().default(3000),
DATABASE_URL: Joi.string().uri().required(),
REDIS_URL: Joi.string().uri().required(),
SESSION_SECRET: Joi.string().min(32).required(),
CSRF_SECRET: Joi.string().min(32).required(),
GITHUB_CLIENT_ID: Joi.string().required(),
GITHUB_CLIENT_SECRET: Joi.string().required(),
GITHUB_APP_ID: Joi.string().allow('').default(''),
GITHUB_APP_PRIVATE_KEY: Joi.string().allow('').default(''),
GITLAB_CLIENT_ID: Joi.string().required(),
GITLAB_CLIENT_SECRET: Joi.string().required(),
GITLAB_API_BASE_URL: Joi.string().uri().default('https://gitlab.com/api/v4'),
APP_URL: Joi.string().when('NODE_ENV', {
is: 'production',
then: Joi.string().uri({ scheme: ['https'] }).required(),
otherwise: Joi.string().uri().required()
}),
FRONTEND_URL: Joi.string().when('NODE_ENV', {
is: 'production',
then: Joi.string().uri({ scheme: ['https'] }).required(),
otherwise: Joi.string().uri().required()
}),
SESSION_COOKIE_NAME: Joi.string().default('connect.sid'),
CSRF_COOKIE_NAME: Joi.string().default('csrf_token'),
COOKIE_DOMAIN: Joi.string().allow('').optional(),
COOKIE_SECURE: Joi.string().when('NODE_ENV', {
is: 'production',
then: Joi.valid('true').required(),
otherwise: Joi.valid('true', 'false').default('false')
}),
SESSION_TTL_SECONDS: Joi.number().integer().min(900).max(86_400).default(28_800),
THROTTLE_TTL_MS: Joi.number().integer().min(1_000).max(3_600_000).default(60_000),
THROTTLE_LIMIT: Joi.number().integer().min(1).max(10_000).default(120),
TOKEN_ENCRYPTION_KEY: Joi.string().length(64).required(),
ANALYSIS_CLIENT_MODE: Joi.string().valid('mock', 'internal').default('mock'),
AI_SERVER_URL: Joi.string().uri().default('http://localhost:8000'),
USE_INTERNAL_AI: Joi.string().valid('true', 'false').default('false'),
AI_ADVISORY_TIMEOUT_MS: Joi.number().integer().positive().default(2500),
INTERNAL_API_SECRET: Joi.when('NODE_ENV', {
is: 'production',
then: Joi.string().min(32).required(),
otherwise: Joi.string().allow('').default('')
}),
GITHUB_APP_WEBHOOK_SECRET: Joi.when('NODE_ENV', {
is: 'production',
then: Joi.string().min(32).required(),
otherwise: Joi.string().allow('').default('')
}),
GITLAB_WEBHOOK_SECRET: Joi.when('NODE_ENV', {
is: 'production',
then: Joi.string().min(32).required(),
otherwise: Joi.string().allow('').default('')
}),
REPORT_STORAGE_PATH: Joi.string().default('./tmp/reports'),
REPORT_EXPIRY_HOURS: Joi.number().integer().positive().default(24),
REPORT_EXPIRY_INTERVAL_MS: Joi.number().integer().positive().default(15 * 60 * 1000),
EVIDENCE_STORAGE_PATH: Joi.string().default('./tmp/evidence'),
EVIDENCE_EXPIRY_INTERVAL_MS: Joi.number().integer().positive().default(15 * 60 * 1000),
TEAMS_WEBHOOK_URL: Joi.string().uri().allow('').optional()
});
export { ENVIRONMENT_VALIDATION_SCHEMA } from './config.schema';

@Global()
@Module({
Expand Down
101 changes: 101 additions & 0 deletions apps/api/src/config/config.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import * as Joi from 'joi';

export const ENVIRONMENT_VALIDATION_SCHEMA = Joi.object({
NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
PORT: Joi.number().port().default(3000),
DATABASE_URL: Joi.string().uri().required(),
REDIS_URL: Joi.string().uri().required(),
SESSION_SECRET: Joi.string().min(32).required(),
CSRF_SECRET: Joi.string().min(32).required(),
GITHUB_CLIENT_ID: Joi.string().required(),
GITHUB_CLIENT_SECRET: Joi.string().required(),
GITHUB_APP_ID: Joi.string().allow('').default(''),
GITHUB_APP_PRIVATE_KEY: Joi.string().allow('').default(''),
GITLAB_CLIENT_ID: Joi.string().required(),
GITLAB_CLIENT_SECRET: Joi.string().required(),
GITLAB_API_BASE_URL: Joi.string().uri().default('https://gitlab.com/api/v4'),
APP_URL: Joi.string().when('NODE_ENV', {
is: 'production',
then: Joi.string().uri({ scheme: ['https'] }).required(),
otherwise: Joi.string().uri().required()
}),
FRONTEND_URL: Joi.string().when('NODE_ENV', {
is: 'production',
then: Joi.string().uri({ scheme: ['https'] }).required(),
otherwise: Joi.string().uri().required()
}),
SESSION_COOKIE_NAME: Joi.string().default('connect.sid'),
CSRF_COOKIE_NAME: Joi.string().default('csrf_token'),
COOKIE_DOMAIN: Joi.string().allow('').optional(),
COOKIE_SECURE: Joi.string().when('NODE_ENV', {
is: 'production',
then: Joi.valid('true').required(),
otherwise: Joi.valid('true', 'false').default('false')
}),
SESSION_TTL_SECONDS: Joi.number().integer().min(900).max(86_400).default(28_800),
THROTTLE_TTL_MS: Joi.number().integer().min(1_000).max(3_600_000).default(60_000),
THROTTLE_LIMIT: Joi.number().integer().min(1).max(10_000).default(120),
TOKEN_ENCRYPTION_KEY: Joi.string().hex().length(64).lowercase().required(),
WORKLOAD_ATTESTATION_KEY: Joi.when('NODE_ENV', {
is: 'production',
then: Joi.string()
.hex()
.length(64)
.lowercase()
.invalid(Joi.ref('TOKEN_ENCRYPTION_KEY'))
Comment thread
goodtu02 marked this conversation as resolved.
.required(),
otherwise: Joi.string()
.hex()
.length(64)
.lowercase()
.invalid(Joi.ref('TOKEN_ENCRYPTION_KEY'))
.default('a'.repeat(64))
}),
PREFLIGHT_ATTESTATION_KEY: Joi.when('NODE_ENV', {
is: 'production',
then: Joi.string()
.hex()
.length(64)
.lowercase()
.invalid(Joi.ref('TOKEN_ENCRYPTION_KEY'))
.invalid(Joi.ref('WORKLOAD_ATTESTATION_KEY'))
.required(),
otherwise: Joi.string()
.hex()
.length(64)
.lowercase()
.invalid(Joi.ref('TOKEN_ENCRYPTION_KEY'))
.invalid(Joi.ref('WORKLOAD_ATTESTATION_KEY'))
.default('b'.repeat(64))
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
CREDENTIAL_LEASE_EXPIRY_INTERVAL_MS: Joi.number()
.integer()
.min(1_000)
.max(3_600_000)
.default(60_000),
ANALYSIS_CLIENT_MODE: Joi.string().valid('mock', 'internal').default('mock'),
AI_SERVER_URL: Joi.string().uri().default('http://localhost:8000'),
USE_INTERNAL_AI: Joi.string().valid('true', 'false').default('false'),
AI_ADVISORY_TIMEOUT_MS: Joi.number().integer().positive().default(2500),
INTERNAL_API_SECRET: Joi.when('NODE_ENV', {
is: 'production',
then: Joi.string().min(32).required(),
otherwise: Joi.string().allow('').default('')
}),
GITHUB_APP_WEBHOOK_SECRET: Joi.when('NODE_ENV', {
is: 'production',
then: Joi.string().min(32).required(),
otherwise: Joi.string().allow('').default('')
}),
GITLAB_WEBHOOK_SECRET: Joi.when('NODE_ENV', {
is: 'production',
then: Joi.string().min(32).required(),
otherwise: Joi.string().allow('').default('')
}),
REPORT_STORAGE_PATH: Joi.string().default('./tmp/reports'),
REPORT_EXPIRY_HOURS: Joi.number().integer().positive().default(24),
REPORT_EXPIRY_INTERVAL_MS: Joi.number().integer().positive().default(15 * 60 * 1000),
EVIDENCE_STORAGE_PATH: Joi.string().default('./tmp/evidence'),
EVIDENCE_EXPIRY_INTERVAL_MS: Joi.number().integer().positive().default(15 * 60 * 1000),
TEAMS_WEBHOOK_URL: Joi.string().uri().allow('').optional()
});
Loading
Loading