From 1088cd04bdec107bc820afb0ad7c3d77de6cace3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 11:43:50 -0500 Subject: [PATCH 1/5] [dev] [carhartlewis] lewis/aikido-integration (#1942) * feat(aikido): add Aikido security integration with vulnerability checks * feat(aikido): enhance OAuth and variable controllers with improved error handling and logging * feat(aikido): improve repository fetching and error handling in checks * fix(aikido): improve error handling for code repository scanning * fix(aikido): improve error handling for code repository scanning * fix(aikido): enhance error handling and update fetch logic for repositories * fix(aikido): update OAuth client credential handling and improve error logging --------- Co-authored-by: Lewis Carhart Co-authored-by: Mariano Fuentes --- .../controllers/oauth.controller.ts | 8 +- .../controllers/variables.controller.ts | 37 ++- .../services/credential-vault.service.ts | 2 + .../aikido/checks/code-repository-scanning.ts | 194 +++++++++++++ .../src/manifests/aikido/checks/index.ts | 9 + .../aikido/checks/issue-count-threshold.ts | 148 ++++++++++ .../aikido/checks/open-security-issues.ts | 210 ++++++++++++++ .../src/manifests/aikido/index.ts | 65 +++++ .../src/manifests/aikido/types.ts | 268 ++++++++++++++++++ .../src/manifests/aikido/variables.ts | 75 +++++ .../checks/two-factor-auth.ts | 2 +- .../src/registry/index.ts | 2 + .../integration-platform/src/task-mappings.ts | 130 ++++----- 13 files changed, 1077 insertions(+), 73 deletions(-) create mode 100644 packages/integration-platform/src/manifests/aikido/checks/code-repository-scanning.ts create mode 100644 packages/integration-platform/src/manifests/aikido/checks/index.ts create mode 100644 packages/integration-platform/src/manifests/aikido/checks/issue-count-threshold.ts create mode 100644 packages/integration-platform/src/manifests/aikido/checks/open-security-issues.ts create mode 100644 packages/integration-platform/src/manifests/aikido/index.ts create mode 100644 packages/integration-platform/src/manifests/aikido/types.ts create mode 100644 packages/integration-platform/src/manifests/aikido/variables.ts diff --git a/apps/api/src/integration-platform/controllers/oauth.controller.ts b/apps/api/src/integration-platform/controllers/oauth.controller.ts index 14d92d79b..e655356b8 100644 --- a/apps/api/src/integration-platform/controllers/oauth.controller.ts +++ b/apps/api/src/integration-platform/controllers/oauth.controller.ts @@ -403,6 +403,8 @@ export class OAuthController { }; // Add client credentials based on auth method + // Per OAuth 2.0 RFC 6749 Section 2.3.1, when using HTTP Basic auth (header), + // client credentials should NOT be included in the request body if (config.clientAuthMethod === 'header') { const creds = Buffer.from( `${credentials.clientId}:${credentials.clientSecret}`, @@ -422,8 +424,10 @@ export class OAuthController { }); if (!response.ok) { - await response.text(); // consume body - this.logger.error(`Token exchange failed: ${response.status}`); + const errorBody = await response.text(); + this.logger.error( + `Token exchange failed: ${response.status} - ${errorBody}`, + ); throw new Error(`Token exchange failed: ${response.status}`); } diff --git a/apps/api/src/integration-platform/controllers/variables.controller.ts b/apps/api/src/integration-platform/controllers/variables.controller.ts index 3918a0e75..addf14f59 100644 --- a/apps/api/src/integration-platform/controllers/variables.controller.ts +++ b/apps/api/src/integration-platform/controllers/variables.controller.ts @@ -248,11 +248,18 @@ export class VariablesController { fetch: async (path: string): Promise => { const url = new URL(path, baseUrl); + const response = await fetch(url.toString(), { headers: buildHeaders(), }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return response.json(); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + const data = await response.json(); + return data as T; }, fetchAllPages: async (path: string): Promise => { @@ -268,10 +275,30 @@ export class VariablesController { const response = await fetch(url.toString(), { headers: buildHeaders(), }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const items: T[] = await response.json(); - if (!Array.isArray(items) || items.length === 0) break; + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + const data = await response.json(); + + // Handle both direct array responses and wrapped responses + // e.g., some APIs return { items: [...] } instead of [...] + let items: T[]; + if (Array.isArray(data)) { + items = data; + } else if (data && typeof data === 'object') { + // Find the first array property in the response + const arrayValue = Object.values(data).find((v) => + Array.isArray(v), + ) as T[] | undefined; + items = arrayValue ?? []; + } else { + items = []; + } + + if (items.length === 0) break; allItems.push(...items); if (items.length < perPage) break; diff --git a/apps/api/src/integration-platform/services/credential-vault.service.ts b/apps/api/src/integration-platform/services/credential-vault.service.ts index 62a35df18..7a158f7a1 100644 --- a/apps/api/src/integration-platform/services/credential-vault.service.ts +++ b/apps/api/src/integration-platform/services/credential-vault.service.ts @@ -304,6 +304,8 @@ export class CredentialVaultService { }; // Add client credentials based on auth method + // Per OAuth 2.0 RFC 6749 Section 2.3.1, when using HTTP Basic auth (header), + // client credentials should NOT be included in the request body if (config.clientAuthMethod === 'header') { const credentials = Buffer.from( `${config.clientId}:${config.clientSecret}`, diff --git a/packages/integration-platform/src/manifests/aikido/checks/code-repository-scanning.ts b/packages/integration-platform/src/manifests/aikido/checks/code-repository-scanning.ts new file mode 100644 index 000000000..64737a9e2 --- /dev/null +++ b/packages/integration-platform/src/manifests/aikido/checks/code-repository-scanning.ts @@ -0,0 +1,194 @@ +/** + * Code Repository Scanning Check + * + * Verifies that all code repositories are actively being scanned by Aikido. + * Ensures continuous security monitoring of the codebase. + */ + +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { IntegrationCheck } from '../../../types'; +import type { AikidoCodeRepositoriesResponse, AikidoCodeRepository } from '../types'; +import { targetRepositoriesVariable } from '../variables'; + +const SCAN_STALE_DAYS = 7; // Consider a scan stale after 7 days + +/** + * Check if a scan is stale based on unix timestamp or ISO string + */ +const isStale = (lastScannedAt: number | string | undefined): boolean => { + if (!lastScannedAt) return true; + + // Handle both unix timestamp (number) and ISO string + const lastScanMs = + typeof lastScannedAt === 'number' ? lastScannedAt * 1000 : new Date(lastScannedAt).getTime(); + + const diffDays = (Date.now() - lastScanMs) / (1000 * 60 * 60 * 24); + return diffDays > SCAN_STALE_DAYS; +}; + +export const codeRepositoryScanningCheck: IntegrationCheck = { + id: 'code_repository_scanning', + name: 'Code Repositories Actively Scanned', + description: 'Verify that all code repositories are being actively scanned for vulnerabilities', + taskMapping: TASK_TEMPLATES.secureCode, + defaultSeverity: 'medium', + + variables: [targetRepositoriesVariable], + + run: async (ctx) => { + const targetRepoIds = ctx.variables.target_repositories as string[] | undefined; + + ctx.log('Fetching code repositories from Aikido'); + + // Aikido API: https://apidocs.aikido.dev/reference/listcoderepos + // Note: The API returns a direct array without pagination support + // Adding page/per_page params causes empty response + const response = await ctx.fetch( + 'repositories/code', + ); + + // Handle both array response and wrapped response formats + const allRepos = Array.isArray(response) ? response : (response?.repositories ?? []); + + ctx.log(`Found ${allRepos.length} code repositories`); + + // Filter to target repos if specified + let repos = allRepos; + if (targetRepoIds && targetRepoIds.length > 0) { + repos = allRepos.filter((repo) => targetRepoIds.includes(String(repo.id))); + ctx.log(`Filtered to ${repos.length} target repositories`); + } + + if (repos.length === 0) { + // Differentiate between no repos connected vs filter mismatch + if (targetRepoIds && targetRepoIds.length > 0 && allRepos.length > 0) { + // Repositories exist but none match the target_repositories filter + ctx.fail({ + title: 'No matching repositories found', + description: `None of the ${targetRepoIds.length} specified target repository IDs match the ${allRepos.length} connected repositories. This may be due to typos, disconnected repositories, or incorrect IDs in the target_repositories configuration.`, + resourceType: 'workspace', + resourceId: 'aikido-repos', + severity: 'high', + remediation: `1. Verify the repository IDs in your target_repositories configuration +2. Go to Aikido > Repositories to find correct repository IDs +3. Check if the target repositories are still connected +4. Update the target_repositories variable with valid IDs +5. Or remove target_repositories to scan all connected repositories`, + evidence: { + target_repository_ids: targetRepoIds, + connected_repository_count: allRepos.length, + connected_repository_ids: allRepos.map((r) => String(r.id)), + checked_at: new Date().toISOString(), + }, + }); + } else { + // No repositories connected at all + ctx.fail({ + title: 'No code repositories connected', + description: + 'No code repositories are connected to Aikido. Connect your repositories to enable security scanning.', + resourceType: 'workspace', + resourceId: 'aikido-repos', + severity: 'high', + remediation: `1. Go to Aikido > Repositories +2. Click "Add Repository" or connect your source control provider +3. Select the repositories you want to scan +4. Enable scanning for each repository`, + evidence: { + total_repos: 0, + checked_at: new Date().toISOString(), + }, + }); + } + return; + } + + for (const repo of repos) { + // Use actual API field names: 'active', 'last_scanned_at', 'name' + const stale = isStale(repo.last_scanned_at ?? repo.last_scan_at); + const inactive = !(repo.active ?? repo.is_active); + const failed = repo.scan_status === 'failed'; + const repoName = repo.name || repo.full_name || String(repo.id); + + if (inactive) { + ctx.fail({ + title: `Repository not active: ${repoName}`, + description: `The repository ${repoName} is not activated for scanning in Aikido.`, + resourceType: 'repository', + resourceId: String(repo.id), + severity: 'medium', + remediation: `1. Go to Aikido > Repositories +2. Find ${repoName} +3. Click "Activate" to enable scanning`, + evidence: { + repo_id: repo.id, + name: repoName, + provider: repo.provider, + active: repo.active ?? repo.is_active, + }, + }); + } else if (failed) { + ctx.fail({ + title: `Scan failed: ${repoName}`, + description: `The last scan for ${repoName} failed.`, + resourceType: 'repository', + resourceId: String(repo.id), + severity: 'high', + remediation: `1. Go to Aikido > Repositories > ${repoName} +2. Check scan logs for error details +3. Verify repository access and permissions +4. Retry the scan`, + evidence: { + repo_id: repo.id, + name: repoName, + provider: repo.provider, + scan_status: repo.scan_status, + last_scanned_at: repo.last_scanned_at, + }, + }); + } else if (stale) { + const lastScanMs = repo.last_scanned_at + ? repo.last_scanned_at * 1000 + : repo.last_scan_at + ? new Date(repo.last_scan_at).getTime() + : null; + const daysSinceScan = lastScanMs + ? Math.floor((Date.now() - lastScanMs) / (1000 * 60 * 60 * 24)) + : 'never'; + + ctx.fail({ + title: `Stale scan: ${repoName}`, + description: `Repository ${repoName} hasn't been scanned in over ${SCAN_STALE_DAYS} days.`, + resourceType: 'repository', + resourceId: String(repo.id), + severity: 'low', + remediation: `1. Go to Aikido > Repositories > ${repoName} +2. Click "Scan now" to trigger a new scan +3. Verify webhook integration for automatic scanning`, + evidence: { + repo_id: repo.id, + name: repoName, + provider: repo.provider, + last_scanned_at: repo.last_scanned_at, + days_since_scan: daysSinceScan, + }, + }); + } else { + ctx.pass({ + title: `Repository actively scanned: ${repoName}`, + description: `Repository ${repoName} is active and has been scanned recently.`, + resourceType: 'repository', + resourceId: String(repo.id), + evidence: { + repo_id: repo.id, + name: repoName, + provider: repo.provider, + active: repo.active ?? repo.is_active, + last_scanned_at: repo.last_scanned_at, + checked_at: new Date().toISOString(), + }, + }); + } + } + }, +}; diff --git a/packages/integration-platform/src/manifests/aikido/checks/index.ts b/packages/integration-platform/src/manifests/aikido/checks/index.ts new file mode 100644 index 000000000..716154620 --- /dev/null +++ b/packages/integration-platform/src/manifests/aikido/checks/index.ts @@ -0,0 +1,9 @@ +/** + * Aikido Integration Checks + * + * Export all checks for use in the manifest. + */ + +export { codeRepositoryScanningCheck } from './code-repository-scanning'; +export { issueCountThresholdCheck } from './issue-count-threshold'; +export { openSecurityIssuesCheck } from './open-security-issues'; diff --git a/packages/integration-platform/src/manifests/aikido/checks/issue-count-threshold.ts b/packages/integration-platform/src/manifests/aikido/checks/issue-count-threshold.ts new file mode 100644 index 000000000..055112091 --- /dev/null +++ b/packages/integration-platform/src/manifests/aikido/checks/issue-count-threshold.ts @@ -0,0 +1,148 @@ +/** + * Issue Count Threshold Check + * + * Monitors the total number of open issues and fails if it exceeds + * a configurable threshold. Useful for maintaining security hygiene. + */ + +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { IntegrationCheck } from '../../../types'; +import type { AikidoIssueCounts, AikidoSeverity, AikidoSeverityCounts } from '../types'; +import { issueThresholdVariable, severityThresholdVariable } from '../variables'; + +/** + * Calculate the count of issues at or above the severity threshold. + * e.g., if threshold is "high", count critical + high issues. + */ +const countAtOrAboveSeverity = ( + counts: AikidoSeverityCounts, + threshold: AikidoSeverity, +): number => { + switch (threshold) { + case 'critical': + return counts.critical; + case 'high': + return counts.critical + counts.high; + case 'medium': + return counts.critical + counts.high + counts.medium; + case 'low': + default: + return counts.all; + } +}; + +export const issueCountThresholdCheck: IntegrationCheck = { + id: 'issue_count_threshold', + name: 'Issue Count Within Threshold', + description: 'Verify that the total number of open security issues is within acceptable limits', + taskMapping: TASK_TEMPLATES.monitoringAlerting, + defaultSeverity: 'medium', + + variables: [severityThresholdVariable, issueThresholdVariable], + + run: async (ctx) => { + const severityThreshold = (ctx.variables.severity_threshold as AikidoSeverity) || 'high'; + const threshold = (ctx.variables.issue_threshold as number) ?? 0; + + ctx.log( + `Fetching issue counts from Aikido (severity: ${severityThreshold}, threshold: ${threshold})`, + ); + + let counts: AikidoIssueCounts; + try { + // Aikido API: https://apidocs.aikido.dev/reference/getissuecounts + counts = await ctx.fetch('issues/counts'); + ctx.log(`Issue counts response: ${JSON.stringify(counts)}`); + } catch (error) { + ctx.warn(`Issue counts endpoint error: ${error}`); + ctx.pass({ + title: 'Issue count check skipped', + description: 'Could not fetch issue counts from Aikido.', + resourceType: 'workspace', + resourceId: 'issue-counts', + evidence: { + reason: 'API endpoint not accessible', + error: String(error), + checked_at: new Date().toISOString(), + }, + }); + return; + } + + // API returns: { issue_groups: { critical, high, medium, low, all }, issues: { ... } } + const issueGroups = counts?.issue_groups; + const issues = counts?.issues; + + if (!issueGroups) { + ctx.warn('No issue group counts in response'); + ctx.pass({ + title: 'Issue count check skipped', + description: 'Could not parse issue counts from Aikido response.', + resourceType: 'workspace', + resourceId: 'issue-counts', + evidence: { + reason: 'Invalid response format', + checked_at: new Date().toISOString(), + }, + }); + return; + } + + // Count only issues at or above the severity threshold + const openCount = countAtOrAboveSeverity(issueGroups, severityThreshold); + ctx.log(`Found ${openCount} issue groups at ${severityThreshold} severity or above`); + + // Build severity breakdown showing what's being counted + const severityLabel = + severityThreshold === 'low' ? 'all severities' : `${severityThreshold} severity or above`; + + const severityBreakdown = `Critical: ${issueGroups.critical}, High: ${issueGroups.high}, Medium: ${issueGroups.medium}, Low: ${issueGroups.low}`; + + if (openCount <= threshold) { + ctx.pass({ + title: `Issue count within threshold: ${openCount}/${threshold}`, + description: `There are ${openCount} issue groups at ${severityLabel}, which is within the configured threshold of ${threshold}.`, + resourceType: 'workspace', + resourceId: 'issue-counts', + evidence: { + counted_issues: openCount, + severity_filter: severityThreshold, + threshold, + total_issue_groups: issueGroups.all, + total_issues: issues?.all, + issue_groups_by_severity: issueGroups, + issues_by_severity: issues, + checked_at: new Date().toISOString(), + }, + }); + return; + } + + // Determine check severity based on how far over threshold + const overageRatio = openCount / (threshold || 1); + const checkSeverity = overageRatio >= 3 ? 'high' : 'medium'; + + ctx.fail({ + title: `Issue count exceeds threshold: ${openCount}/${threshold}`, + description: `There are ${openCount} issue groups at ${severityLabel}, which exceeds the configured threshold of ${threshold}. ${severityBreakdown}`, + resourceType: 'workspace', + resourceId: 'issue-counts', + severity: checkSeverity, + remediation: `1. Log into Aikido Security dashboard +2. Review open issues by priority (${issueGroups.critical} critical, ${issueGroups.high} high) +3. Address or appropriately snooze/ignore issues +4. Consider adjusting the threshold if the current limit is too restrictive`, + evidence: { + counted_issues: openCount, + severity_filter: severityThreshold, + threshold, + overage: openCount - threshold, + total_issue_groups: issueGroups.all, + total_issues: issues?.all, + issue_groups_by_severity: issueGroups, + issues_by_severity: issues, + checked_at: new Date().toISOString(), + }, + }); + }, +}; diff --git a/packages/integration-platform/src/manifests/aikido/checks/open-security-issues.ts b/packages/integration-platform/src/manifests/aikido/checks/open-security-issues.ts new file mode 100644 index 000000000..63e65b6e3 --- /dev/null +++ b/packages/integration-platform/src/manifests/aikido/checks/open-security-issues.ts @@ -0,0 +1,210 @@ +/** + * Open Security Issues Check + * + * Verifies that there are no open high/critical security issues in Aikido. + * This check monitors for vulnerabilities across code, dependencies, containers, and cloud. + */ + +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { IntegrationCheck } from '../../../types'; +import type { AikidoSeverity } from '../types'; +import { includeSnoozedVariable, severityThresholdVariable } from '../variables'; + +interface SeverityCounts { + critical: number; + high: number; + medium: number; + low: number; + all?: number; +} + +interface IssueCountsResponse { + issue_groups?: SeverityCounts; + issues?: SeverityCounts; + // Direct counts if API returns flat structure + critical?: number; + high?: number; + medium?: number; + low?: number; +} + +/** + * Calculate the count of issues at or above the severity threshold. + */ +const countAtOrAboveSeverity = (counts: SeverityCounts, threshold: AikidoSeverity): number => { + switch (threshold) { + case 'critical': + return counts.critical; + case 'high': + return counts.critical + counts.high; + case 'medium': + return counts.critical + counts.high + counts.medium; + case 'low': + default: + return counts.critical + counts.high + counts.medium + counts.low; + } +}; + +/** + * Extract severity counts from API response, handling different formats + */ +const extractCounts = (response: IssueCountsResponse): SeverityCounts => { + return ( + response.issue_groups ?? + response.issues ?? { + critical: response.critical ?? 0, + high: response.high ?? 0, + medium: response.medium ?? 0, + low: response.low ?? 0, + } + ); +}; + +/** + * Combine two severity count objects by summing each level + */ +const combineCounts = (a: SeverityCounts, b: SeverityCounts): SeverityCounts => ({ + critical: a.critical + b.critical, + high: a.high + b.high, + medium: a.medium + b.medium, + low: a.low + b.low, +}); + +export const openSecurityIssuesCheck: IntegrationCheck = { + id: 'open_security_issues', + name: 'No Open Security Issues', + description: + 'Verify that there are no open high or critical security vulnerabilities detected by Aikido', + taskMapping: TASK_TEMPLATES.secureCode, + defaultSeverity: 'high', + + variables: [severityThresholdVariable, includeSnoozedVariable], + + run: async (ctx) => { + const severityThreshold = (ctx.variables.severity_threshold as AikidoSeverity) || 'high'; + const includeSnoozed = ctx.variables.include_snoozed === true; + + ctx.log( + `Fetching issue counts from Aikido (threshold: ${severityThreshold}, include_snoozed: ${includeSnoozed})`, + ); + + // Aikido API: https://apidocs.aikido.dev/ + // Use issues/counts endpoint which returns counts by severity + // Pass status parameter to filter by issue status + let openResponse: IssueCountsResponse; + try { + openResponse = await ctx.fetch('issues/counts', { + params: { status: 'open' }, + }); + } catch (error) { + ctx.warn(`Failed to fetch issue counts from Aikido API: ${error}`); + ctx.pass({ + title: 'Unable to verify security issues', + description: + 'Could not fetch issue counts from Aikido API. The API may be temporarily unavailable.', + resourceType: 'workspace', + resourceId: 'aikido-workspace', + evidence: { + error: error instanceof Error ? error.message : String(error), + checked_at: new Date().toISOString(), + }, + }); + return; + } + + ctx.log(`Open issues response: ${JSON.stringify(openResponse)}`); + + let counts = extractCounts(openResponse); + + // If include_snoozed is enabled, also fetch snoozed issues and combine + if (includeSnoozed) { + ctx.log('Fetching snoozed issues (include_snoozed is enabled)'); + try { + const snoozedResponse = await ctx.fetch('issues/counts', { + params: { status: 'snoozed' }, + }); + ctx.log(`Snoozed issues response: ${JSON.stringify(snoozedResponse)}`); + + const snoozedCounts = extractCounts(snoozedResponse); + counts = combineCounts(counts, snoozedCounts); + + ctx.log( + `Combined counts (open + snoozed): critical=${counts.critical}, high=${counts.high}, medium=${counts.medium}, low=${counts.low}`, + ); + } catch (error) { + ctx.warn(`Failed to fetch snoozed issues: ${error}`); + // Continue with just open issues if snoozed fetch fails + } + } + + ctx.log( + `Issue counts: critical=${counts.critical}, high=${counts.high}, medium=${counts.medium}, low=${counts.low}`, + ); + + // Calculate total issues at or above threshold + const issueCount = countAtOrAboveSeverity(counts, severityThreshold); + const severityLabel = + severityThreshold === 'low' ? 'all severities' : `${severityThreshold} severity or above`; + + ctx.log(`Found ${issueCount} issues at ${severityLabel}`); + + const statusLabel = includeSnoozed ? 'open or snoozed' : 'open'; + + if (issueCount === 0) { + ctx.pass({ + title: 'No open security issues found', + description: `No ${statusLabel} issues at ${severityLabel} were detected by Aikido.`, + resourceType: 'workspace', + resourceId: 'aikido-workspace', + evidence: { + severity_threshold: severityThreshold, + include_snoozed: includeSnoozed, + issues_above_threshold: 0, + counts: { + critical: counts.critical, + high: counts.high, + medium: counts.medium, + low: counts.low, + }, + checked_at: new Date().toISOString(), + }, + }); + return; + } + + // There are open issues - report as failure + // Determine severity based on highest actual issue severity present + const checkSeverity: AikidoSeverity = + counts.critical > 0 + ? 'critical' + : counts.high > 0 + ? 'high' + : counts.medium > 0 + ? 'medium' + : 'low'; + + ctx.fail({ + title: `${issueCount} security issues require attention`, + description: `Found ${counts.critical} critical, ${counts.high} high, ${counts.medium} medium, ${counts.low} low severity ${statusLabel} issues. Issues at ${severityLabel}: ${issueCount}`, + resourceType: 'workspace', + resourceId: 'aikido-issues', + severity: checkSeverity, + remediation: `1. Log into Aikido Security dashboard at https://app.aikido.dev +2. Review and prioritize the ${issueCount} ${statusLabel} issues +3. Address critical and high severity issues first +4. Apply recommended fixes and re-scan affected repositories`, + evidence: { + severity_threshold: severityThreshold, + include_snoozed: includeSnoozed, + issues_above_threshold: issueCount, + counts: { + critical: counts.critical, + high: counts.high, + medium: counts.medium, + low: counts.low, + }, + checked_at: new Date().toISOString(), + }, + }); + }, +}; diff --git a/packages/integration-platform/src/manifests/aikido/index.ts b/packages/integration-platform/src/manifests/aikido/index.ts new file mode 100644 index 000000000..24b1b6a70 --- /dev/null +++ b/packages/integration-platform/src/manifests/aikido/index.ts @@ -0,0 +1,65 @@ +/** + * Aikido Security Integration Manifest + * + * Aikido is a developer-focused security platform that scans code repositories, + * containers, clouds, and domains for vulnerabilities. + * + * API Documentation: https://apidocs.aikido.dev/reference + */ + +import type { IntegrationManifest } from '../../types'; +import { + codeRepositoryScanningCheck, + issueCountThresholdCheck, + openSecurityIssuesCheck, +} from './checks'; + +export const manifest: IntegrationManifest = { + id: 'aikido', + name: 'Aikido Security', + description: + 'Connect Aikido Security to monitor vulnerabilities, code security, and compliance status across your repositories and infrastructure.', + category: 'Security', + logoUrl: 'https://img.logo.dev/aikido.dev?token=pk_AZatYxV5QDSfWpRDaBxzRQ', + docsUrl: 'https://docs.trycomp.ai/integrations/aikido', + + // API configuration + baseUrl: 'https://app.aikido.dev/api/public/v1/', + defaultHeaders: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + + auth: { + type: 'oauth2', + config: { + authorizeUrl: 'https://app.aikido.dev/oauth/authorize', + tokenUrl: 'https://app.aikido.dev/api/oauth/token', + scopes: [], + pkce: false, + // Aikido uses Basic auth header for client credentials + clientAuthMethod: 'header', + supportsRefreshToken: true, + setupInstructions: `To connect Aikido Security: + +1. Go to Aikido Settings > Integrations > API +2. Click "Create OAuth App" or use existing credentials +3. Set the callback URL to the URL shown below +4. Copy the Client ID and Client Secret +5. Paste them here and click Connect + +Note: You'll need admin access to your Aikido workspace to create OAuth credentials.`, + createAppUrl: 'https://app.aikido.dev/settings/integrations/api/aikido/rest', + }, + }, + + capabilities: ['checks'], + + // Compliance checks that run daily + checks: [openSecurityIssuesCheck, codeRepositoryScanningCheck, issueCountThresholdCheck], + + isActive: true, +}; + +export default manifest; +export * from './types'; diff --git a/packages/integration-platform/src/manifests/aikido/types.ts b/packages/integration-platform/src/manifests/aikido/types.ts new file mode 100644 index 000000000..a7b88c0fa --- /dev/null +++ b/packages/integration-platform/src/manifests/aikido/types.ts @@ -0,0 +1,268 @@ +/** + * Aikido Security API Types + * + * These types match the Aikido REST API responses. + * API Documentation: https://apidocs.aikido.dev/reference + */ + +// ============================================================================ +// Issue Types +// ============================================================================ + +export type AikidoSeverity = 'low' | 'medium' | 'high' | 'critical'; + +export type AikidoIssueStatus = 'open' | 'ignored' | 'snoozed' | 'fixed'; + +export type AikidoIssueType = + | 'dependency' + | 'sast' + | 'iac' + | 'secrets' + | 'container' + | 'cloud' + | 'dast'; + +export interface AikidoIssueGroup { + id: number; + group_id?: number; + rule: string; + rule_id?: string; + /** Numeric severity score (0-100) */ + severity: number; + /** String severity level */ + severity_score: AikidoSeverity; + status: AikidoIssueStatus; + type: AikidoIssueType; + attack_surface?: string; + first_detected_at: number; + affected_package?: string; + affected_file?: string; + code_repo_name?: string; + code_repo_id?: number; + container_repo_id?: number; + container_repo_name?: string; + start_line?: number; + end_line?: number; + cwe_classes?: string[]; + installed_version?: string; + patched_versions?: string[]; + sla_days?: number; + sla_remediate_by?: number; + ignored_at?: number | null; + ignored_by?: string | null; + closed_at?: number | null; + snooze_until?: number | null; + license?: string | null; + programming_language?: string; +} + +export interface AikidoIssueGroupsResponse { + /** Issue groups - API may use 'groups' key */ + groups?: AikidoIssueGroup[]; + /** Alternative key name - kept for backwards compatibility */ + issue_groups?: AikidoIssueGroup[]; + total?: number; + page?: number; + per_page?: number; +} + +/** + * Severity breakdown with counts per level plus total + */ +export interface AikidoSeverityCounts { + critical: number; + high: number; + medium: number; + low: number; + all: number; +} + +/** + * Response from GET /issues/counts endpoint + */ +export interface AikidoIssueCounts { + /** Counts grouped by issue group (deduplicated issues) */ + issue_groups: AikidoSeverityCounts; + /** Counts of individual issues (may have multiple per group) */ + issues: AikidoSeverityCounts; +} + +// ============================================================================ +// Code Repository Types +// ============================================================================ + +export interface AikidoCodeRepository { + /** Numeric ID from Aikido API */ + id: number; + /** Repository name (e.g., "comp-new") */ + name: string; + /** External repository ID from provider */ + external_repo_id?: string; + /** Source control provider */ + provider: 'github' | 'gitlab' | 'bitbucket' | 'azure_devops'; + /** API URL to the repository */ + url?: string; + /** Default branch name */ + branch?: string; + /** Whether the repository is active in Aikido */ + active?: boolean; + /** Unix timestamp of last scan */ + last_scanned_at?: number; + /** Legacy fields for backwards compatibility */ + full_name?: string; + is_active?: boolean; + last_scan_at?: string; + scan_status?: 'pending' | 'scanning' | 'completed' | 'failed'; + issues_count?: number; + sensitivity?: 'low' | 'medium' | 'high'; + default_branch?: string; + created_at?: string; + updated_at?: string; +} + +export interface AikidoCodeRepositoriesResponse { + repositories: AikidoCodeRepository[]; + total: number; + page: number; + per_page: number; +} + +// ============================================================================ +// Compliance Types +// ============================================================================ + +/** + * Individual requirement check in the compliance report. + * Status can be 'complying', 'not_complying', or 'not_applicable'. + */ +export interface AikidoComplianceRequirement { + title: string; + status: 'complying' | 'not_complying' | 'not_applicable'; + type?: string; +} + +/** + * Group of requirements under a control criterion. + * Groups are organized by type (e.g., "Protects unauthorized runtime access"). + */ +export interface AikidoComplianceGroup { + type: string; + requirements: AikidoComplianceRequirement[]; +} + +/** + * Control criterion in the compliance framework (e.g., CC6.8 for SOC2). + * Each control has a percentage score and groups of requirements. + */ +export interface AikidoComplianceControl { + id: string; + title: string; + percentage: number; + warning?: string; + groups: AikidoComplianceGroup[]; +} + +/** + * Response from the SOC2/ISO27001 overview API endpoint. + * Contains an array of control criteria with their compliance status. + */ +export interface AikidoComplianceOverviewResponse { + controls: AikidoComplianceControl[]; +} + +/** + * @deprecated Use AikidoComplianceOverviewResponse instead. + * Kept for backwards compatibility. + */ +export interface AikidoComplianceStatus { + framework: 'soc2' | 'iso27001' | 'nis2'; + overall_status: 'compliant' | 'non_compliant' | 'partial'; + passing_count: number; + failing_count: number; + not_applicable_count: number; + total_requirements: number; + requirements: Array<{ + id: string; + name: string; + description: string; + status: 'passing' | 'failing' | 'not_applicable'; + category: string; + evidence?: string; + }>; + last_checked_at: string; +} + +// ============================================================================ +// Cloud Types +// ============================================================================ + +export type AikidoCloudProvider = 'aws' | 'azure' | 'gcp' | 'kubernetes'; + +export interface AikidoCloud { + id: string; + provider: AikidoCloudProvider; + name: string; + account_id?: string; + subscription_id?: string; + project_id?: string; + status: 'connected' | 'disconnected' | 'error'; + issues_count: number; + last_scan_at?: string; + created_at: string; +} + +export interface AikidoCloudsResponse { + clouds: AikidoCloud[]; + total: number; +} + +// ============================================================================ +// Container Types +// ============================================================================ + +export interface AikidoContainer { + id: string; + name: string; + image: string; + tag?: string; + registry?: string; + is_active: boolean; + issues_count: number; + last_scan_at?: string; + created_at: string; +} + +export interface AikidoContainersResponse { + containers: AikidoContainer[]; + total: number; + page: number; + per_page: number; +} + +// ============================================================================ +// Workspace Types +// ============================================================================ + +export interface AikidoWorkspace { + id: string; + name: string; + plan: string; + created_at: string; +} + +// ============================================================================ +// User Types +// ============================================================================ + +export interface AikidoUser { + id: string; + email: string; + name?: string; + role: 'admin' | 'member' | 'viewer'; + created_at: string; +} + +export interface AikidoUsersResponse { + users: AikidoUser[]; + total: number; +} diff --git a/packages/integration-platform/src/manifests/aikido/variables.ts b/packages/integration-platform/src/manifests/aikido/variables.ts new file mode 100644 index 000000000..4269b49f8 --- /dev/null +++ b/packages/integration-platform/src/manifests/aikido/variables.ts @@ -0,0 +1,75 @@ +/** + * Shared Variables for Aikido Integration + * + * These variables can be configured by users when setting up the integration. + */ + +import type { CheckVariable } from '../../types'; +import type { AikidoCodeRepository } from './types'; + +/** + * Minimum severity level to fail checks on. + * Issues below this threshold will pass. + */ +export const severityThresholdVariable: CheckVariable = { + id: 'severity_threshold', + label: 'Minimum severity to fail on', + type: 'select', + required: false, + default: 'high', + helpText: 'Issues below this severity will not cause the check to fail', + options: [ + { value: 'low', label: 'Low (fail on all issues)' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High (recommended)' }, + { value: 'critical', label: 'Critical only' }, + ], +}; + +/** + * Maximum number of open issues before failing the threshold check. + */ +export const issueThresholdVariable: CheckVariable = { + id: 'issue_threshold', + label: 'Maximum allowed open issues', + type: 'number', + required: false, + default: 0, + placeholder: '0', + helpText: 'Check fails if total open issues exceeds this number (0 = no issues allowed)', +}; + +/** + * Target repositories to monitor. + * Dynamically fetches available repositories from Aikido. + */ +export const targetRepositoriesVariable: CheckVariable = { + id: 'target_repositories', + label: 'Repositories to monitor', + type: 'multi-select', + required: false, + helpText: 'Leave empty to check all repositories', + fetchOptions: async (ctx) => { + // Aikido API: https://apidocs.aikido.dev/reference/listcoderepos + // Note: The API returns a direct array, NOT a wrapped object + // Note: Adding page/per_page params causes empty response - don't use fetchAllPages + const repositories = await ctx.fetch('repositories/code'); + + return repositories.map((repo) => ({ + value: String(repo.id), + label: `${repo.name} (${repo.provider})`, + })); + }, +}; + +/** + * Include snoozed issues in checks. + */ +export const includeSnoozedVariable: CheckVariable = { + id: 'include_snoozed', + label: 'Include snoozed issues', + type: 'boolean', + required: false, + default: false, + helpText: 'If enabled, snoozed issues will also be counted as open', +}; diff --git a/packages/integration-platform/src/manifests/google-workspace/checks/two-factor-auth.ts b/packages/integration-platform/src/manifests/google-workspace/checks/two-factor-auth.ts index 0189c1abf..87745f7dd 100644 --- a/packages/integration-platform/src/manifests/google-workspace/checks/two-factor-auth.ts +++ b/packages/integration-platform/src/manifests/google-workspace/checks/two-factor-auth.ts @@ -11,7 +11,7 @@ export const twoFactorAuthCheck: IntegrationCheck = { id: 'two-factor-auth', name: '2-Step Verification Enabled', description: 'Verify all users have 2-Step Verification (2FA) enabled in Google Workspace', - taskMapping: TASK_TEMPLATES['2fa'], + taskMapping: TASK_TEMPLATES.twoFactorAuth, variables: [targetOrgUnitsVariable, includeSuspendedVariable], run: async (ctx: CheckContext) => { diff --git a/packages/integration-platform/src/registry/index.ts b/packages/integration-platform/src/registry/index.ts index b3cb3e312..e7638c47e 100644 --- a/packages/integration-platform/src/registry/index.ts +++ b/packages/integration-platform/src/registry/index.ts @@ -15,6 +15,7 @@ import { googleWorkspaceManifest } from '../manifests/google-workspace'; import { manifest as jumpcloudManifest } from '../manifests/jumpcloud'; import { ripplingManifest } from '../manifests/rippling'; import { vercelManifest } from '../manifests/vercel'; +import { manifest as aikidoManifest } from '../manifests/aikido'; // ============================================================================ // Registry Implementation @@ -105,6 +106,7 @@ const allManifests: IntegrationManifest[] = [ jumpcloudManifest, ripplingManifest, vercelManifest, + aikidoManifest, ]; // Create and export the registry singleton diff --git a/packages/integration-platform/src/task-mappings.ts b/packages/integration-platform/src/task-mappings.ts index 0621f2d45..aa4823af6 100644 --- a/packages/integration-platform/src/task-mappings.ts +++ b/packages/integration-platform/src/task-mappings.ts @@ -186,7 +186,7 @@ export const TASK_TEMPLATES = { /** Employee Descriptions */ employeeDescriptions: 'frk_tt_684069a3a0dd8322b2ac3f03', /** 2FA */ - '2fa': 'frk_tt_68406cd9dde2d8cd4c463fe0', + twoFactorAuth: 'frk_tt_68406cd9dde2d8cd4c463fe0', // 2FA /** Role-based Access Controls */ rolebasedAccessControls: 'frk_tt_68e80544d9734e0402cfa807', /** Employee Performance Evaluations */ @@ -222,13 +222,13 @@ export const TASK_TEMPLATE_INFO: Record< TaskTemplateId, { name: string; description: string; department: string; frequency: string } > = { - frk_tt_68407ae5274a64092c305104: { + 'frk_tt_68407ae5274a64092c305104': { name: 'Secure Secrets', description: `Use your cloud providers default secret manager for storing secrets. Don't commit secrets to Git and...`, department: 'itsm', frequency: 'yearly', }, - frk_tt_6849c1a1038c3f18cfff47bf: { + 'frk_tt_6849c1a1038c3f18cfff47bf': { name: 'Utility Monitoring', description: `Maintain a list of approved privileged utilities (e.g. iptables, tcpdump, disk‑encryption tools) @@ -236,25 +236,25 @@ Sh...`, department: 'it', frequency: 'yearly', }, - frk_tt_68406951bd282273ebe286cc: { + 'frk_tt_68406951bd282273ebe286cc': { name: 'Employee Verification', description: `Maintain a list of reference checks you made for every new hire. Verify the identity of every new hi...`, department: 'hr', frequency: 'yearly', }, - frk_tt_68406e7abae2a9b16c2cc197: { + 'frk_tt_68406e7abae2a9b16c2cc197': { name: 'Planning', description: `Make sure you have point in time recovery / backups enabled and that you test this works on an annua...`, department: 'gov', frequency: 'yearly', }, - frk_tt_68406a514e90bb6e32e0b107: { + 'frk_tt_68406a514e90bb6e32e0b107': { name: 'Contact Information', description: `You need to show what services/software you offer and provide clear instructions on how your custome...`, department: 'it', frequency: 'yearly', }, - frk_tt_68406f411fe27e47a0d6d5f3: { + 'frk_tt_68406f411fe27e47a0d6d5f3': { name: 'TLS / HTTPS', description: `Ensure TLS / HTTPS is enabled. @@ -262,19 +262,19 @@ Upload a screenshot from SSL Labs to show this is enabled....`, department: 'itsm', frequency: 'yearly', }, - frk_tt_68406e353df3bc002994acef: { + 'frk_tt_68406e353df3bc002994acef': { name: 'Secure Code', description: `Ensure dependabot or it's equivalent is enabled to automatically identify insecure or patched depend...`, department: 'itsm', frequency: 'yearly', }, - frk_tt_68406af04a4acb93083413b9: { + 'frk_tt_68406af04a4acb93083413b9': { name: 'Monitoring & Alerting', description: `Ensure you have logging enabled in cloud environments (e.g. Google Cloud or Vercel) and review it pe...`, department: 'itsm', frequency: 'yearly', }, - frk_tt_686b51339d7e9f8ef2081a70: { + 'frk_tt_686b51339d7e9f8ef2081a70': { name: 'Data Masking', description: `Hide Sensitive fields @@ -282,37 +282,37 @@ PCI: Mask PAN when displayed outside the secure CDE, ensuring only truncated ... department: 'it', frequency: 'yearly', }, - frk_tt_68406d64f09f13271c14dd01: { + 'frk_tt_68406d64f09f13271c14dd01': { name: 'Code Changes', description: `Enable branch protection on your main branch to prevent direct pushes and enforce pull requests. Ens...`, department: 'gov', frequency: 'yearly', }, - frk_tt_684076a02261faf3d331289d: { + 'frk_tt_684076a02261faf3d331289d': { name: 'Publish Policies', description: `Make sure all of the policies in Comp AI have been published and all employees have signed/agreed to...`, department: 'gov', frequency: 'yearly', }, - frk_tt_68406903839203801ac8041a: { + 'frk_tt_68406903839203801ac8041a': { name: 'Device List', description: `Keep and maintain a list of your devices (laptops/servers). If you install the Comp AI agent on your...`, department: 'admin', frequency: 'yearly', }, - frk_tt_68b59e7a29bec89c57014868: { + 'frk_tt_68b59e7a29bec89c57014868': { name: 'Statement of Applicability', description: `The Statement of Applicability identifies relevant ISO 27001 Annex A controls, explains their inclus...`, department: 'admin', frequency: 'yearly', }, - frk_tt_68406d2e86acc048d1774ea6: { + 'frk_tt_68406d2e86acc048d1774ea6': { name: 'App Availability', description: `Make sure your website or app doesn't slow down or crash because too many people are using it, or if...`, department: 'it', frequency: 'yearly', }, - frk_tt_68406eedf0f0ddd220ea19c2: { + 'frk_tt_68406eedf0f0ddd220ea19c2': { name: 'Sanitized Inputs', description: `Implement input validation and sanitization using libraries such as Zod, Pydantic, or equivalent. @@ -320,37 +320,37 @@ PCI: Mask PAN when displayed outside the secure CDE, ensuring only truncated ... department: 'it', frequency: 'yearly', }, - frk_tt_6840796f77d8a0dff53f947a: { + 'frk_tt_6840796f77d8a0dff53f947a': { name: 'Secure Devices', description: `Ensure all devices have BitLocker/FileVault enabled, screen lock enabled after 5 minutes (for mac) o...`, department: 'itsm', frequency: 'yearly', }, - frk_tt_68b5ce9dd597ac7d650e4915: { + 'frk_tt_68b5ce9dd597ac7d650e4915': { name: 'Reassess Legal Basis for Processing', description: `Review and document the lawful basis for each processing activity under GDPR Article 6 (e.g., consen...`, department: 'admin', frequency: 'yearly', }, - frk_tt_68b5ce9b5393ae083c3beadf: { + 'frk_tt_68b5ce9b5393ae083c3beadf': { name: 'Appoint or Review Data Protection Officer', description: `Assess whether your organization is required to appoint a Data Protection Officer (DPO) under GDPR (...`, department: 'admin', frequency: 'yearly', }, - frk_tt_68e52b2618cb9d9722c6edfd: { + 'frk_tt_68e52b2618cb9d9722c6edfd': { name: 'Internal Security Audit', description: `Upload evidence of a recent internal information security audit - e.g., audit plan/scope, auditor in...`, department: 'itsm', frequency: 'yearly', }, - frk_tt_68cc327ff5d3130a1b42420b: { + 'frk_tt_68cc327ff5d3130a1b42420b': { name: 'AI MS Communication Plan', description: `Document and maintain a plan outlining internal and external communication relevant to the AI MS: wh...`, department: 'hr', frequency: 'yearly', }, - frk_tt_68c3248edf65e750909dfd07: { + 'frk_tt_68c3248edf65e750909dfd07': { name: 'Attestation of Compliance', description: `Download and complete the AoC form. Once complete, upload the file here. @@ -358,73 +358,73 @@ For Merchants - https://d...`, department: 'it', frequency: 'yearly', }, - frk_tt_68c332c3bc6d696bb61e7351: { + 'frk_tt_68c332c3bc6d696bb61e7351': { name: 'Self-Assessment Questionnaires', description: `- SAQ A: For merchants fully outsourcing all card data functions to PCI-validated 3rd parties (you r...`, department: 'it', frequency: 'yearly', }, - frk_tt_68b5ce9d508cacf8e4517b56: { + 'frk_tt_68b5ce9d508cacf8e4517b56': { name: 'Review International Data Transfers', description: `Review and document all international data transfers to ensure compliance with GDPR Chapter V. Confi...`, department: 'admin', frequency: 'yearly', }, - frk_tt_68cc25658bacb2ccff56adf9: { + 'frk_tt_68cc25658bacb2ccff56adf9': { name: 'AI Context Register', description: `Maintain a register of internal and external issues relevant to AI systems, including organizational...`, department: 'it', frequency: 'yearly', }, - frk_tt_6840791cac0a7b780dbaf932: { + 'frk_tt_6840791cac0a7b780dbaf932': { name: 'Public Policies', description: `Add a comment with links to your privacy policy / terms of service. Ensure Privacy policy has a for...`, department: 'it', frequency: 'yearly', }, - frk_tt_68cc27431d1266e3c77d7c0f: { + 'frk_tt_68cc27431d1266e3c77d7c0f': { name: 'Stakeholder Register / Interested Parties Log', description: `Document a register of interested parties (e.g., regulators, customers, employees, partners, societa...`, department: 'admin', frequency: 'yearly', }, - frk_tt_68e52b26bf0e656af9e4e9c3: { + 'frk_tt_68e52b26bf0e656af9e4e9c3': { name: 'Encryption at Rest', description: `Upload evidence that all data stores are encrypted at rest -cloud console screenshots showing encryp...`, department: 'it', frequency: 'yearly', }, - frk_tt_68b5ce9c6c1bdb171870f623: { + 'frk_tt_68b5ce9c6c1bdb171870f623': { name: 'Manage Third-party and EU Representative Relationships', description: `Ensure all third-party processors have GDPR-compliant Data Processing Agreements (DPAs) in place. If...`, department: 'admin', frequency: 'yearly', }, - frk_tt_68e52a484cad0014de7a628f: { + 'frk_tt_68e52a484cad0014de7a628f': { name: 'Separation of Environments', description: `Upload proof that dev/test/staging and production are segregated - e.g., a cloud console screenshot ...`, department: 'it', frequency: 'yearly', }, - frk_tt_68406b4f40c87c12ae0479ce: { + 'frk_tt_68406b4f40c87c12ae0479ce': { name: 'Incident Response', description: `Keep a record of all security incidents and how they were resolved. If there haven't been any, add a...`, department: 'itsm', frequency: 'yearly', }, - frk_tt_68c8309516fdbc514404988d: { + 'frk_tt_68c8309516fdbc514404988d': { name: 'Board Meetings & Independence', description: `Submit the most recent board (or management) meeting agenda and minutes covering security topics. In...`, department: 'admin', frequency: 'yearly', }, - frk_tt_68cc2ce9cb0d2a4774975ace: { + 'frk_tt_68cc2ce9cb0d2a4774975ace': { name: 'AI MS Roles & Responsibilities Assignment', description: `Define and communicate responsibilities for the AI MS, including who ensures compliance with 42001 r...`, department: 'hr', frequency: 'yearly', }, - frk_tt_6849aad98c50d734dd904d98: { + 'frk_tt_6849aad98c50d734dd904d98': { name: 'Diagramming', description: `Architecture Diagram: Draw a single‑page diagram (Figma, Draw.io, Lucidchart—whatever is fastest) @@ -432,79 +432,79 @@ F...`, department: 'it', frequency: 'yearly', }, - frk_tt_68cc2f30b51920e5515465e6: { + 'frk_tt_68cc2f30b51920e5515465e6': { name: 'AI System Impact Assessment Procedure', description: `Define a process to identify/assess potential consequences of AI system deployment, intended use, an...`, department: 'it', frequency: 'yearly', }, - frk_tt_68cc2fa423533e602e4dee25: { + 'frk_tt_68cc2fa423533e602e4dee25': { name: 'AI Objectives Register', description: `Define measurable AI objectives (aligned to AI Policy and compliance requirements), assign resources...`, department: 'admin', frequency: 'yearly', }, - frk_tt_68cc301a8fd914534ab95b11: { + 'frk_tt_68cc301a8fd914534ab95b11': { name: 'AI System Change Log', description: `Maintain a documented log of changes to the AI management system, including model retraining, policy...`, department: 'it', frequency: 'yearly', }, - frk_tt_68cc30fbbeabb8a4c2f56082: { + 'frk_tt_68cc30fbbeabb8a4c2f56082': { name: 'AI MS Resource Allocation Record', description: `Maintain documented evidence of resources allocated for the AI MS, including skills, tools, infrastr...`, department: 'it', frequency: 'yearly', }, - frk_tt_68cc395b90e179fe3209b795: { + 'frk_tt_68cc395b90e179fe3209b795': { name: 'AI MS Operational Control Procedure', description: `Document and implement an AI Operational Controls procedure defining process criteria, monitoring ef...`, department: 'it', frequency: 'yearly', }, - frk_tt_68cc3f427e309607f1ad5ba4: { + 'frk_tt_68cc3f427e309607f1ad5ba4': { name: 'AI Risk Treatment Implementation Record', description: `Maintain documented evidence of risk treatment implementation corresponding to the treatment plan. R...`, department: 'it', frequency: 'yearly', }, - frk_tt_68cc3ffab3fb703917f51e19: { + 'frk_tt_68cc3ffab3fb703917f51e19': { name: 'AI Impact Assessment Log', description: `Conduct AI system impact assessments at planned intervals or when significant changes occur. Documen...`, department: 'it', frequency: 'yearly', }, - frk_tt_68cc4190caf0b458effcee6e: { + 'frk_tt_68cc4190caf0b458effcee6e': { name: 'AI MS Internal Audit Program', description: `Establish and maintain an internal audit program for the AI MS. Define frequency, methods, auditor r...`, department: 'it', frequency: 'yearly', }, - frk_tt_68cc41ad7cbd2839c0bc7104: { + 'frk_tt_68cc41ad7cbd2839c0bc7104': { name: 'AI Internal Audit Reports', description: `Conduct AI MS audits at planned intervals. Maintain documented evidence of objectives, criteria, sco...`, department: 'it', frequency: 'yearly', }, - frk_tt_68cc459ed57b70a4cb631e72: { + 'frk_tt_68cc459ed57b70a4cb631e72': { name: 'AI MS Continual Improvement Log', description: `Maintain a documented continual improvement log for the AI MS. Capture decisions, actions, and imple...`, department: 'it', frequency: 'yearly', }, - frk_tt_68d28f68f117d45c0adcba33: { + 'frk_tt_68d28f68f117d45c0adcba33': { name: 'Legal Proof of Company Registration', description: `Upload official proof of legal entity registration—e.g., a certificate of incorporation or governmen...`, department: 'admin', frequency: 'yearly', }, - frk_tt_68cc39efd7916e240451cae5: { + 'frk_tt_68cc39efd7916e240451cae5': { name: 'AI Risk Assessment Execution (Log)', description: `Add AI related Risks to the risk register....`, department: 'it', frequency: 'yearly', }, - frk_tt_68dc1a3a9b92bb4ffb89e334: { + 'frk_tt_68dc1a3a9b92bb4ffb89e334': { name: 'Systems Description', description: `Provide a short paragraph giving a description of your app & any architecture around it. @@ -512,101 +512,101 @@ F...`, department: 'it', frequency: 'yearly', }, - frk_tt_68e1d5667f2b14a9b0c2daf8: { + 'frk_tt_68e1d5667f2b14a9b0c2daf8': { name: 'NEN 7510 Risk Assessments', description: `Create and assess risks, in the risk register tab, that identifies and evaluates information‑securit...`, department: 'hr', frequency: 'yearly', }, - frk_tt_68e1d619944625cc1876540c: { + 'frk_tt_68e1d619944625cc1876540c': { name: 'Management Review Minutes', description: `Prove that top management reviews the performance of the ISMS - including incidents, risks, audits, ...`, department: 'admin', frequency: 'quarterly', }, - frk_tt_68e52b274a7c38c62db08e80: { + 'frk_tt_68e52b274a7c38c62db08e80': { name: 'Organisation Chart', description: `Upload a hierarchical organization chart showing reporting lines, with each box including the person...`, department: 'hr', frequency: 'yearly', }, - frk_tt_684069a3a0dd8322b2ac3f03: { + 'frk_tt_684069a3a0dd8322b2ac3f03': { name: 'Employee Descriptions', description: `We need to make sure every employee has a clear job description. Once a year, you'll meet with each ...`, department: 'hr', frequency: 'yearly', }, - frk_tt_68406cd9dde2d8cd4c463fe0: { + 'frk_tt_68406cd9dde2d8cd4c463fe0': { name: '2FA', description: `Always enable 2FA/MFA (Two-Factor Authentication/Multi-Factor Authentication) on Google Workspace, t...`, department: 'itsm', frequency: 'yearly', }, - frk_tt_68e80544d9734e0402cfa807: { + 'frk_tt_68e80544d9734e0402cfa807': { name: 'Role-based Access Controls', description: `Upload proof that access is managed by roles- RBAC matrix and role definitions or use our template: ...`, department: 'it', frequency: 'yearly', }, - frk_tt_68e52b27c4bdbf1b24051b8b: { + 'frk_tt_68e52b27c4bdbf1b24051b8b': { name: 'Employee Performance Evaluations', description: `Employee Performance Evaluations Upload one recent employee performance evaluation (anonymized) - ei...`, department: 'hr', frequency: 'yearly', }, - frk_tt_68406ca292d9fffb264991b9: { + 'frk_tt_68406ca292d9fffb264991b9': { name: 'Employee Access', description: `Ensure you are using an identity provider like Google Workspace and upload a screenshot of a list of...`, department: 'it', frequency: 'yearly', }, - frk_tt_68e80545a8b432bc59eb8037: { + 'frk_tt_68e80545a8b432bc59eb8037': { name: 'Incident Response Tabletop Exercise', description: `Upload evidence of a recent incident response tabletop exercise - agenda/scenario, attendee list and...`, department: 'itsm', frequency: 'yearly', }, - frk_tt_68e805457c2dcc784e72e3cc: { + 'frk_tt_68e805457c2dcc784e72e3cc': { name: 'Access Review Log', description: `Upload an access review log for key systems -showing review dates, reviewers/owners, users/roles rev...`, department: 'it', frequency: 'yearly', }, - frk_tt_68e52b269db179c434734766: { + 'frk_tt_68e52b269db179c434734766': { name: 'Backup Restoration Test', description: `Provide evidence of a recent backup restoration test - either share a link to a short screen recordi...`, department: 'it', frequency: 'yearly', }, - frk_tt_68e52b26b166e2c0a0d11956: { + 'frk_tt_68e52b26b166e2c0a0d11956': { name: 'Backup logs', description: `Upload backup job logs covering the last 10 consecutive days (all critical systems) - e.g., exported...`, department: 'it', frequency: 'yearly', }, - frk_tt_6901e040a21d5e8fdc9736e8: { + 'frk_tt_6901e040a21d5e8fdc9736e8': { name: 'Building / Workplace Rules', description: `Email or note from your building contact (landlord, building manager, or coworking operator) that in...`, department: 'admin', frequency: 'yearly', }, - frk_tt_6901e041bb02b41fa3b7dca9: { + 'frk_tt_6901e041bb02b41fa3b7dca9': { name: 'Office Access & Door Monitoring', description: `If you use a smart lock/alarm system: - Access list (export or simple table): name, role, code/badge...`, department: 'admin', frequency: 'yearly', }, - frk_tt_6901e0aa49fb834934748c93: { + 'frk_tt_6901e0aa49fb834934748c93': { name: 'Visitor Control', description: `Upload a Visitor log sample for a recent week (paper scan or form export). Photo of visitor badge/st...`, department: 'admin', frequency: 'yearly', }, - frk_tt_6901e0aa6d3f2bbab1ea5b84: { + 'frk_tt_6901e0aa6d3f2bbab1ea5b84': { name: 'Secure Storage', description: `Physical storage (cabinet/media): - Photo of locked cabinet @@ -615,13 +615,13 @@ Photo of visitor badge/st...`, department: 'admin', frequency: 'yearly', }, - frk_tt_69033a6bfeb4759be36257bc: { + 'frk_tt_69033a6bfeb4759be36257bc': { name: 'Infrastructure Inventory', description: `Upload an up-to-date inventory of infrastructure assets—cloud accounts/resources (compute, DBs, stor...`, department: 'itsm', frequency: 'yearly', }, - frk_tt_68fa2a852e70f757188f0c39: { + 'frk_tt_68fa2a852e70f757188f0c39': { name: 'Production Firewall & No-Public-Access Controls', description: `Upload proof that production hosts enforce a deny-by-default firewall and that production databases ...`, department: 'itsm', From 63895179bd83f2218ec551ef78cfbece804ea6d6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 14:06:52 -0500 Subject: [PATCH 2/5] feat(task): add task automation helpers and tests for status calculation (#1984) Co-authored-by: Tofik Hasanov --- .../tasks/task/task-schedule-helpers.test.ts | 325 ++++++++++++++++++ .../tasks/task/task-schedule-helpers.ts | 131 +++++++ .../src/trigger/tasks/task/task-schedule.ts | 119 +++++-- 3 files changed, 550 insertions(+), 25 deletions(-) create mode 100644 apps/app/src/trigger/tasks/task/task-schedule-helpers.test.ts create mode 100644 apps/app/src/trigger/tasks/task/task-schedule-helpers.ts diff --git a/apps/app/src/trigger/tasks/task/task-schedule-helpers.test.ts b/apps/app/src/trigger/tasks/task/task-schedule-helpers.test.ts new file mode 100644 index 000000000..dc21c1ac5 --- /dev/null +++ b/apps/app/src/trigger/tasks/task/task-schedule-helpers.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it } from 'vitest'; + +import { + calculateNextDueDate, + getTargetStatus, + type TaskAutomationData, +} from './task-schedule-helpers'; + +describe('task-schedule-helpers', () => { + describe('getTargetStatus', () => { + describe('No automations configured', () => { + it('should return "todo" when no automations are configured', () => { + const task: TaskAutomationData = { + evidenceAutomations: [], + integrationCheckRuns: [], + }; + + expect(getTargetStatus(task)).toBe('todo'); + }); + }); + + describe('Custom Automations only', () => { + it('should return "done" when all custom automations pass', () => { + const task: TaskAutomationData = { + evidenceAutomations: [ + { id: 'aut_1', runs: [{ evaluationStatus: 'pass' }] }, + { id: 'aut_2', runs: [{ evaluationStatus: 'pass' }] }, + ], + integrationCheckRuns: [], + }; + + expect(getTargetStatus(task)).toBe('done'); + }); + + it('should return "failed" when any custom automation fails', () => { + const task: TaskAutomationData = { + evidenceAutomations: [ + { id: 'aut_1', runs: [{ evaluationStatus: 'pass' }] }, + { id: 'aut_2', runs: [{ evaluationStatus: 'fail' }] }, + ], + integrationCheckRuns: [], + }; + + expect(getTargetStatus(task)).toBe('failed'); + }); + + it('should return "failed" when custom automation has no runs', () => { + const task: TaskAutomationData = { + evidenceAutomations: [{ id: 'aut_1', runs: [] }], + integrationCheckRuns: [], + }; + + expect(getTargetStatus(task)).toBe('failed'); + }); + + it('should return "failed" when custom automation has null evaluationStatus', () => { + const task: TaskAutomationData = { + evidenceAutomations: [{ id: 'aut_1', runs: [{ evaluationStatus: null }] }], + integrationCheckRuns: [], + }; + + expect(getTargetStatus(task)).toBe('failed'); + }); + + it('should only check the latest run for each custom automation', () => { + const task: TaskAutomationData = { + evidenceAutomations: [ + { + id: 'aut_1', + runs: [ + { evaluationStatus: 'pass' }, // Latest - pass + { evaluationStatus: 'fail' }, // Older - fail (should be ignored) + ], + }, + ], + integrationCheckRuns: [], + }; + + expect(getTargetStatus(task)).toBe('done'); + }); + }); + + describe('App Automations only', () => { + it('should return "done" when all app automations succeed', () => { + const task: TaskAutomationData = { + evidenceAutomations: [], + integrationCheckRuns: [ + { checkId: 'github-mfa', status: 'success', createdAt: new Date('2024-01-06') }, + { checkId: 'slack-channels', status: 'success', createdAt: new Date('2024-01-06') }, + ], + }; + + expect(getTargetStatus(task)).toBe('done'); + }); + + it('should return "failed" when any app automation fails', () => { + const task: TaskAutomationData = { + evidenceAutomations: [], + integrationCheckRuns: [ + { checkId: 'github-mfa', status: 'success', createdAt: new Date('2024-01-06') }, + { checkId: 'slack-channels', status: 'failed', createdAt: new Date('2024-01-06') }, + ], + }; + + expect(getTargetStatus(task)).toBe('failed'); + }); + + it('should return "failed" when app automation is pending', () => { + const task: TaskAutomationData = { + evidenceAutomations: [], + integrationCheckRuns: [ + { checkId: 'github-mfa', status: 'pending', createdAt: new Date('2024-01-06') }, + ], + }; + + expect(getTargetStatus(task)).toBe('failed'); + }); + + it('should return "failed" when app automation is running', () => { + const task: TaskAutomationData = { + evidenceAutomations: [], + integrationCheckRuns: [ + { checkId: 'github-mfa', status: 'running', createdAt: new Date('2024-01-06') }, + ], + }; + + expect(getTargetStatus(task)).toBe('failed'); + }); + + it('should check latest run for each checkId separately', () => { + const task: TaskAutomationData = { + evidenceAutomations: [], + integrationCheckRuns: [ + // Sorted by createdAt desc (latest first) + { + checkId: 'github-mfa', + status: 'success', + createdAt: new Date('2024-01-06T12:00:00'), + }, + { + checkId: 'slack-channels', + status: 'failed', + createdAt: new Date('2024-01-06T11:00:00'), + }, + { checkId: 'github-mfa', status: 'failed', createdAt: new Date('2024-01-05T10:00:00') }, // Older, ignored + { + checkId: 'slack-channels', + status: 'success', + createdAt: new Date('2024-01-04T09:00:00'), + }, // Older, ignored + ], + }; + + // github-mfa: latest is success ✓ + // slack-channels: latest is failed ✗ + expect(getTargetStatus(task)).toBe('failed'); + }); + + it('should not depend on input ordering when selecting latest per checkId', () => { + const task: TaskAutomationData = { + evidenceAutomations: [], + integrationCheckRuns: [ + // Unsorted on purpose + { + checkId: 'github-mfa', + status: 'failed', + createdAt: new Date('2024-01-05T10:00:00'), + }, + { + checkId: 'slack-channels', + status: 'success', + createdAt: new Date('2024-01-04T09:00:00'), + }, + { + checkId: 'slack-channels', + status: 'failed', + createdAt: new Date('2024-01-06T11:00:00'), + }, // Latest for slack-channels + { + checkId: 'github-mfa', + status: 'success', + createdAt: new Date('2024-01-06T12:00:00'), + }, // Latest for github-mfa + ], + }; + + // github-mfa: latest is success ✓ + // slack-channels: latest is failed ✗ + expect(getTargetStatus(task)).toBe('failed'); + }); + + it('should return "done" when all check types have successful latest runs', () => { + const task: TaskAutomationData = { + evidenceAutomations: [], + integrationCheckRuns: [ + // Sorted by createdAt desc (latest first) + { + checkId: 'github-mfa', + status: 'success', + createdAt: new Date('2024-01-06T12:00:00'), + }, + { + checkId: 'slack-channels', + status: 'success', + createdAt: new Date('2024-01-06T11:00:00'), + }, + { checkId: 'github-mfa', status: 'failed', createdAt: new Date('2024-01-05T10:00:00') }, // Older, ignored + ], + }; + + expect(getTargetStatus(task)).toBe('done'); + }); + }); + + describe('Both Custom and App Automations', () => { + it('should return "done" when both types pass', () => { + const task: TaskAutomationData = { + evidenceAutomations: [{ id: 'aut_1', runs: [{ evaluationStatus: 'pass' }] }], + integrationCheckRuns: [ + { checkId: 'github-mfa', status: 'success', createdAt: new Date('2024-01-06') }, + ], + }; + + expect(getTargetStatus(task)).toBe('done'); + }); + + it('should return "failed" when custom passes but app fails', () => { + const task: TaskAutomationData = { + evidenceAutomations: [{ id: 'aut_1', runs: [{ evaluationStatus: 'pass' }] }], + integrationCheckRuns: [ + { checkId: 'github-mfa', status: 'failed', createdAt: new Date('2024-01-06') }, + ], + }; + + expect(getTargetStatus(task)).toBe('failed'); + }); + + it('should return "failed" when custom fails but app passes', () => { + const task: TaskAutomationData = { + evidenceAutomations: [{ id: 'aut_1', runs: [{ evaluationStatus: 'fail' }] }], + integrationCheckRuns: [ + { checkId: 'github-mfa', status: 'success', createdAt: new Date('2024-01-06') }, + ], + }; + + expect(getTargetStatus(task)).toBe('failed'); + }); + + it('should return "failed" when both fail', () => { + const task: TaskAutomationData = { + evidenceAutomations: [{ id: 'aut_1', runs: [{ evaluationStatus: 'fail' }] }], + integrationCheckRuns: [ + { checkId: 'github-mfa', status: 'failed', createdAt: new Date('2024-01-06') }, + ], + }; + + expect(getTargetStatus(task)).toBe('failed'); + }); + + it('should check all custom and all app automations', () => { + const task: TaskAutomationData = { + evidenceAutomations: [ + { id: 'aut_1', runs: [{ evaluationStatus: 'pass' }] }, + { id: 'aut_2', runs: [{ evaluationStatus: 'pass' }] }, + { id: 'aut_3', runs: [{ evaluationStatus: 'pass' }] }, + ], + integrationCheckRuns: [ + { checkId: 'github-mfa', status: 'success', createdAt: new Date('2024-01-06') }, + { checkId: 'slack-channels', status: 'success', createdAt: new Date('2024-01-06') }, + { checkId: 'jira-issues', status: 'success', createdAt: new Date('2024-01-06') }, + ], + }; + + expect(getTargetStatus(task)).toBe('done'); + }); + }); + }); + + describe('calculateNextDueDate', () => { + const baseDate = new Date('2024-01-15T10:00:00Z'); + + it('should add 1 day for daily frequency', () => { + const result = calculateNextDueDate(baseDate, 'daily'); + expect(result.toISOString()).toBe('2024-01-16T10:00:00.000Z'); + }); + + it('should add 7 days for weekly frequency', () => { + const result = calculateNextDueDate(baseDate, 'weekly'); + expect(result.toISOString()).toBe('2024-01-22T10:00:00.000Z'); + }); + + it('should add 1 month for monthly frequency', () => { + const result = calculateNextDueDate(baseDate, 'monthly'); + expect(result.toISOString()).toBe('2024-02-15T10:00:00.000Z'); + }); + + it('should add 3 months for quarterly frequency', () => { + const result = calculateNextDueDate(baseDate, 'quarterly'); + // Check date components (timezone-safe) + expect(result.getFullYear()).toBe(2024); + expect(result.getMonth()).toBe(3); // April (0-indexed) + expect(result.getDate()).toBe(15); + }); + + it('should add 12 months for yearly frequency', () => { + const result = calculateNextDueDate(baseDate, 'yearly'); + expect(result.toISOString()).toBe('2025-01-15T10:00:00.000Z'); + }); + + it('should handle month rollover (Jan 31 + 1 month = Feb 29 in leap year)', () => { + const jan31 = new Date('2024-01-31T10:00:00Z'); // 2024 is a leap year + const result = calculateNextDueDate(jan31, 'monthly'); + // Feb doesn't have 31 days, so it should be Feb 29 (leap year) + expect(result.getMonth()).toBe(1); // February + expect(result.getDate()).toBeLessThanOrEqual(29); + }); + + it('should handle month rollover (Jan 31 + 1 month = Feb 28 in non-leap year)', () => { + const jan31 = new Date('2023-01-31T10:00:00Z'); // 2023 is not a leap year + const result = calculateNextDueDate(jan31, 'monthly'); + expect(result.getMonth()).toBe(1); // February + expect(result.getDate()).toBeLessThanOrEqual(28); + }); + }); +}); diff --git a/apps/app/src/trigger/tasks/task/task-schedule-helpers.ts b/apps/app/src/trigger/tasks/task/task-schedule-helpers.ts new file mode 100644 index 000000000..518a8eb8e --- /dev/null +++ b/apps/app/src/trigger/tasks/task/task-schedule-helpers.ts @@ -0,0 +1,131 @@ +/** + * Helper types and functions for task-schedule logic + * Extracted for testability + */ + +import type { + EvidenceAutomationEvaluationStatus, + IntegrationRunStatus, + TaskStatus, +} from '@trycompai/db'; + +export type TargetStatus = Extract; + +export interface CustomAutomation { + id: string; + runs: Array<{ + evaluationStatus: EvidenceAutomationEvaluationStatus | null; + }>; +} + +export interface AppAutomationRun { + checkId: string; + status: IntegrationRunStatus; + createdAt: Date; +} + +export interface TaskAutomationData { + evidenceAutomations: CustomAutomation[]; + integrationCheckRuns: AppAutomationRun[]; +} + +/** + * Determines the target status for a task based on its automation state. + * + * Logic: + * - No automations configured → 'todo' + * - All automations passing → 'done' (keep current status) + * - Any automation failing → 'failed' + * + * Custom Automations (EvidenceAutomation): + * - Must have isEnabled = true (already filtered in query) + * - Latest run must have evaluationStatus = 'pass' + * + * App Automations (IntegrationCheckRun): + * - Groups by checkId + * - Latest run for each checkId must have status = 'success' + */ +export const getTargetStatus = (task: TaskAutomationData): TargetStatus => { + // Custom Automations: isEnabled = true AND latest run evaluationStatus = 'pass' + const hasCustomAutomations = task.evidenceAutomations.length > 0; + const customAutomationsPassing = + hasCustomAutomations && + task.evidenceAutomations.every((automation) => { + const latestRun = automation.runs[0]; + return latestRun?.evaluationStatus === 'pass'; + }); + + // App Automations: Group by checkId and check latest run for each check type + const hasAppAutomations = task.integrationCheckRuns.length > 0; + let appAutomationsPassing = false; + + if (hasAppAutomations) { + // Group runs by checkId and get the latest for each (order-independent) + const latestRunByCheckId = new Map(); + for (const run of task.integrationCheckRuns) { + const existing = latestRunByCheckId.get(run.checkId); + if (!existing || run.createdAt > existing.createdAt) { + latestRunByCheckId.set(run.checkId, run); + } + } + + // All check types must have status = 'success' + appAutomationsPassing = Array.from(latestRunByCheckId.values()).every( + (run) => run.status === 'success', + ); + } + + // If no automations configured at all → move to "todo" + if (!hasCustomAutomations && !hasAppAutomations) { + return 'todo'; + } + + // If automations are configured, check if all are passing + const allPassing = + (!hasCustomAutomations || customAutomationsPassing) && + (!hasAppAutomations || appAutomationsPassing); + + if (allPassing) { + return 'done'; + } + + // Some automations are failing → move to "failed" + return 'failed'; +}; + +/** + * Calculate next due date based on review date and frequency + */ +export const calculateNextDueDate = ( + reviewDate: Date, + frequency: 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly', +): Date => { + const addDaysToDate = (date: Date, days: number): Date => { + const result = new Date(date.getTime()); + result.setDate(result.getDate() + days); + return result; + }; + + const addMonthsToDate = (date: Date, months: number): Date => { + const result = new Date(date.getTime()); + const originalDayOfMonth = result.getDate(); + result.setMonth(result.getMonth() + months); + if (result.getDate() < originalDayOfMonth) { + result.setDate(0); + } + return result; + }; + + switch (frequency) { + case 'daily': + return addDaysToDate(reviewDate, 1); + case 'weekly': + return addDaysToDate(reviewDate, 7); + case 'monthly': + return addMonthsToDate(reviewDate, 1); + case 'quarterly': + return addMonthsToDate(reviewDate, 3); + case 'yearly': + return addMonthsToDate(reviewDate, 12); + } +}; diff --git a/apps/app/src/trigger/tasks/task/task-schedule.ts b/apps/app/src/trigger/tasks/task/task-schedule.ts index 5d2ae3913..48b8180fc 100644 --- a/apps/app/src/trigger/tasks/task/task-schedule.ts +++ b/apps/app/src/trigger/tasks/task/task-schedule.ts @@ -2,6 +2,8 @@ import { db } from '@db'; import { Novu } from '@novu/api'; import { logger, schedules } from '@trigger.dev/sdk'; +import { getTargetStatus } from './task-schedule-helpers'; + export const taskSchedule = schedules.task({ id: 'task-schedule', machine: 'large-1x', @@ -56,6 +58,31 @@ export const taskSchedule = schedules.task({ }, }, }, + // Include Custom Automations (EvidenceAutomation with isEnabled) + evidenceAutomations: { + where: { + isEnabled: true, + }, + select: { + id: true, + runs: { + orderBy: { createdAt: 'desc' }, + take: 1, + select: { + evaluationStatus: true, + }, + }, + }, + }, + // Include App Automations (IntegrationCheckRun) - get all runs to group by checkId + integrationCheckRuns: { + orderBy: { createdAt: 'desc' }, + select: { + checkId: true, + status: true, + createdAt: true, + }, + }, }, }); @@ -106,29 +133,60 @@ export const taskSchedule = schedules.task({ logger.info(`Found ${overdueTasks.length} tasks past their computed review deadline`); - if (overdueTasks.length === 0) { + // Categorize tasks by their target status using the extracted helper + const tasksKeptDone = overdueTasks.filter((task) => getTargetStatus(task) === 'done'); + const tasksToTodo = overdueTasks.filter((task) => getTargetStatus(task) === 'todo'); + const tasksToFailed = overdueTasks.filter((task) => getTargetStatus(task) === 'failed'); + + logger.info( + `${tasksToTodo.length} tasks → "todo", ${tasksToFailed.length} tasks → "failed", ${tasksKeptDone.length} tasks kept as "done"`, + ); + + // Log tasks kept as done due to passing automations + tasksKeptDone.forEach((task) => { + logger.info(`Task "${task.title}" (${task.id}) kept as "done" - all automations passing`); + }); + + if (tasksToTodo.length === 0 && tasksToFailed.length === 0) { return { success: true, - totalTasksChecked: 0, - updatedTasks: 0, - message: 'No tasks found past their computed review deadline', + totalTasksChecked: overdueTasks.length, + updatedToTodo: 0, + updatedToFailed: 0, + tasksKeptDone: tasksKeptDone.length, + message: + overdueTasks.length === 0 + ? 'No tasks found past their computed review deadline' + : 'All overdue tasks have passing automations, no status changes needed', }; } try { - // Update all overdue tasks to "todo" status - const taskIds = overdueTasks.map((task) => task.id); + // Update tasks to "todo" status (no automations configured) + const todoTaskIds = tasksToTodo.map((task) => task.id); + let todoUpdateCount = 0; + if (todoTaskIds.length > 0) { + const todoResult = await db.task.updateMany({ + where: { id: { in: todoTaskIds } }, + data: { status: 'todo' }, + }); + todoUpdateCount = todoResult.count; + } - const updateResult = await db.task.updateMany({ - where: { - id: { - in: taskIds, - }, - }, - data: { - status: 'todo', - }, - }); + // Update tasks to "failed" status (automations failing) + const failedTaskIds = tasksToFailed.map((task) => task.id); + let failedUpdateCount = 0; + if (failedTaskIds.length > 0) { + const failedResult = await db.task.updateMany({ + where: { id: { in: failedTaskIds } }, + data: { status: 'failed' }, + }); + failedUpdateCount = failedResult.count; + } + + // Combine all updated tasks for notifications + const allUpdatedTasks = [...tasksToTodo, ...tasksToFailed]; + const taskIds = allUpdatedTasks.map((task) => task.id); const recipientsMap = new Map< string, @@ -136,12 +194,12 @@ export const taskSchedule = schedules.task({ email: string; userId: string; name: string; - task: (typeof overdueTasks)[number]; + task: (typeof allUpdatedTasks)[number]; } >(); const addRecipients = ( users: Array<{ user: { id: string; email: string; name?: string } }>, - task: (typeof overdueTasks)[number], + task: (typeof allUpdatedTasks)[number], ) => { for (const entry of users) { const user = entry.user; @@ -160,7 +218,7 @@ export const taskSchedule = schedules.task({ }; // Find recipients (org owner and assignee) for each task and add to recipientsMap - for (const task of overdueTasks) { + for (const task of allUpdatedTasks) { // Org owners if (task.organization && Array.isArray(task.organization.members)) { addRecipients(task.organization.members, task); @@ -194,20 +252,29 @@ export const taskSchedule = schedules.task({ }); // Log details about updated tasks - overdueTasks.forEach((task) => { + tasksToTodo.forEach((task) => { + logger.info( + `Updated task "${task.title}" (${task.id}) to "todo" - no automations - org "${task.organization.name}" - frequency ${task.frequency}`, + ); + }); + tasksToFailed.forEach((task) => { logger.info( - `Updated task "${task.title}" (${task.id}) from org "${task.organization.name}" - frequency ${task.frequency} - last reviewed ${task.reviewDate?.toISOString()}`, + `Updated task "${task.title}" (${task.id}) to "failed" - automations failing - org "${task.organization.name}" - frequency ${task.frequency}`, ); }); - logger.info(`Successfully updated ${updateResult.count} tasks to "todo" status`); + logger.info( + `Successfully updated ${todoUpdateCount} tasks to "todo" and ${failedUpdateCount} tasks to "failed"`, + ); return { success: true, totalTasksChecked: overdueTasks.length, - updatedTasks: updateResult.count, + updatedToTodo: todoUpdateCount, + updatedToFailed: failedUpdateCount, updatedTaskIds: taskIds, - message: `Updated ${updateResult.count} tasks past their review deadline`, + tasksKeptDone: tasksKeptDone.length, + message: `Updated ${todoUpdateCount} to "todo", ${failedUpdateCount} to "failed" (${tasksKeptDone.length} kept as done)`, }; } catch (error) { logger.error(`Failed to update overdue tasks: ${error}`); @@ -215,7 +282,9 @@ export const taskSchedule = schedules.task({ return { success: false, totalTasksChecked: overdueTasks.length, - updatedTasks: 0, + updatedToTodo: 0, + updatedToFailed: 0, + tasksKeptDone: 0, error: error instanceof Error ? error.message : String(error), message: 'Failed to update tasks past their review deadline', }; From bb39d6f6c3137d767a9eea55917535a8cd92a439 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:47:29 -0500 Subject: [PATCH 3/5] [dev] [Marfuen] mariano/fix-google (#1985) * fix(sync): enhance error logging for Google API response * fix(google-workspace): update domain scope to role management readonly --------- Co-authored-by: Mariano Fuentes --- .../api/src/integration-platform/controllers/sync.controller.ts | 2 +- apps/app/tailwind.config.ts | 2 +- .../src/manifests/google-workspace/index.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index 32322dcca..ff541aee5 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -186,7 +186,7 @@ export class SyncController { } const errorText = await response.text(); this.logger.error( - `Google API error: ${response.status} ${response.statusText}`, + `Google API error: ${response.status} ${response.statusText} - ${errorText}`, ); throw new HttpException( 'Failed to fetch users from Google Workspace', diff --git a/apps/app/tailwind.config.ts b/apps/app/tailwind.config.ts index d60f01c16..ecfc384a6 100644 --- a/apps/app/tailwind.config.ts +++ b/apps/app/tailwind.config.ts @@ -1,5 +1,5 @@ +import baseConfig from '@trycompai/ui/tailwind.config'; import type { Config } from 'tailwindcss'; -import baseConfig from '../../packages/ui/tailwind.config'; export default { content: [ diff --git a/packages/integration-platform/src/manifests/google-workspace/index.ts b/packages/integration-platform/src/manifests/google-workspace/index.ts index e5987b396..6cd75e297 100644 --- a/packages/integration-platform/src/manifests/google-workspace/index.ts +++ b/packages/integration-platform/src/manifests/google-workspace/index.ts @@ -18,7 +18,7 @@ export const googleWorkspaceManifest: IntegrationManifest = { scopes: [ 'https://www.googleapis.com/auth/admin.directory.user.readonly', 'https://www.googleapis.com/auth/admin.directory.orgunit.readonly', - 'https://www.googleapis.com/auth/admin.directory.domain.readonly', + 'https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly', ], pkce: false, clientAuthMethod: 'body', From 97af744a78e7dfbd0c889da4d4cedd07af5a629a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:43:22 -0500 Subject: [PATCH 4/5] [dev] [tofikwest] tofik/github-evidence-check-pr-and-json (#1986) * feat(evidence): add EvidenceJsonView component for JSON evidence display * chore: remove unused react-json-view dependency and related code --------- Co-authored-by: Tofik Hasanov --- apps/app/package.json | 1 + .../[taskId]/components/EvidenceJsonView.tsx | 224 ++++++++++++++++++ .../components/TaskIntegrationChecks.tsx | 47 +++- bun.lock | 3 + .../github/checks/branch-protection.ts | 97 +++++++- .../src/manifests/github/index.ts | 4 +- .../src/manifests/github/types.ts | 15 ++ .../src/manifests/github/variables.ts | 16 ++ .../components/editor/extensions/mention.tsx | 18 +- 9 files changed, 406 insertions(+), 19 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/EvidenceJsonView.tsx diff --git a/apps/app/package.json b/apps/app/package.json index 38dd4e9b6..6e64cba08 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -61,6 +61,7 @@ "@types/canvas-confetti": "^1.9.0", "@types/react-syntax-highlighter": "^15.5.13", "@types/three": "^0.180.0", + "@uiw/react-json-view": "^2.0.0-alpha.40", "@uploadthing/react": "^7.3.0", "@upstash/ratelimit": "^2.0.5", "@vercel/analytics": "^1.5.0", diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/EvidenceJsonView.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/EvidenceJsonView.tsx new file mode 100644 index 000000000..5bc7263cd --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/EvidenceJsonView.tsx @@ -0,0 +1,224 @@ +'use client'; + +import JsonView from '@uiw/react-json-view'; +import { Download } from 'lucide-react'; +import { useCallback, useMemo } from 'react'; + +interface EvidenceJsonViewProps { + evidence: Record; + organizationName?: string; + automationName?: string; +} + +/** + * Sanitizes a value for safe JSON serialization. + * Handles edge cases: functions, symbols, circular refs, undefined, NaN, Infinity, + * Map, Set, RegExp, Error objects, and getters that throw. + */ +const sanitizeForJson = (obj: unknown, seen = new WeakSet()): unknown => { + // Handle null/undefined + if (obj === null || obj === undefined) return null; + + // Handle functions + if (typeof obj === 'function') return '[Function]'; + + // Handle symbols + if (typeof obj === 'symbol') return obj.toString(); + + // Handle bigint + if (typeof obj === 'bigint') return obj.toString(); + + // Handle numbers - NaN and Infinity are not valid JSON + if (typeof obj === 'number') { + if (Number.isNaN(obj)) return null; + if (!Number.isFinite(obj)) return null; + return obj; + } + + // Handle strings and booleans - pass through + if (typeof obj === 'string' || typeof obj === 'boolean') { + return obj; + } + + // Handle Date objects + if (obj instanceof Date) { + return Number.isNaN(obj.getTime()) ? null : obj.toISOString(); + } + + // Handle RegExp + if (obj instanceof RegExp) { + return obj.toString(); + } + + // Handle Error objects - extract useful info + if (obj instanceof Error) { + return { + name: obj.name, + message: obj.message, + stack: obj.stack, + }; + } + + // Handle Map - convert to object + if (obj instanceof Map) { + if (seen.has(obj)) return '[Circular Reference]'; + seen.add(obj); + const result: Record = {}; + obj.forEach((value, key) => { + const keyStr = typeof key === 'string' ? key : String(key); + result[keyStr] = sanitizeForJson(value, seen); + }); + return result; + } + + // Handle Set - convert to array + if (obj instanceof Set) { + if (seen.has(obj)) return '[Circular Reference]'; + seen.add(obj); + return Array.from(obj).map((item) => sanitizeForJson(item, seen)); + } + + // Handle arrays - check for circular reference first + if (Array.isArray(obj)) { + if (seen.has(obj)) { + return '[Circular Reference]'; + } + seen.add(obj); + return obj.map((item) => sanitizeForJson(item, seen)); + } + + // Handle plain objects + if (typeof obj === 'object') { + // Detect circular reference + if (seen.has(obj)) { + return '[Circular Reference]'; + } + seen.add(obj); + + const result: Record = {}; + + // Use try/catch to handle getters that might throw + let entries: [string, unknown][]; + try { + entries = Object.entries(obj); + } catch { + return '[Object with inaccessible properties]'; + } + + for (const [key, value] of entries) { + try { + result[key] = sanitizeForJson(value, seen); + } catch { + result[key] = '[Error accessing property]'; + } + } + return result; + } + + // Fallback for any unknown types + return String(obj); +}; + +/** + * Formats a string to be safe for filenames + */ +const toSafeFilename = (str: string): string => { + return str + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 50); +}; + +/** + * Gets a short date string for the filename (YYYY-MM-DD) + */ +const getShortDate = (): string => { + const now = new Date(); + return now.toISOString().split('T')[0]; +}; + +export function EvidenceJsonView({ + evidence, + organizationName = 'organization', + automationName = 'automation', +}: EvidenceJsonViewProps) { + // Sanitize evidence for safe rendering and download + const sanitizedEvidence = useMemo(() => { + try { + return sanitizeForJson(evidence) as Record; + } catch { + return { error: 'Failed to process evidence data' }; + } + }, [evidence]); + + // Generate filename: {orgName}_evidence_{automationName}_{date}.json + const generateFilename = useCallback(() => { + const orgPart = toSafeFilename(organizationName); + const automationPart = toSafeFilename(automationName); + const datePart = getShortDate(); + return `${orgPart}_evidence_${automationPart}_${datePart}.json`; + }, [organizationName, automationName]); + + const handleDownload = useCallback(() => { + try { + const jsonString = JSON.stringify(sanitizedEvidence, null, 2); + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = generateFilename(); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch { + console.error('Failed to download evidence JSON'); + } + }, [sanitizedEvidence, generateFilename]); + + // Check if evidence is empty or invalid + const hasValidEvidence = useMemo(() => { + return ( + sanitizedEvidence && + typeof sanitizedEvidence === 'object' && + Object.keys(sanitizedEvidence).length > 0 + ); + }, [sanitizedEvidence]); + + if (!hasValidEvidence) { + return ( +
+ No evidence data available +
+ ); + } + + return ( +
+ +
+ +
+
+ ); +} + + 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 8aa90326a..b699564c0 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 @@ -22,11 +22,13 @@ import { TrendingUp, XCircle, } from 'lucide-react'; +import { useActiveOrganization } from '@/utils/auth-client'; import Image from 'next/image'; import Link from 'next/link'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; +import { EvidenceJsonView } from './EvidenceJsonView'; interface TaskIntegrationCheck { integrationId: string; @@ -89,6 +91,8 @@ export function TaskIntegrationChecks({ taskId, onTaskUpdated }: TaskIntegration const params = useParams(); const searchParams = useSearchParams(); const orgId = params.orgId as string; + const activeOrg = useActiveOrganization(); + const organizationName = activeOrg.data?.name || orgId; const [checks, setChecks] = useState([]); const [storedRuns, setStoredRuns] = useState([]); @@ -613,7 +617,11 @@ export function TaskIntegrationChecks({ taskId, onTaskUpdated }: TaskIntegration >
- +
@@ -694,7 +702,15 @@ export function TaskIntegrationChecks({ taskId, onTaskUpdated }: TaskIntegration } // Group runs by date for display -function GroupedCheckRuns({ runs, maxRuns = 5 }: { runs: StoredCheckRun[]; maxRuns?: number }) { +function GroupedCheckRuns({ + runs, + maxRuns = 5, + organizationName, +}: { + runs: StoredCheckRun[]; + maxRuns?: number; + organizationName: string; +}) { const [showAll, setShowAll] = useState(false); // Group runs by date @@ -756,7 +772,14 @@ function GroupedCheckRuns({ runs, maxRuns = 5 }: { runs: StoredCheckRun[]; maxRu {dateRuns.map((run: StoredCheckRun) => { const isLatest = runIndex === 0; runIndex++; - return ; + return ( + + ); })} @@ -775,7 +798,15 @@ function GroupedCheckRuns({ runs, maxRuns = 5 }: { runs: StoredCheckRun[]; maxRu } // Individual check run item with expandable details -function CheckRunItem({ run, isLatest }: { run: StoredCheckRun; isLatest: boolean }) { +function CheckRunItem({ + run, + isLatest, + organizationName, +}: { + run: StoredCheckRun; + isLatest: boolean; + organizationName: string; +}) { const [expanded, setExpanded] = useState(isLatest); const timeAgo = formatDistanceToNow(new Date(run.createdAt), { addSuffix: true }); @@ -897,9 +928,11 @@ function CheckRunItem({ run, isLatest }: { run: StoredCheckRun; isLatest: boolea View Evidence -
-                          {JSON.stringify(result.evidence, null, 2)}
-                        
+ )} diff --git a/bun.lock b/bun.lock index 59d7508bb..157ac190b 100644 --- a/bun.lock +++ b/bun.lock @@ -215,6 +215,7 @@ "@types/canvas-confetti": "^1.9.0", "@types/react-syntax-highlighter": "^15.5.13", "@types/three": "^0.180.0", + "@uiw/react-json-view": "^2.0.0-alpha.40", "@uploadthing/react": "^7.3.0", "@upstash/ratelimit": "^2.0.5", "@vercel/analytics": "^1.5.0", @@ -2418,6 +2419,8 @@ "@uidotdev/usehooks": ["@uidotdev/usehooks@2.4.1", "", { "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg=="], + "@uiw/react-json-view": ["@uiw/react-json-view@2.0.0-alpha.40", "", { "peerDependencies": { "@babel/runtime": ">=7.10.0", "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-j8YgmUrLAokX0k3TJC1+Rae3G2XS2hTYA9SsnQVWeQpn/PiqxwG8mI4A5TCASUTltPtpM/9Yp+mRm7L4Wjy8rw=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="], diff --git a/packages/integration-platform/src/manifests/github/checks/branch-protection.ts b/packages/integration-platform/src/manifests/github/checks/branch-protection.ts index 202359d11..2b1696c7b 100644 --- a/packages/integration-platform/src/manifests/github/checks/branch-protection.ts +++ b/packages/integration-platform/src/manifests/github/checks/branch-protection.ts @@ -1,6 +1,8 @@ /** * Branch Protection Check - * Verifies that default branches have protection rules configured + * Verifies that default branches have protection rules configured. + * Also fetches recent pull request history for the protected branch + * and includes it in the evidence for auditors. */ import { TASK_TEMPLATES } from '../../../task-mappings'; @@ -9,10 +11,49 @@ import type { GitHubBranchProtection, GitHubBranchRule, GitHubOrg, + GitHubPullRequest, GitHubRepo, GitHubRuleset, } from '../types'; -import { protectedBranchVariable, targetReposVariable } from '../variables'; +import { + protectedBranchVariable, + recentPullRequestDaysVariable, + targetReposVariable, +} from '../variables'; + +// ───────────────────────────────────────────────────────────────────────────── +// PR History Config +// ───────────────────────────────────────────────────────────────────────────── +const MAX_RECENT_PRS = 50; +const DEFAULT_RECENT_WINDOW_DAYS = 180; // ~6 months + +interface PullRequestEvidenceSummary { + id: number; + number: number; + url: string; + state: 'open' | 'closed'; + title: string; + author: string | null; + created_at: string; + updated_at: string; +} + +const summarizePullRequest = (pr: GitHubPullRequest): PullRequestEvidenceSummary => ({ + id: pr.id, + number: pr.number, + url: pr.html_url, + state: pr.state, + title: pr.title, + author: pr.user?.login ?? null, + created_at: pr.created_at, + updated_at: pr.updated_at, +}); + +const toSafeNumber = (value: unknown): number | null => { + if (typeof value !== 'number') return null; + if (!Number.isFinite(value)) return null; + return value; +}; export const branchProtectionCheck: IntegrationCheck = { id: 'branch_protection', @@ -21,11 +62,45 @@ export const branchProtectionCheck: IntegrationCheck = { taskMapping: TASK_TEMPLATES.codeChanges, defaultSeverity: 'high', - variables: [targetReposVariable, protectedBranchVariable], + variables: [targetReposVariable, protectedBranchVariable, recentPullRequestDaysVariable], run: async (ctx) => { const targetRepos = ctx.variables.target_repos as string[] | undefined; const protectedBranch = ctx.variables.protected_branch as string | undefined; + const recentDaysRaw = toSafeNumber(ctx.variables.recent_pr_days); + const recentWindowDays = + recentDaysRaw && recentDaysRaw > 0 ? recentDaysRaw : DEFAULT_RECENT_WINDOW_DAYS; + const cutoff = new Date(Date.now() - recentWindowDays * 24 * 60 * 60 * 1000); + + ctx.log( + `Config: branch="${protectedBranch}", recentWindowDays=${recentWindowDays}, cutoff=${cutoff.toISOString()}`, + ); + + // ─────────────────────────────────────────────────────────────────────── + // Helper: fetch recent PRs targeting the protected branch + // ─────────────────────────────────────────────────────────────────────── + const fetchRecentPullRequests = async ({ + repoFullName, + baseBranch, + }: { + repoFullName: string; + baseBranch: string; + }): Promise => { + const base = encodeURIComponent(baseBranch); + try { + ctx.log(`[PRs] Fetching PRs for ${repoFullName} with base="${baseBranch}"`); + const pulls = await ctx.fetchAllPages( + `/repos/${repoFullName}/pulls?state=all&base=${base}&sort=updated&direction=desc`, + { maxPages: 5 }, + ); + const recent = pulls.filter((pr) => new Date(pr.created_at).getTime() >= cutoff.getTime()); + return recent.slice(0, MAX_RECENT_PRS).map(summarizePullRequest); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + ctx.warn(`[PRs] Could not fetch pull requests for ${repoFullName}: ${errorMsg}`); + return null; + } + }; ctx.log('Fetching repositories...'); @@ -58,6 +133,12 @@ export const branchProtectionCheck: IntegrationCheck = { ctx.log(`Checking branch "${branchToCheck}" on ${repo.full_name}`); + // Fetch recent PRs in parallel while we check protection + const pullRequestsPromise = fetchRecentPullRequests({ + repoFullName: repo.full_name, + baseBranch: branchToCheck, + }); + // Helper to check if a branch matches ruleset conditions const branchMatchesRuleset = (ruleset: GitHubRuleset, branch: string): boolean => { if (!ruleset.conditions?.ref_name) return true; @@ -201,6 +282,9 @@ export const branchProtectionCheck: IntegrationCheck = { } } + // Wait for PR fetch to complete + const pullRequests = await pullRequestsPromise; + // Record result if (isProtected) { ctx.pass({ @@ -210,6 +294,8 @@ export const branchProtectionCheck: IntegrationCheck = { resourceId: repo.full_name, evidence: { ...protectionEvidence, + pull_requests: pullRequests, + pull_requests_window_days: recentWindowDays, checked_at: new Date().toISOString(), }, }); @@ -221,6 +307,11 @@ export const branchProtectionCheck: IntegrationCheck = { resourceId: repo.full_name, severity: 'high', remediation: `1. Go to ${repo.html_url}/settings/rules\n2. Create a new ruleset targeting branch "${branchToCheck}"\n3. Enable "Require a pull request before merging"\n4. Set required approvals to at least 1`, + evidence: { + pull_requests: pullRequests, + pull_requests_window_days: recentWindowDays, + checked_at: new Date().toISOString(), + }, }); } } diff --git a/packages/integration-platform/src/manifests/github/index.ts b/packages/integration-platform/src/manifests/github/index.ts index 55f83e1de..aa5bb406a 100644 --- a/packages/integration-platform/src/manifests/github/index.ts +++ b/packages/integration-platform/src/manifests/github/index.ts @@ -6,7 +6,9 @@ */ import type { IntegrationManifest } from '../../types'; -import { branchProtectionCheck, dependabotCheck, sanitizedInputsCheck } from './checks'; +import { branchProtectionCheck } from './checks/branch-protection'; +import { dependabotCheck } from './checks/dependabot'; +import { sanitizedInputsCheck } from './checks/sanitized-inputs'; export const manifest: IntegrationManifest = { id: 'github', diff --git a/packages/integration-platform/src/manifests/github/types.ts b/packages/integration-platform/src/manifests/github/types.ts index 889aabf4c..e2d02cf78 100644 --- a/packages/integration-platform/src/manifests/github/types.ts +++ b/packages/integration-platform/src/manifests/github/types.ts @@ -23,6 +23,21 @@ export interface GitHubRepo { }; } +export interface GitHubPullRequest { + id: number; + number: number; + title: string; + state: 'open' | 'closed'; + html_url: string; + created_at: string; + updated_at: string; + closed_at: string | null; + merged_at: string | null; + user: { login: string } | null; + base: { ref: string }; + head: { ref: string }; +} + export interface GitHubBranchProtection { required_pull_request_reviews?: { required_approving_review_count: number; diff --git a/packages/integration-platform/src/manifests/github/variables.ts b/packages/integration-platform/src/manifests/github/variables.ts index a8b862013..ec63df59f 100644 --- a/packages/integration-platform/src/manifests/github/variables.ts +++ b/packages/integration-platform/src/manifests/github/variables.ts @@ -47,3 +47,19 @@ export const protectedBranchVariable: CheckVariable = { placeholder: 'main', helpText: 'Branch name to check for protection - e.g., main, master, develop', }; + +/** + * Variable controlling how far back we look for "recent" pull requests. + * Used by checks that validate recent code change activity. + */ +export const recentPullRequestDaysVariable: CheckVariable = { + id: 'recent_pr_days', + label: 'Recent PR window (days)', + type: 'number', + required: false, + // ~6 months + default: 180, + placeholder: '180', + helpText: + 'How many days back to look when determining whether pull requests are "recent". Confirm the right value with your security/compliance owner.', +}; diff --git a/packages/ui/src/components/editor/extensions/mention.tsx b/packages/ui/src/components/editor/extensions/mention.tsx index 1537c0dd9..7a84d8684 100644 --- a/packages/ui/src/components/editor/extensions/mention.tsx +++ b/packages/ui/src/components/editor/extensions/mention.tsx @@ -26,15 +26,15 @@ function MentionList({ items, command, onSelect, onKeyDownRef }: MentionListProp const [selectedIndex, setSelectedIndex] = useState(0); const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); const containerRef = useRef(null); - + // Store current state in refs for the keydown handler const selectedIndexRef = useRef(selectedIndex); const safeItemsRef = useRef(safeItems); - + useEffect(() => { selectedIndexRef.current = selectedIndex; }, [selectedIndex]); - + useEffect(() => { safeItemsRef.current = safeItems; }, [safeItems]); @@ -65,7 +65,7 @@ function MentionList({ items, command, onSelect, onKeyDownRef }: MentionListProp const handleKeyDown = (event: React.KeyboardEvent) => { if (safeItems.length === 0) return; - + if (event.key === 'ArrowDown') { event.preventDefault(); setSelectedIndex((prev) => (prev + 1) % safeItems.length); @@ -87,9 +87,9 @@ function MentionList({ items, command, onSelect, onKeyDownRef }: MentionListProp const { event } = props; const currentItems = safeItemsRef.current; const currentIndex = selectedIndexRef.current; - + if (currentItems.length === 0) return false; - + if (event.key === 'ArrowDown') { event.preventDefault(); setSelectedIndex((prev) => (prev + 1) % currentItems.length); @@ -108,7 +108,7 @@ function MentionList({ items, command, onSelect, onKeyDownRef }: MentionListProp return false; }; } - + return () => { if (onKeyDownRef) { onKeyDownRef.current = null; @@ -211,7 +211,9 @@ export function createMentionExtension({ suggestion }: CreateMentionExtensionOpt let component: ReactRenderer; let popup: TippyInstance | null = null; // Mutable ref to store the keydown handler from the component - const keyDownHandlerRef: { current: ((props: { event: KeyboardEvent }) => boolean) | null } = { current: null }; + const keyDownHandlerRef: { + current: ((props: { event: KeyboardEvent }) => boolean) | null; + } = { current: null }; return { onStart: (props) => { From 90b0c49287109f849611c4020406cf0a262955b3 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 8 Jan 2026 09:53:06 -0500 Subject: [PATCH 5/5] chore(policies): add AI policy assistant feature toggle (#1987) --- .../policies/[policyId]/components/PolicyPage.tsx | 4 ++++ .../[policyId]/editor/components/PolicyDetails.tsx | 9 ++++++--- .../app/(app)/[orgId]/policies/[policyId]/page.tsx | 13 +++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPage.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPage.tsx index f3bd72d21..c0e1dfded 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPage.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPage.tsx @@ -15,6 +15,7 @@ export default function PolicyPage({ policyId, organizationId, logs, + showAiAssistant, }: { policy: (Policy & { approver: (Member & { user: User }) | null }) | null; assignees: (Member & { user: User })[]; @@ -25,6 +26,8 @@ export default function PolicyPage({ /** Organization ID - required for correct org context in comments */ organizationId: string; logs: AuditLogWithRelations[]; + /** Whether the AI assistant feature is enabled */ + showAiAssistant: boolean; }) { return ( <> @@ -41,6 +44,7 @@ export default function PolicyPage({ policyContent={policy?.content ? (policy.content as JSONContent[]) : []} displayFormat={policy?.displayFormat} pdfUrl={policy?.pdfUrl} + aiAssistantEnabled={showAiAssistant} /> diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx index 6a82e5bc0..ab03416ac 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx @@ -81,6 +81,8 @@ interface PolicyContentManagerProps { isPendingApproval: boolean; displayFormat?: PolicyDisplayFormat; pdfUrl?: string | null; + /** Whether the AI assistant feature is enabled (behind feature flag) */ + aiAssistantEnabled?: boolean; } export function PolicyContentManager({ @@ -89,8 +91,9 @@ export function PolicyContentManager({ isPendingApproval, displayFormat = 'EDITOR', pdfUrl, + aiAssistantEnabled = false, }: PolicyContentManagerProps) { - const [showAiAssistant, setShowAiAssistant] = useState(true); + const [showAiAssistant, setShowAiAssistant] = useState(aiAssistantEnabled); const [editorKey, setEditorKey] = useState(0); const [currentContent, setCurrentContent] = useState>(() => { const formattedContent = Array.isArray(policyContent) @@ -205,7 +208,7 @@ export function PolicyContentManager({ PDF View - {!isPendingApproval && ( + {!isPendingApproval && aiAssistantEnabled && (