From b4492d1fa229295d51a2f355632f19a098677f81 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 22 Apr 2026 22:39:30 -0400 Subject: [PATCH 1/4] fix(integration-platform): fail Dependabot check on open high/critical alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GitHub "Dependabot Security Updates Enabled" check previously passed whenever Dependabot was enabled on a repo, even when open high- or critical-severity alerts were waiting — hiding a real compliance risk behind a green checkmark. It now: - Accepts an `alert_severity_threshold` variable (default: `high`) so CX can configure what counts as a failure per connection. - Fails the check for the repo when open alerts at or above the threshold exist, using the highest actual severity present as the finding severity, and pointing the user at the repo's /security/dependabot page for remediation. - Preserves the transparent fallback when alert data cannot be fetched (e.g. 403) — no alert signal means no pass/fail regression. Severity helpers split into `dependabot-alert-severity.ts` for focused unit testing; the main check stays a single-responsibility orchestrator. --- .../checks/__tests__/dependabot.test.ts | 308 ++++++++++++++++++ .../checks/dependabot-alert-severity.ts | 72 ++++ .../src/manifests/github/checks/dependabot.ts | 64 +++- .../src/manifests/github/variables.ts | 20 ++ 4 files changed, 448 insertions(+), 16 deletions(-) create mode 100644 packages/integration-platform/src/manifests/github/checks/__tests__/dependabot.test.ts create mode 100644 packages/integration-platform/src/manifests/github/checks/dependabot-alert-severity.ts diff --git a/packages/integration-platform/src/manifests/github/checks/__tests__/dependabot.test.ts b/packages/integration-platform/src/manifests/github/checks/__tests__/dependabot.test.ts new file mode 100644 index 0000000000..8625e58b72 --- /dev/null +++ b/packages/integration-platform/src/manifests/github/checks/__tests__/dependabot.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it } from 'bun:test'; +import type { CheckContext, CheckResult, CheckVariableValues } from '../../../../types'; +import type { GitHubDependabotAlert, GitHubRepo } from '../../types'; +import { dependabotCheck } from '../dependabot'; +import { + countAtOrAboveSeverity, + highestPresentSeverity, + resolveSeverityThreshold, + thresholdLabel, +} from '../dependabot-alert-severity'; + +type AlertSeverity = 'critical' | 'high' | 'medium' | 'low'; + +interface RepoFixture { + full_name: string; + name: string; + html_url: string; + dependabotStatus: 'enabled' | 'paused' | 'disabled' | 'unknown'; + openAlertSeverities: AlertSeverity[]; + fixedCount?: number; + dismissedCount?: number; + alertsFetchFails?: boolean; +} + +interface RunResult { + passed: Array<{ resourceId: string; title: string; description: string }>; + failed: Array<{ + resourceId: string; + title: string; + description: string; + severity: CheckResult['severity']; + }>; +} + +const makeRepo = (fixture: RepoFixture): GitHubRepo => + ({ + id: 1, + name: fixture.name, + full_name: fixture.full_name, + private: false, + html_url: fixture.html_url, + default_branch: 'main', + owner: { login: fixture.full_name.split('/')[0]!, type: 'Organization' }, + }) as GitHubRepo; + +const makeAlert = (severity: AlertSeverity): GitHubDependabotAlert => + ({ + number: Math.floor(Math.random() * 10000), + state: 'open', + security_vulnerability: { severity }, + }) as unknown as GitHubDependabotAlert; + +async function runCheck( + fixtures: RepoFixture[], + variables: CheckVariableValues, +): Promise { + const passed: RunResult['passed'] = []; + const failed: RunResult['failed'] = []; + + const byFullName = new Map(fixtures.map((f) => [f.full_name, f])); + + const ctx: CheckContext = { + accessToken: 'tok', + credentials: {}, + variables, + connectionId: 'conn_1', + organizationId: 'org_1', + metadata: {}, + log: () => {}, + warn: () => {}, + pass: (result) => { + passed.push({ + resourceId: result.resourceId ?? '', + title: result.title, + description: result.description, + }); + }, + fail: (result) => { + failed.push({ + resourceId: result.resourceId ?? '', + title: result.title, + description: result.description, + severity: result.severity, + }); + }, + fetch: (async (path: string): Promise => { + // /repos// + const repoMatch = path.match(/^\/repos\/([^/]+\/[^/]+)$/); + if (repoMatch) { + const fixture = byFullName.get(repoMatch[1]!); + if (!fixture) throw new Error(`404 ${path}`); + return makeRepo(fixture) as unknown as T; + } + // /repos///automated-security-fixes + const statusMatch = path.match(/^\/repos\/([^/]+\/[^/]+)\/automated-security-fixes$/); + if (statusMatch) { + const fixture = byFullName.get(statusMatch[1]!); + if (!fixture) throw new Error(`404 ${path}`); + if (fixture.dependabotStatus === 'unknown') throw new Error('403 Forbidden'); + if (fixture.dependabotStatus === 'disabled') throw new Error('404 Not Found'); + return { + enabled: true, + paused: fixture.dependabotStatus === 'paused', + } as unknown as T; + } + throw new Error(`Unexpected fetch: ${path}`); + }) as CheckContext['fetch'], + fetchAllPages: (async () => []) as CheckContext['fetchAllPages'], + fetchWithLinkHeader: (async ( + path: string, + options?: { params?: Record }, + ): Promise => { + const alertsMatch = path.match(/^\/repos\/([^/]+\/[^/]+)\/dependabot\/alerts$/); + if (!alertsMatch) throw new Error(`Unexpected fetchWithLinkHeader: ${path}`); + const fixture = byFullName.get(alertsMatch[1]!); + if (!fixture) throw new Error(`404 ${path}`); + if (fixture.alertsFetchFails) throw new Error('403 Forbidden'); + const state = options?.params?.state; + if (state === 'open') { + return fixture.openAlertSeverities.map(makeAlert) as unknown as T[]; + } + if (state === 'fixed') { + return (Array(fixture.fixedCount ?? 0).fill(makeAlert('low')) as unknown) as T[]; + } + if (state === 'dismissed') { + return (Array(fixture.dismissedCount ?? 0).fill(makeAlert('low')) as unknown) as T[]; + } + return [] as unknown as T[]; + }) as CheckContext['fetchWithLinkHeader'], + fetchWithCursor: (async () => []) as CheckContext['fetchWithCursor'], + graphql: (async () => ({})) as CheckContext['graphql'], + getState: (async () => null) as CheckContext['getState'], + setState: (async () => {}) as CheckContext['setState'], + } as CheckContext; + + await dependabotCheck.run(ctx); + return { passed, failed }; +} + +const repo = (fullName: string, overrides: Partial = {}): RepoFixture => ({ + full_name: fullName, + name: fullName.split('/')[1]!, + html_url: `https://github.com/${fullName}`, + dependabotStatus: 'enabled', + openAlertSeverities: [], + ...overrides, +}); + +describe('dependabotCheck severity gating', () => { + it('passes when Dependabot is enabled and there are zero open alerts', async () => { + const result = await runCheck([repo('acme/api')], { + target_repos: ['acme/api'], + }); + expect(result.passed.map((p) => p.title)).toEqual(['Dependabot enabled on api']); + expect(result.failed).toEqual([]); + }); + + it('fails when Dependabot is enabled but open high alerts exist (default threshold)', async () => { + // This is the exact bug reported: 8 high alerts should fail, not pass. + const result = await runCheck( + [ + repo('acme/api', { + openAlertSeverities: Array(8).fill('high'), + }), + ], + { target_repos: ['acme/api'] }, + ); + expect(result.passed).toEqual([]); + expect(result.failed).toHaveLength(1); + expect(result.failed[0]!.title).toBe('8 unresolved Dependabot alerts on api'); + expect(result.failed[0]!.severity).toBe('high'); + expect(result.failed[0]!.description).toContain('8 open high severity or above alerts'); + }); + + it('fails with critical severity when critical alerts are present', async () => { + const result = await runCheck( + [ + repo('acme/api', { + openAlertSeverities: ['critical', 'critical', 'high'], + }), + ], + { target_repos: ['acme/api'] }, + ); + expect(result.failed).toHaveLength(1); + expect(result.failed[0]!.severity).toBe('critical'); + expect(result.failed[0]!.title).toBe('3 unresolved Dependabot alerts on api'); + }); + + it('passes when only medium alerts exist and default threshold is high', async () => { + const result = await runCheck( + [ + repo('acme/api', { + openAlertSeverities: ['medium', 'medium', 'low'], + }), + ], + { target_repos: ['acme/api'] }, + ); + expect(result.passed).toHaveLength(1); + expect(result.failed).toEqual([]); + }); + + it('passes when threshold is critical and only high alerts exist', async () => { + const result = await runCheck( + [repo('acme/api', { openAlertSeverities: ['high', 'high'] })], + { target_repos: ['acme/api'], alert_severity_threshold: 'critical' }, + ); + expect(result.passed).toHaveLength(1); + expect(result.failed).toEqual([]); + }); + + it('fails when threshold is low and any alert exists', async () => { + const result = await runCheck( + [repo('acme/api', { openAlertSeverities: ['low'] })], + { target_repos: ['acme/api'], alert_severity_threshold: 'low' }, + ); + expect(result.failed).toHaveLength(1); + expect(result.failed[0]!.title).toBe('1 unresolved Dependabot alert on api'); + expect(result.failed[0]!.description).toContain('1 open any severity alert is still unresolved'); + }); + + it('fails when Dependabot is paused but high alerts exist', async () => { + const result = await runCheck( + [ + repo('acme/api', { + dependabotStatus: 'paused', + openAlertSeverities: ['high', 'high'], + }), + ], + { target_repos: ['acme/api'] }, + ); + expect(result.failed).toHaveLength(1); + expect(result.failed[0]!.title).toBe('2 unresolved Dependabot alerts on api (paused)'); + expect(result.failed[0]!.description).toContain('Paused Dependabot'); + }); + + it('passes (paused) when no threshold alerts exist', async () => { + const result = await runCheck( + [repo('acme/api', { dependabotStatus: 'paused', openAlertSeverities: [] })], + { target_repos: ['acme/api'] }, + ); + expect(result.passed).toHaveLength(1); + expect(result.passed[0]!.title).toBe('Dependabot enabled on api (paused)'); + }); + + it('fails with generic "not enabled" message when Dependabot is disabled, ignoring threshold', async () => { + const result = await runCheck( + [ + repo('acme/api', { + dependabotStatus: 'disabled', + openAlertSeverities: ['critical'], + }), + ], + { target_repos: ['acme/api'] }, + ); + expect(result.failed).toHaveLength(1); + expect(result.failed[0]!.title).toBe('Dependabot not enabled on api'); + }); + + it('passes when alert fetch fails (null alertCounts) and Dependabot is enabled', async () => { + // No alert signal -> do not regress to a false-fail. + const result = await runCheck( + [repo('acme/api', { alertsFetchFails: true })], + { target_repos: ['acme/api'] }, + ); + expect(result.passed).toHaveLength(1); + expect(result.failed).toEqual([]); + }); + + it('handles unknown threshold by falling back to "high"', async () => { + const result = await runCheck( + [repo('acme/api', { openAlertSeverities: ['high'] })], + { target_repos: ['acme/api'], alert_severity_threshold: 'bogus' }, + ); + expect(result.failed).toHaveLength(1); + }); +}); + +describe('dependabot severity helpers', () => { + it('countAtOrAboveSeverity sums the right buckets per threshold', () => { + const counts = { critical: 2, high: 3, medium: 5, low: 7 }; + expect(countAtOrAboveSeverity(counts, 'critical')).toBe(2); + expect(countAtOrAboveSeverity(counts, 'high')).toBe(5); + expect(countAtOrAboveSeverity(counts, 'medium')).toBe(10); + expect(countAtOrAboveSeverity(counts, 'low')).toBe(17); + }); + + it('highestPresentSeverity returns the highest bucket with a non-zero count', () => { + expect(highestPresentSeverity({ critical: 1, high: 5, medium: 0, low: 0 })).toBe('critical'); + expect(highestPresentSeverity({ critical: 0, high: 5, medium: 0, low: 0 })).toBe('high'); + expect(highestPresentSeverity({ critical: 0, high: 0, medium: 2, low: 1 })).toBe('medium'); + expect(highestPresentSeverity({ critical: 0, high: 0, medium: 0, low: 0 })).toBe('low'); + }); + + it('resolveSeverityThreshold normalizes invalid input to "high"', () => { + expect(resolveSeverityThreshold(undefined)).toBe('high'); + expect(resolveSeverityThreshold('')).toBe('high'); + expect(resolveSeverityThreshold('BOGUS')).toBe('high'); + expect(resolveSeverityThreshold('critical')).toBe('critical'); + expect(resolveSeverityThreshold('low')).toBe('low'); + }); + + it('thresholdLabel humanizes each level', () => { + expect(thresholdLabel('critical')).toBe('critical severity or above'); + expect(thresholdLabel('high')).toBe('high severity or above'); + expect(thresholdLabel('medium')).toBe('medium severity or above'); + expect(thresholdLabel('low')).toBe('any severity'); + }); +}); diff --git a/packages/integration-platform/src/manifests/github/checks/dependabot-alert-severity.ts b/packages/integration-platform/src/manifests/github/checks/dependabot-alert-severity.ts new file mode 100644 index 0000000000..67a157e3bd --- /dev/null +++ b/packages/integration-platform/src/manifests/github/checks/dependabot-alert-severity.ts @@ -0,0 +1,72 @@ +/** + * Severity helpers for the Dependabot check. + * Kept in a separate module so the main check file stays focused and the + * helpers can be unit-tested independently of the NestJS/GitHub fetch layer. + */ + +import type { FindingSeverity } from '../../../types'; + +export type AlertSeverity = 'critical' | 'high' | 'medium' | 'low'; + +export interface AlertCounts { + open: number; + dismissed: number; + fixed: number; + total: number; + bySeverity: Record; +} + +export const VALID_ALERT_SEVERITIES: ReadonlySet = new Set([ + 'critical', + 'high', + 'medium', + 'low', +]); + +export const DEFAULT_ALERT_SEVERITY_THRESHOLD: AlertSeverity = 'high'; + +/** + * Normalize an arbitrary string into a valid AlertSeverity, falling back to + * the default threshold when the value is missing or unknown. + */ +export const resolveSeverityThreshold = (raw: string | undefined): AlertSeverity => + raw && VALID_ALERT_SEVERITIES.has(raw as AlertSeverity) + ? (raw as AlertSeverity) + : DEFAULT_ALERT_SEVERITY_THRESHOLD; + +/** + * Count open alerts at or above the configured severity threshold. + * e.g. threshold='high' counts critical + high. + */ +export const countAtOrAboveSeverity = ( + bySeverity: Record, + threshold: AlertSeverity, +): number => { + switch (threshold) { + case 'critical': + return bySeverity.critical; + case 'high': + return bySeverity.critical + bySeverity.high; + case 'medium': + return bySeverity.critical + bySeverity.high + bySeverity.medium; + case 'low': + default: + return bySeverity.critical + bySeverity.high + bySeverity.medium + bySeverity.low; + } +}; + +/** + * Highest actual severity present in the counts, used to set the severity of + * the emitted ctx.fail finding. + */ +export const highestPresentSeverity = ( + bySeverity: Record, +): FindingSeverity => { + if (bySeverity.critical > 0) return 'critical'; + if (bySeverity.high > 0) return 'high'; + if (bySeverity.medium > 0) return 'medium'; + return 'low'; +}; + +export const thresholdLabel = (threshold: AlertSeverity): string => + threshold === 'low' ? 'any severity' : `${threshold} severity or above`; diff --git a/packages/integration-platform/src/manifests/github/checks/dependabot.ts b/packages/integration-platform/src/manifests/github/checks/dependabot.ts index ea2b8a17bf..49ac09a2b4 100644 --- a/packages/integration-platform/src/manifests/github/checks/dependabot.ts +++ b/packages/integration-platform/src/manifests/github/checks/dependabot.ts @@ -7,20 +7,18 @@ import { TASK_TEMPLATES } from '../../../task-mappings'; import type { IntegrationCheck } from '../../../types'; import type { GitHubDependabotAlert, GitHubOrg, GitHubRepo } from '../types'; -import { parseRepoBranch, targetReposVariable } from '../variables'; - -interface AlertCounts { - open: number; - dismissed: number; - fixed: number; - total: number; - bySeverity: { - critical: number; - high: number; - medium: number; - low: number; - }; -} +import { + alertSeverityThresholdVariable, + parseRepoBranch, + targetReposVariable, +} from '../variables'; +import { + countAtOrAboveSeverity, + highestPresentSeverity, + resolveSeverityThreshold, + thresholdLabel, + type AlertCounts, +} from './dependabot-alert-severity'; export const dependabotCheck: IntegrationCheck = { id: 'dependabot_enabled', @@ -30,13 +28,17 @@ export const dependabotCheck: IntegrationCheck = { taskMapping: TASK_TEMPLATES.secureCode, defaultSeverity: 'medium', - variables: [targetReposVariable], + variables: [targetReposVariable, alertSeverityThresholdVariable], run: async (ctx) => { const targetReposRaw = ctx.variables.target_repos as string[] | undefined; // Extract just the repo names (values may be in "owner/repo:branch" format) const targetRepos = (targetReposRaw || []).map((v) => parseRepoBranch(v).repo); + const severityThreshold = resolveSeverityThreshold( + ctx.variables.alert_severity_threshold as string | undefined, + ); + let repos: GitHubRepo[]; if (targetRepos.length > 0) { @@ -225,7 +227,37 @@ export const dependabotCheck: IntegrationCheck = { ? `\n\nAlert Summary: ${formatAlertSummary(alertCounts)}` : ''; - if (dependabotStatus === 'enabled') { + // Gate pass/fail on open alerts at/above the configured threshold. If we + // couldn't fetch alerts (alertCounts == null), fall back to the original + // "Dependabot on = pass" path — we have no alert signal to act on. + const alertsAtOrAboveThreshold = alertCounts + ? countAtOrAboveSeverity(alertCounts.bySeverity, severityThreshold) + : 0; + const isDependabotActive = + dependabotStatus === 'enabled' || dependabotStatus === 'paused'; + + if (alertCounts && alertsAtOrAboveThreshold > 0 && isDependabotActive) { + const isSingular = alertsAtOrAboveThreshold === 1; + const noun = isSingular ? 'alert' : 'alerts'; + const verb = isSingular ? 'is' : 'are'; + const pausedNote = + dependabotStatus === 'paused' + ? ' Paused Dependabot will not open fix PRs until it resumes.' + : ''; + const titleSuffix = dependabotStatus === 'paused' ? ' (paused)' : ''; + + ctx.fail({ + title: `${alertsAtOrAboveThreshold} unresolved Dependabot ${noun} on ${repo.name}${titleSuffix}`, + description: `Dependabot is ${dependabotStatus} but ${alertsAtOrAboveThreshold} open ${thresholdLabel(severityThreshold)} ${noun} ${verb} still unresolved.${pausedNote}${alertSummary}`, + resourceType: 'repository', + resourceId: repo.full_name, + severity: highestPresentSeverity(alertCounts.bySeverity), + remediation: `1. Review open alerts at ${repo.html_url}/security/dependabot\n2. Merge the auto-generated fix PRs, or dismiss alerts with a documented justification\n3. Re-run this check once the alert count drops to zero`, + evidence: { + [repo.full_name]: repoEvidence, + }, + }); + } else if (dependabotStatus === 'enabled') { ctx.pass({ title: `Dependabot enabled on ${repo.name}`, description: `Dependabot security updates are enabled and will automatically create pull requests to fix vulnerable dependencies.${alertSummary}`, diff --git a/packages/integration-platform/src/manifests/github/variables.ts b/packages/integration-platform/src/manifests/github/variables.ts index b16d3501a7..98b93f1f5e 100644 --- a/packages/integration-platform/src/manifests/github/variables.ts +++ b/packages/integration-platform/src/manifests/github/variables.ts @@ -151,3 +151,23 @@ export const recentPullRequestDaysVariable: CheckVariable = { helpText: 'How many days back to look when determining whether pull requests are "recent". Confirm the right value with your security/compliance owner.', }; + +/** + * Minimum severity of open Dependabot alerts that should cause the check to fail. + * Alerts below the threshold do not affect the pass/fail verdict. + */ +export const alertSeverityThresholdVariable: CheckVariable = { + id: 'alert_severity_threshold', + label: 'Fail on open alerts at severity', + type: 'select', + required: false, + default: 'high', + helpText: + 'The check fails when the repository has open Dependabot alerts at or above this severity. Alerts below this level are informational only.', + options: [ + { value: 'critical', label: 'Critical only' }, + { value: 'high', label: 'High or above (recommended)' }, + { value: 'medium', label: 'Medium or above' }, + { value: 'low', label: 'Low (fail on any open alert)' }, + ], +}; From 975b4c9089c2ab603dea65f769e2daa53fc2c516 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 22 Apr 2026 23:01:14 -0400 Subject: [PATCH 2/4] fix(app): show evidence block for failed automation runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The automation-run detail card in Compliance → Task → Integration Checks rendered a "View Evidence" expandable JSON tree for every *passing* result but never for a *failing* one — even though the backend saves the same `evidence` payload for both and the API returns it identically. After the Dependabot severity-gating change (#2643), failing runs surface useful context in their evidence (open_by_severity breakdown, checked_at, etc.) that users need to understand *why* the check failed. Hiding it behind a UI inconsistency defeats that. Mirror the passing block's `details > EvidenceJsonView` pattern onto the findings map so both states render identically. --- .../[taskId]/components/TaskIntegrationChecks.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskIntegrationChecks.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskIntegrationChecks.tsx index 9e66b1c6b3..73b7572673 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskIntegrationChecks.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskIntegrationChecks.tsx @@ -1046,6 +1046,18 @@ function CheckRunItem({ )} + {finding.evidence && Object.keys(finding.evidence).length > 0 && ( +
+ + View Evidence + + +
+ )} ))} {findings.length > 3 && ( From b20ced08edca1d6673efb025711a974d5a7748b6 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 22 Apr 2026 23:22:47 -0400 Subject: [PATCH 3/4] feat(device-agent): relax screen lock threshold to 15 minutes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Industry baseline (NIST 800-53 AC-11, CIS Benchmarks, common SOC 2 practice) is 15 minutes, not 5. Customers have repeatedly asked for this. Raise both the compliance-check maximum and the auto-fix target from 300s to 900s across macOS, Linux, and Windows. Update all user-facing copy (renderer labels, guided instructions, docs, framework task/seed descriptions, SPEC) to match. Pure relaxation: devices currently passing (≤5 min) still pass; devices failing at 6–15 min now pass. No migration required. Seed template changes affect new orgs only; existing task rows are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../FrameworkEditorTaskTemplate.json | 2 +- packages/device-agent/SPEC.md | 2 +- .../src/checks/linux/screen-lock.ts | 6 +++--- .../src/checks/macos/screen-lock.ts | 6 +++--- .../src/checks/windows/screen-lock.ts | 6 +++--- .../src/remediations/instructions.ts | 14 +++++++------- .../src/remediations/linux/screen-lock.ts | 6 +++--- .../src/remediations/macos/screen-lock.ts | 6 +++--- .../src/remediations/windows/screen-lock.ts | 6 +++--- packages/device-agent/src/renderer/App.tsx | 2 +- packages/docs/device-agent.mdx | 18 +++++++++--------- .../integration-platform/src/task-mappings.ts | 2 +- 12 files changed, 38 insertions(+), 38 deletions(-) diff --git a/packages/db/prisma/seed/primitives/FrameworkEditorTaskTemplate.json b/packages/db/prisma/seed/primitives/FrameworkEditorTaskTemplate.json index 97717cc1e3..423b364e79 100644 --- a/packages/db/prisma/seed/primitives/FrameworkEditorTaskTemplate.json +++ b/packages/db/prisma/seed/primitives/FrameworkEditorTaskTemplate.json @@ -692,7 +692,7 @@ { "id": "frk_tt_6840796f77d8a0dff53f947a", "name": "Secure Devices", - "description": "Ensure all devices meet the following security requirements:\n\n• Full disk encryption enabled (BitLocker for Windows, FileVault for macOS)\n\n• Screen lock enabled after 5 minutes of inactivity on macOS and 15 minutes on Windows\n\n• Minimum password length of 8+ characters\n\n• Automatic installation of security updates\n\n• Anti-virus enabled (Windows Defender on Windows or macOS XProtect)\n\nYou may verify compliance by uploading screenshots for each device or by installing the Comp AI device agent.\n\nIf you already use a third-party MDM provider (e.g., Jamf, Intune, Kandji, etc.), it is recommended that you do not install the Comp AI device agent, as running multiple device management agents may cause conflicts. In these cases, please upload screenshots instead.\n\nThe Comp AI device agent can be downloaded from: portal.trycomp.ai", + "description": "Ensure all devices meet the following security requirements:\n\n• Full disk encryption enabled (BitLocker for Windows, FileVault for macOS)\n\n• Screen lock enabled after 15 minutes of inactivity\n\n• Minimum password length of 8+ characters\n\n• Automatic installation of security updates\n\n• Anti-virus enabled (Windows Defender on Windows or macOS XProtect)\n\nYou may verify compliance by uploading screenshots for each device or by installing the Comp AI device agent.\n\nIf you already use a third-party MDM provider (e.g., Jamf, Intune, Kandji, etc.), it is recommended that you do not install the Comp AI device agent, as running multiple device management agents may cause conflicts. In these cases, please upload screenshots instead.\n\nThe Comp AI device agent can be downloaded from: portal.trycomp.ai", "frequency": "yearly", "department": "itsm", "createdAt": "2025-06-04 16:50:54.671", diff --git a/packages/device-agent/SPEC.md b/packages/device-agent/SPEC.md index e82a63e5d6..bce7d1c5ad 100644 --- a/packages/device-agent/SPEC.md +++ b/packages/device-agent/SPEC.md @@ -46,7 +46,7 @@ The agent runs four compliance checks every hour: 1. **Disk Encryption** -- FileVault (macOS), BitLocker (Windows), LUKS (Linux) 2. **Antivirus** -- XProtect (macOS), Windows Defender (Windows), ClamAV/AppArmor/SELinux (Linux) 3. **Password Policy** -- Minimum 8-character password enforced at OS level -4. **Screen Lock** -- Automatic screen lock within 5 minutes of inactivity +4. **Screen Lock** -- Automatic screen lock within 15 minutes of inactivity A device is **compliant** when all four checks pass. diff --git a/packages/device-agent/src/checks/linux/screen-lock.ts b/packages/device-agent/src/checks/linux/screen-lock.ts index db97443124..b731fd8be3 100644 --- a/packages/device-agent/src/checks/linux/screen-lock.ts +++ b/packages/device-agent/src/checks/linux/screen-lock.ts @@ -2,10 +2,10 @@ import { execSync } from 'node:child_process'; import type { CheckResult } from '../../shared/types'; import type { ComplianceCheck } from '../types'; -const MAX_IDLE_TIME_SECONDS = 300; // 5 minutes +const MAX_IDLE_TIME_SECONDS = 900; // 15 minutes /** - * Checks if screen lock is enabled and set to 5 minutes or less on Linux. + * Checks if screen lock is enabled and set to 15 minutes or less on Linux. * * Detection methods: * 1. GNOME: gsettings for org.gnome.desktop.session idle-delay and @@ -16,7 +16,7 @@ const MAX_IDLE_TIME_SECONDS = 300; // 5 minutes */ export class LinuxScreenLockCheck implements ComplianceCheck { checkType = 'screen_lock' as const; - displayName = 'Screen Lock (5 min or less)'; + displayName = 'Screen Lock (15 min or less)'; async run(): Promise { try { diff --git a/packages/device-agent/src/checks/macos/screen-lock.ts b/packages/device-agent/src/checks/macos/screen-lock.ts index f7e488e91b..fcbcd658ed 100644 --- a/packages/device-agent/src/checks/macos/screen-lock.ts +++ b/packages/device-agent/src/checks/macos/screen-lock.ts @@ -2,10 +2,10 @@ import { execSync } from 'node:child_process'; import type { CheckResult } from '../../shared/types'; import type { ComplianceCheck } from '../types'; -const MAX_IDLE_TIME_SECONDS = 300; // 5 minutes +const MAX_IDLE_TIME_SECONDS = 900; // 15 minutes /** - * Checks if screen lock is enabled and set to 5 minutes or less on macOS. + * Checks if screen lock is enabled and set to 15 minutes or less on macOS. * * Checks two settings: * 1. Screen saver idle time (how long before screen saver activates) @@ -20,7 +20,7 @@ const MAX_IDLE_TIME_SECONDS = 300; // 5 minutes */ export class MacOSScreenLockCheck implements ComplianceCheck { checkType = 'screen_lock' as const; - displayName = 'Screen Lock (5 min or less)'; + displayName = 'Screen Lock (15 min or less)'; async run(): Promise { try { diff --git a/packages/device-agent/src/checks/windows/screen-lock.ts b/packages/device-agent/src/checks/windows/screen-lock.ts index b17e9a0d25..774e38b691 100644 --- a/packages/device-agent/src/checks/windows/screen-lock.ts +++ b/packages/device-agent/src/checks/windows/screen-lock.ts @@ -2,10 +2,10 @@ import { execSync } from 'node:child_process'; import type { CheckResult } from '../../shared/types'; import type { ComplianceCheck } from '../types'; -const MAX_IDLE_TIME_SECONDS = 300; // 5 minutes +const MAX_IDLE_TIME_SECONDS = 900; // 15 minutes /** - * Checks if screen lock is enabled and set to 5 minutes or less on Windows. + * Checks if screen lock is enabled and set to 15 minutes or less on Windows. * * Checks: * 1. Screen saver timeout (ScreenSaveTimeOut registry key) @@ -14,7 +14,7 @@ const MAX_IDLE_TIME_SECONDS = 300; // 5 minutes */ export class WindowsScreenLockCheck implements ComplianceCheck { checkType = 'screen_lock' as const; - displayName = 'Screen Lock (5 min or less)'; + displayName = 'Screen Lock (15 min or less)'; async run(): Promise { try { diff --git a/packages/device-agent/src/remediations/instructions.ts b/packages/device-agent/src/remediations/instructions.ts index 55212dc92c..b1ac60d1b6 100644 --- a/packages/device-agent/src/remediations/instructions.ts +++ b/packages/device-agent/src/remediations/instructions.ts @@ -12,11 +12,11 @@ interface InstructionSet { const MACOS_INSTRUCTIONS: Record = { screen_lock: { - description: 'Enable screen lock with a 5-minute timeout', + description: 'Enable screen lock with a 15-minute timeout', steps: [ 'Open System Settings', 'Go to Lock Screen', - 'Set "Start Screen Saver when inactive" to 5 minutes or less', + 'Set "Start Screen Saver when inactive" to 15 minutes or less', 'Set "Require password after screen saver begins or display is turned off" to Immediately', ], }, @@ -55,14 +55,14 @@ const MACOS_INSTRUCTIONS: Record = { const LINUX_INSTRUCTIONS: Record = { screen_lock: { - description: 'Enable screen lock with a 5-minute timeout', + description: 'Enable screen lock with a 15-minute timeout', steps: [ 'GNOME: Open Settings > Privacy > Screen Lock', - 'Set "Blank Screen Delay" to 5 minutes or less', + 'Set "Blank Screen Delay" to 15 minutes or less', 'Enable "Automatic Screen Lock"', 'Set "Automatic Screen Lock Delay" to immediately', 'KDE: Open System Settings > Workspace Behavior > Screen Locking', - 'Set "Lock screen automatically after" to 5 minutes or less', + 'Set "Lock screen automatically after" to 15 minutes or less', ], }, password_policy: { @@ -103,11 +103,11 @@ const LINUX_INSTRUCTIONS: Record = { const WINDOWS_INSTRUCTIONS: Record = { screen_lock: { - description: 'Enable screen lock with a 5-minute timeout', + description: 'Enable screen lock with a 15-minute timeout', steps: [ 'Open Settings (Win + I)', 'Go to System > Power & battery (or Power)', - 'Under "Screen and sleep", set "When plugged in, turn off my screen after" to 5 minutes or less', + 'Under "Screen and sleep", set "When plugged in, turn off my screen after" to 15 minutes or less', 'Then go to Accounts > Sign-in options', 'Under "If you\'ve been away, when should Windows require you to sign in again?", select "When PC wakes up from sleep"', ], diff --git a/packages/device-agent/src/remediations/linux/screen-lock.ts b/packages/device-agent/src/remediations/linux/screen-lock.ts index 53a174f3c1..70f8c00737 100644 --- a/packages/device-agent/src/remediations/linux/screen-lock.ts +++ b/packages/device-agent/src/remediations/linux/screen-lock.ts @@ -3,12 +3,12 @@ import type { RemediationInfo, RemediationResult } from '../../shared/types'; import { getInstructions } from '../instructions'; import type { ComplianceRemediation } from '../types'; -const TARGET_IDLE_TIME_SECONDS = 300; // 5 minutes +const TARGET_IDLE_TIME_SECONDS = 900; // 15 minutes /** * Linux screen lock remediation. * Auto-fixes without admin privileges by setting GNOME gsettings: - * - idle-delay to 5 minutes + * - idle-delay to 15 minutes * - lock-enabled to true * - lock-delay to 0 (immediate) */ @@ -29,7 +29,7 @@ export class LinuxScreenLockRemediation implements ComplianceRemediation { async remediate(): Promise { try { - // Set idle delay to 5 minutes + // Set idle delay to 15 minutes execSync(`gsettings set org.gnome.desktop.session idle-delay ${TARGET_IDLE_TIME_SECONDS}`, { encoding: 'utf-8', timeout: 10000, diff --git a/packages/device-agent/src/remediations/macos/screen-lock.ts b/packages/device-agent/src/remediations/macos/screen-lock.ts index 7538e393ea..da3a4ae95a 100644 --- a/packages/device-agent/src/remediations/macos/screen-lock.ts +++ b/packages/device-agent/src/remediations/macos/screen-lock.ts @@ -3,12 +3,12 @@ import type { RemediationInfo, RemediationResult } from '../../shared/types'; import { getInstructions } from '../instructions'; import type { ComplianceRemediation } from '../types'; -const TARGET_IDLE_TIME_SECONDS = 300; // 5 minutes +const TARGET_IDLE_TIME_SECONDS = 900; // 15 minutes /** * macOS screen lock remediation. * Auto-fixes without admin privileges by writing user-level defaults: - * - Screen saver idle time set to 5 minutes + * - Screen saver idle time set to 15 minutes * - Password required immediately after screen saver */ export class MacOSScreenLockRemediation implements ComplianceRemediation { @@ -28,7 +28,7 @@ export class MacOSScreenLockRemediation implements ComplianceRemediation { async remediate(): Promise { try { - // Set screen saver idle time to 5 minutes + // Set screen saver idle time to 15 minutes execSync( `defaults -currentHost write com.apple.screensaver idleTime -int ${TARGET_IDLE_TIME_SECONDS}`, { encoding: 'utf-8', timeout: 10000 }, diff --git a/packages/device-agent/src/remediations/windows/screen-lock.ts b/packages/device-agent/src/remediations/windows/screen-lock.ts index 358807466c..6017cd40b8 100644 --- a/packages/device-agent/src/remediations/windows/screen-lock.ts +++ b/packages/device-agent/src/remediations/windows/screen-lock.ts @@ -3,12 +3,12 @@ import type { RemediationInfo, RemediationResult } from '../../shared/types'; import { getInstructions } from '../instructions'; import type { ComplianceRemediation } from '../types'; -const TARGET_IDLE_TIME_SECONDS = 300; // 5 minutes +const TARGET_IDLE_TIME_SECONDS = 900; // 15 minutes /** * Windows screen lock remediation. * Auto-fixes without admin privileges by writing user-level HKCU registry keys: - * - ScreenSaveTimeOut = 300 (5 minutes) + * - ScreenSaveTimeOut = 900 (15 minutes) * - ScreenSaverIsSecure = 1 (password required on resume) * - ScreenSaveActive = 1 (screen saver enabled) */ @@ -37,7 +37,7 @@ export class WindowsScreenLockRemediation implements ComplianceRemediation { { encoding: 'utf-8', timeout: 10000 }, ); - // Set timeout to 5 minutes + // Set timeout to 15 minutes execSync( `powershell.exe -NoProfile -NonInteractive -Command "Set-ItemProperty -Path '${regPath}' -Name ScreenSaveTimeOut -Value '${TARGET_IDLE_TIME_SECONDS}'"`, { encoding: 'utf-8', timeout: 10000 }, diff --git a/packages/device-agent/src/renderer/App.tsx b/packages/device-agent/src/renderer/App.tsx index 2289d26d6b..3da7e5d387 100644 --- a/packages/device-agent/src/renderer/App.tsx +++ b/packages/device-agent/src/renderer/App.tsx @@ -57,7 +57,7 @@ const CHECK_DESCRIPTIONS: Record = { disk_encryption: 'FileVault or BitLocker enabled', antivirus: 'Antivirus software active', password_policy: 'Minimum 8 character password', - screen_lock: 'Screen locks within 5 minutes', + screen_lock: 'Screen locks within 15 minutes', }; /** Label for the remediation button based on remediation type */ diff --git a/packages/docs/device-agent.mdx b/packages/docs/device-agent.mdx index 3f172dbf43..29e6a33ef0 100644 --- a/packages/docs/device-agent.mdx +++ b/packages/docs/device-agent.mdx @@ -14,7 +14,7 @@ The agent checks four security areas: | **Disk Encryption** | FileVault (macOS), BitLocker (Windows), or LUKS (Linux) is enabled | | **Antivirus** | Active antivirus protection (XProtect, Windows Defender, ClamAV, etc.) | | **Password Policy** | Minimum 8-character password enforced at the OS level | -| **Screen Lock** | Automatic screen lock activates within 5 minutes of inactivity | +| **Screen Lock** | Automatic screen lock activates within 15 minutes of inactivity | Your device is **compliant** when all four checks pass. @@ -180,12 +180,12 @@ For users who cannot install the agent on their device, manual evidence of devic 3. Save the recovery key to Microsoft Account / USB / secure location. 4. Restart if prompted. - **Screen Lock after 5 Minutes** + **Screen Lock after 15 Minutes** 1. Press **Start** → **Settings** → **Personalization** → **Lock screen**. 2. Scroll down → click **Screen timeout settings**.\ - Take a screenshot showing the screen timeout set to 5 minutes. - 3. Set **Screen turns off** = 5 minutes. + Take a screenshot showing the screen timeout set to 15 minutes. + 3. Set **Screen turns off** = 15 minutes. 4. In **Settings** → **Accounts** → **Sign-in options** → ensure **Require sign-in** is set to _"When PC wakes up from sleep"_.\ Take a screenshot of the Sign-in Options page showing this setting. @@ -226,11 +226,11 @@ For users who cannot install the agent on their device, manual evidence of devic 4. Record recovery key.\ Take a screenshot of the FileVault settings page showing "FileVault is enabled for the disk." - **Screen Auto-lock (5 min)** + **Screen Auto-lock (15 min)** 1. **System Settings** → **Lock Screen**. - 2. Set **Start screen saver when inactive** = 5 minutes.\ - Take a screenshot showing the setting at 5 minutes. + 2. Set **Start screen saver when inactive** = 15 minutes.\ + Take a screenshot showing the setting at 15 minutes. 3. Set **Require password after sleep or screen saver begins** = _Immediately_.\ Take a screenshot showing "Require password immediately" is selected. @@ -267,10 +267,10 @@ For users who cannot install the agent on their device, manual evidence of devic 2. To verify, run: `lsblk -o NAME,TYPE,FSTYPE | grep crypt`\ Take a screenshot showing the encrypted volume. - **Screen Lock (5 min)** + **Screen Lock (15 min)** 1. Open **Settings** → **Privacy** → **Screen Lock**. - 2. Set **Automatic Screen Lock Delay** = 5 minutes.\ + 2. Set **Automatic Screen Lock Delay** = 15 minutes.\ Take a screenshot showing the screen lock delay setting. **Password Policy** diff --git a/packages/integration-platform/src/task-mappings.ts b/packages/integration-platform/src/task-mappings.ts index c6f921ff10..0e98a2b693 100644 --- a/packages/integration-platform/src/task-mappings.ts +++ b/packages/integration-platform/src/task-mappings.ts @@ -322,7 +322,7 @@ PCI: Mask PAN when displayed outside the secure CDE, ensuring only truncated ... }, frk_tt_6840796f77d8a0dff53f947a: { name: 'Secure Devices', - description: `Ensure all devices have BitLocker/FileVault enabled, screen lock enabled after 5 minutes (for mac) o...`, + description: `Ensure all devices have BitLocker/FileVault enabled, screen lock enabled after 15 minutes (for mac) o...`, department: 'itsm', frequency: 'yearly', }, From 0c5d3321a0ccea26b07f10f9f6a48188cb7f1634 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 23 Apr 2026 11:33:26 -0400 Subject: [PATCH 4/4] perf(onboarding): replace sequential update loops with bulk SQL in org init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The org initialization transaction was firing ~100 sequential queries for a typical SOC 2 onboarding — two loops inside the transaction were the main contributors: 1. Per-policy `policy.update` to set `currentVersionId` after creating PolicyVersion rows (one UPDATE per new policy). 2. Per-control `control.update({ policies: { connect }, tasks: { connect } })` to link policies/tasks to controls (one UPDATE per control). On Prisma v6 with the native Rust engine this ran in ~5-10s. The v6→v7 migration swapped the Rust engine for `@prisma/adapter-pg` (node-postgres), which roughly tripled per-query overhead, pushing the same workload to 30+s and causing onboarding to trip the 30s transaction timeout (which we bumped to 60s as a stopgap in 8d1764a67). Fix: - Policy block: pre-generate Policy and PolicyVersion CUIDs in a single `$queryRaw` call using `generate_prefixed_cuid()`. Create policies, then versions, then set `currentVersionId` for all rows in a single bulk `UPDATE ... FROM (VALUES ...)` statement. Handles the FK cycle (Policy.currentVersionId ↔ PolicyVersion.policyId) by insert ordering rather than schema changes. Kills N sequential updates. - Control block: collect control↔policy and control↔task pairs in memory during the existing requirement-map pass, then bulk-insert into the implicit M2M join tables `_ControlToPolicy` and `_ControlToTask` with `ON CONFLICT ("A","B") DO NOTHING`. Preserves idempotency for re-runs (e.g. adding a framework to an existing org). Kills M sequential updates. Result: ~100 queries → ~20, flat regardless of framework size. Expected wall-clock 2-4s. Public API, return shape, console.warn diagnostics, and org-scoping are all preserved. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/initialize-organization.ts | 235 +++++++++--------- 1 file changed, 121 insertions(+), 114 deletions(-) diff --git a/apps/app/src/actions/organization/lib/initialize-organization.ts b/apps/app/src/actions/organization/lib/initialize-organization.ts index 963c18b805..8c3231f9cb 100644 --- a/apps/app/src/actions/organization/lib/initialize-organization.ts +++ b/apps/app/src/actions/organization/lib/initialize-organization.ts @@ -216,61 +216,61 @@ export const _upsertOrgFrameworkStructureCore = async ({ ); if (policyTemplatesForCreation.length > 0) { + // Pre-generate Policy and PolicyVersion IDs in a single round-trip so we can + // skip the post-insert findMany lookups and the per-policy update loop. + // Policy.currentVersionId -> PolicyVersion.id and PolicyVersion.policyId -> + // Policy.id form an FK cycle, so we insert Policy first (currentVersionId null), + // insert PolicyVersion, then set currentVersionId in one bulk UPDATE. + const idPairs = await tx.$queryRaw< + Array<{ policy_id: string; version_id: string }> + >` + SELECT + generate_prefixed_cuid('pol'::text) AS policy_id, + generate_prefixed_cuid('pv'::text) AS version_id + FROM generate_series(1, ${policyTemplatesForCreation.length}::int) + `; + const preparedPolicies = policyTemplatesForCreation.map((template, i) => ({ + template, + policyId: idPairs[i].policy_id, + versionId: idPairs[i].version_id, + contentArray: extractTipTapContentArray(template.content), + })); + await tx.policy.createMany({ - data: policyTemplatesForCreation.map((policyTemplate) => { - const templateContent = policyTemplate.content; - const contentArray = extractTipTapContentArray(templateContent); - return { - name: policyTemplate.name, - description: policyTemplate.description, - department: policyTemplate.department, - frequency: policyTemplate.frequency, - content: { set: contentArray }, - organizationId: organizationId, - policyTemplateId: policyTemplate.id, - }; - }), + data: preparedPolicies.map(({ template, policyId, contentArray }) => ({ + id: policyId, + name: template.name, + description: template.description, + department: template.department, + frequency: template.frequency, + content: { set: contentArray }, + organizationId: organizationId, + policyTemplateId: template.id, + })), }); - // Fetch newly created policies to create versions for them - const newlyCreatedPolicies = await tx.policy.findMany({ - where: { - organizationId: organizationId, - policyTemplateId: { - in: policyTemplatesForCreation.map((t) => t.id), - }, - }, - select: { id: true, policyTemplateId: true, content: true }, + await tx.policyVersion.createMany({ + data: preparedPolicies.map(({ policyId, versionId, contentArray }) => ({ + id: versionId, + policyId, + version: 1, + content: { set: contentArray }, + changelog: 'Initial version from template', + })), }); - // Create version 1 for each newly created policy - if (newlyCreatedPolicies.length > 0) { - await tx.policyVersion.createMany({ - data: newlyCreatedPolicies.map((policy) => ({ - policyId: policy.id, - version: 1, - content: { set: policy.content as Prisma.InputJsonValue[] }, - changelog: 'Initial version from template', - })), - }); - - // Fetch the created versions to update policies with currentVersionId - const createdVersions = await tx.policyVersion.findMany({ - where: { - policyId: { in: newlyCreatedPolicies.map((p) => p.id) }, - version: 1, - }, - select: { id: true, policyId: true }, - }); - - // Update each policy with its currentVersionId - for (const version of createdVersions) { - await tx.policy.update({ - where: { id: version.policyId }, - data: { currentVersionId: version.id }, - }); - } - } + const currentVersionValues = Prisma.join( + preparedPolicies.map( + ({ policyId, versionId }) => + Prisma.sql`(${policyId}::text, ${versionId}::text)`, + ), + ); + await tx.$executeRaw` + UPDATE "Policy" + SET "currentVersionId" = v.version_id + FROM (VALUES ${currentVersionValues}) AS v(policy_id, version_id) + WHERE "Policy".id = v.policy_id + `; } /** @@ -350,6 +350,8 @@ export const _upsertOrgFrameworkStructureCore = async ({ ); const requirementMapEntriesToCreate: Prisma.RequirementMapCreateManyInput[] = []; + const controlToPolicyPairs: Array<{ controlId: string; policyId: string }> = []; + const controlToTaskPairs: Array<{ controlId: string; taskId: string }> = []; for (const controlTemplateRelation of groupedControlTemplateRelations) { const newControlId = controlTemplateIdToInstanceIdMap.get( @@ -363,81 +365,86 @@ export const _upsertOrgFrameworkStructureCore = async ({ continue; } - const updateData: Prisma.ControlUpdateInput = {}; - let needsUpdate = false; - // --- Process Requirements for RequirementMap --- - if (controlTemplateRelation.requirementTemplateIds.length > 0) { - for (const reqTemplateId of controlTemplateRelation.requirementTemplateIds) { - let frameworkEditorFrameworkIdForReq: string | undefined; - for (const fw of frameworkEditorFrameworks) { - if (fw.requirements.some((r) => r.id === reqTemplateId)) { - frameworkEditorFrameworkIdForReq = fw.id; - break; - } - } - const frameworkInstanceId = frameworkEditorFrameworkIdForReq - ? editorFrameworkIdToInstanceIdMap.get(frameworkEditorFrameworkIdForReq) - : undefined; - - if (frameworkInstanceId) { - requirementMapEntriesToCreate.push({ - controlId: newControlId, - requirementId: reqTemplateId, - frameworkInstanceId: frameworkInstanceId, - }); - } else { - console.warn( - `UpsertOrgFrameworkStructureCore: Could not find FrameworkInstanceId for editor requirement ID ${reqTemplateId}. Cannot create RequirementMap for Control ${newControlId}.`, - ); + for (const reqTemplateId of controlTemplateRelation.requirementTemplateIds) { + let frameworkEditorFrameworkIdForReq: string | undefined; + for (const fw of frameworkEditorFrameworks) { + if (fw.requirements.some((r) => r.id === reqTemplateId)) { + frameworkEditorFrameworkIdForReq = fw.id; + break; } } + const frameworkInstanceId = frameworkEditorFrameworkIdForReq + ? editorFrameworkIdToInstanceIdMap.get(frameworkEditorFrameworkIdForReq) + : undefined; + + if (frameworkInstanceId) { + requirementMapEntriesToCreate.push({ + controlId: newControlId, + requirementId: reqTemplateId, + frameworkInstanceId: frameworkInstanceId, + }); + } else { + console.warn( + `UpsertOrgFrameworkStructureCore: Could not find FrameworkInstanceId for editor requirement ID ${reqTemplateId}. Cannot create RequirementMap for Control ${newControlId}.`, + ); + } } - // --- Connect Policies --- - if (controlTemplateRelation.policyTemplateIds.length > 0) { - const policiesToConnect = []; - for (const policyTemplateId of controlTemplateRelation.policyTemplateIds) { - const newPolicyId = policyTemplateIdToInstanceIdMap.get(policyTemplateId); - if (newPolicyId) { - policiesToConnect.push({ id: newPolicyId }); - } else { - console.warn( - `UpsertOrgFrameworkStructureCore: Policy instance not found for template ID ${policyTemplateId}. Cannot connect to Control ${newControlId}.`, - ); - } - } - if (policiesToConnect.length > 0) { - updateData.policies = { connect: policiesToConnect }; - needsUpdate = true; + // --- Collect Control <-> Policy pairs --- + for (const policyTemplateId of controlTemplateRelation.policyTemplateIds) { + const newPolicyId = policyTemplateIdToInstanceIdMap.get(policyTemplateId); + if (newPolicyId) { + controlToPolicyPairs.push({ controlId: newControlId, policyId: newPolicyId }); + } else { + console.warn( + `UpsertOrgFrameworkStructureCore: Policy instance not found for template ID ${policyTemplateId}. Cannot connect to Control ${newControlId}.`, + ); } } - // --- Connect Tasks --- - if (controlTemplateRelation.taskTemplateIds.length > 0) { - const tasksToConnect = []; - for (const taskTemplateId of controlTemplateRelation.taskTemplateIds) { - const newTaskId = taskTemplateIdToInstanceIdMap.get(taskTemplateId); - if (newTaskId) { - tasksToConnect.push({ id: newTaskId }); - } else { - console.warn( - `UpsertOrgFrameworkStructureCore: Task instance not found for template ID ${taskTemplateId}. Cannot connect to Control ${newControlId}.`, - ); - } - } - if (tasksToConnect.length > 0) { - updateData.tasks = { connect: tasksToConnect }; - needsUpdate = true; + // --- Collect Control <-> Task pairs --- + for (const taskTemplateId of controlTemplateRelation.taskTemplateIds) { + const newTaskId = taskTemplateIdToInstanceIdMap.get(taskTemplateId); + if (newTaskId) { + controlToTaskPairs.push({ controlId: newControlId, taskId: newTaskId }); + } else { + console.warn( + `UpsertOrgFrameworkStructureCore: Task instance not found for template ID ${taskTemplateId}. Cannot connect to Control ${newControlId}.`, + ); } } + } - if (needsUpdate) { - await tx.control.update({ - where: { id: newControlId }, - data: updateData, - }); - } + // Bulk-insert into the implicit M2M join tables instead of N `control.update({ connect })` + // calls. ON CONFLICT DO NOTHING preserves the idempotency the connect loop provided for + // re-runs where some links already exist (e.g., adding a framework to an existing org). + if (controlToPolicyPairs.length > 0) { + const rows = Prisma.join( + controlToPolicyPairs.map( + ({ controlId, policyId }) => + Prisma.sql`(${controlId}::text, ${policyId}::text)`, + ), + ); + await tx.$executeRaw` + INSERT INTO "_ControlToPolicy" ("A", "B") + VALUES ${rows} + ON CONFLICT ("A", "B") DO NOTHING + `; + } + + if (controlToTaskPairs.length > 0) { + const rows = Prisma.join( + controlToTaskPairs.map( + ({ controlId, taskId }) => + Prisma.sql`(${controlId}::text, ${taskId}::text)`, + ), + ); + await tx.$executeRaw` + INSERT INTO "_ControlToTask" ("A", "B") + VALUES ${rows} + ON CONFLICT ("A", "B") DO NOTHING + `; } // --- Create RequirementMap entries ---