import { logger } from '../logging.ts'; import type { ConnectionManager } from '../classes/connectionmanager.ts'; import { CachedSecret } from './documents/classes.cached.secret.ts'; import { TTL } from './classes.cached.document.ts'; import type { ISecret } from '../../ts_interfaces/data/secret.ts'; export interface IScanResult { connectionsScanned: number; secretsFound: number; errors: string[]; durationMs: number; } /** * Centralized secrets scanning service. Fetches all secrets from all * connections and upserts them into the CachedSecret collection. */ export class SecretsScanService { public lastScanTimestamp: number = 0; public lastScanResult: IScanResult | null = null; public isScanning: boolean = false; private connectionManager: ConnectionManager; constructor(connectionManager: ConnectionManager) { this.connectionManager = connectionManager; } /** * Upsert a single secret into the cache. If a doc with the same composite ID * already exists, update it in place; otherwise insert a new one. */ private async upsertSecret(secret: ISecret): Promise { const id = CachedSecret.buildId(secret.connectionId, secret.scope, secret.scopeId, secret.key); const existing = await CachedSecret.getInstance({ id }); if (existing) { existing.value = secret.value; existing.protected = secret.protected; existing.masked = secret.masked; existing.environment = secret.environment; existing.scopeName = secret.scopeName; existing.setTTL(TTL.HOURS_24); await existing.save(); } else { const doc = CachedSecret.fromISecret(secret); await doc.save(); } } /** * Save an array of secrets to cache using upsert logic. * Best-effort: individual failures are silently ignored. */ async saveSecrets(secrets: ISecret[]): Promise { for (const secret of secrets) { try { await this.upsertSecret(secret); } catch { // Best-effort caching } } } /** * Full scan: iterate all connections, fetch all projects+groups, * fetch all secrets per entity, upsert CachedSecret docs. */ async fullScan(): Promise { if (this.isScanning) { return { connectionsScanned: 0, secretsFound: 0, errors: ['Scan already in progress'], durationMs: 0, }; } this.isScanning = true; const startTime = Date.now(); const errors: string[] = []; let totalSecrets = 0; let connectionsScanned = 0; try { const connections = this.connectionManager.getConnections(); for (const conn of connections) { try { const provider = this.connectionManager.getProvider(conn.id); connectionsScanned++; // Scan project secrets try { const projects = await provider.getProjects(); for (let i = 0; i < projects.length; i += 5) { const batch = projects.slice(i, i + 5); const results = await Promise.allSettled( batch.map(async (p) => { const secrets = await provider.getProjectSecrets(p.id); return secrets.map((s) => ({ ...s, scope: 'project' as const, scopeId: p.id, scopeName: p.fullPath || p.name, connectionId: conn.id, })); }), ); for (const result of results) { if (result.status === 'fulfilled') { for (const secret of result.value) { try { await this.upsertSecret(secret); totalSecrets++; } catch (err) { errors.push(`Save secret ${secret.key}: ${err}`); } } } else { errors.push(`Fetch project secrets: ${result.reason}`); } } } } catch (err) { errors.push(`Fetch projects for ${conn.id}: ${err}`); } // Scan group secrets try { const groups = await provider.getGroups(); for (let i = 0; i < groups.length; i += 5) { const batch = groups.slice(i, i + 5); const results = await Promise.allSettled( batch.map(async (g) => { const secrets = await provider.getGroupSecrets(g.id); return secrets.map((s) => ({ ...s, scope: 'group' as const, scopeId: g.id, scopeName: g.fullPath || g.name, connectionId: conn.id, })); }), ); for (const result of results) { if (result.status === 'fulfilled') { for (const secret of result.value) { try { await this.upsertSecret(secret); totalSecrets++; } catch (err) { errors.push(`Save secret ${secret.key}: ${err}`); } } } else { errors.push(`Fetch group secrets: ${result.reason}`); } } } } catch (err) { errors.push(`Fetch groups for ${conn.id}: ${err}`); } } catch (err) { errors.push(`Connection ${conn.id}: ${err}`); } } } finally { this.isScanning = false; } const result: IScanResult = { connectionsScanned, secretsFound: totalSecrets, errors, durationMs: Date.now() - startTime, }; this.lastScanTimestamp = Date.now(); this.lastScanResult = result; logger.info( `Secrets scan complete: ${totalSecrets} secrets from ${connectionsScanned} connections in ${result.durationMs}ms` + (errors.length > 0 ? ` (${errors.length} errors)` : ''), ); return result; } /** * Scan a single entity: delete existing cached secrets for that entity, * fetch fresh from provider, and save to cache. */ async scanEntity( connectionId: string, scope: 'project' | 'group', scopeId: string, scopeName?: string, ): Promise { try { // Delete existing cached secrets for this entity const existing = await CachedSecret.getInstances({ connectionId, scope, scopeId, }); for (const doc of existing) { await doc.delete(); } // Fetch fresh from provider const provider = this.connectionManager.getProvider(connectionId); const secrets = scope === 'project' ? await provider.getProjectSecrets(scopeId) : await provider.getGroupSecrets(scopeId); // Save to cache for (const s of secrets) { const doc = CachedSecret.fromISecret({ ...s, scope, scopeId, scopeName: scopeName || s.scopeName || '', connectionId, }); await doc.save(); } } catch (err) { logger.error(`scanEntity failed for ${connectionId}/${scope}/${scopeId}: ${err}`); } } /** * Get cached secrets matching the filter criteria. */ async getCachedSecrets(filter: { connectionId: string; scope: 'project' | 'group'; scopeId?: string; }): Promise { // deno-lint-ignore no-explicit-any const query: any = { connectionId: filter.connectionId, scope: filter.scope, }; if (filter.scopeId) { query.scopeId = filter.scopeId; } const docs = await CachedSecret.getInstances(query); // Filter out expired docs const now = Date.now(); return docs .filter((d) => d.expiresAt > now) .map((d) => d.toISecret()); } /** * Check if non-expired cached data exists for the given connection+scope. */ async hasCachedData(connectionId: string, scope: 'project' | 'group'): Promise { const docs = await CachedSecret.getInstances({ connectionId, scope, expiresAt: { $gt: Date.now() }, }); return docs.length > 0; } }