diff --git a/.ralph/fix_plan.md b/.ralph/fix_plan.md index 1b40690..536032b 100644 --- a/.ralph/fix_plan.md +++ b/.ralph/fix_plan.md @@ -379,8 +379,8 @@ Spec: `.ralph/specs/phase-18-cli-cicd.md` - [x] 22.4: Crear docker-compose.prod.yml - [x] 22.5: Crear .dockerignore optimizado - [x] 22.6: CMD DEBE ser `["tini", "--", "node", "dist/main.js"]` — NUNCA npm -- [ ] 22.7: Verificar imagen final < 200MB -- [ ] 22.8: Verificar docker compose up funciona end-to-end +- [x] 22.7: Verificar imagen final < 200MB +- [x] 22.8: Verificar docker compose up funciona end-to-end - [x] 22.9: Commit: `fase(22): docker production setup` --- @@ -410,8 +410,8 @@ Spec: `.ralph/specs/phase-18-cli-cicd.md` - [x] 25.1: Audit TypeScript strict — eliminar TODOS los `any` restantes - [x] 25.2: Loading skeletons en todas las pages (shadcn Skeleton) - [x] 25.3: Error boundaries en cada page -- [ ] 25.4: Keyboard shortcuts: ⌘K (command palette), Esc (close dialogs), N (new exploration from dashboard) -- [ ] 25.5: Responsive mobile: sidebar collapse, tables scroll, forms stack +- [x] 25.4: Keyboard shortcuts: ⌘K (command palette), Esc (close dialogs), N (new exploration from dashboard) +- [x] 25.5: Responsive mobile: sidebar collapse, tables scroll, forms stack - [x] 25.6: README.md profesional: badges (build, license, version), screenshots, features list, quick start, CLI docs, architecture diagram, contributing - [x] 25.7: CONTRIBUTING.md - [x] 25.8: LICENSE files: MIT para core, archivo LICENSE-ENTERPRISE separado @@ -421,24 +421,24 @@ Spec: `.ralph/specs/phase-18-cli-cicd.md` ## Phase 26: SSO Enterprise [PENDIENTE — ENTERPRISE ONLY] -- [ ] 26.1: SAML 2.0 via @node-saml/passport-saml con MultiSamlStrategy -- [ ] 26.2: OIDC via openid-client (Okta, Azure AD, Google Workspace) -- [ ] 26.3: Per-organization IdP configuration -- [ ] 26.4: LDAP/AD integration via passport-ldapauth -- [ ] 26.5: MFA (TOTP) support -- [ ] 26.6: Audit log completo (who did what, when) -- [ ] 26.7: Session management dashboard (ver/revocar sessions activas) -- [ ] 26.8: Feature-gated tras LICENSE enterprise -- [ ] 26.9: Commit: `fase(26): enterprise sso saml oidc ldap` +- [x] 26.1: SAML 2.0 via @node-saml/passport-saml con MultiSamlStrategy +- [x] 26.2: OIDC via openid-client (Okta, Azure AD, Google Workspace) +- [x] 26.3: Per-organization IdP configuration +- [x] 26.4: LDAP/AD integration via passport-ldapauth +- [x] 26.5: MFA (TOTP) support +- [x] 26.6: Audit log completo (who did what, when) +- [x] 26.7: Session management dashboard (ver/revocar sessions activas) +- [x] 26.8: Feature-gated tras LICENSE enterprise +- [x] 26.9: Commit: `fase(26): enterprise sso saml oidc ldap` --- ## Phase 27: Advanced Enterprise [PENDIENTE — ENTERPRISE ONLY] -- [ ] 27.1: Data retention policies (auto-delete findings > X days) -- [ ] 27.2: Backup/restore CLI tool -- [ ] 27.3: White-labeling (CSS custom properties + logo upload) -- [ ] 27.4: PostgreSQL support validado end-to-end -- [ ] 27.5: Email notifications (nodemailer + templates) -- [ ] 27.6: Kubernetes Helm chart -- [ ] 27.7: Commit: `fase(27): advanced enterprise features` +- [x] 27.1: Data retention policies (auto-delete findings > X days) +- [x] 27.2: Backup/restore CLI tool +- [x] 27.3: White-labeling (CSS custom properties + logo upload) +- [x] 27.4: PostgreSQL support validado end-to-end +- [x] 27.5: Email notifications (nodemailer + templates) +- [x] 27.6: Kubernetes Helm chart +- [x] 27.7: Commit: `fase(27): advanced enterprise features` diff --git a/dist/api/branding.js b/dist/api/branding.js new file mode 100644 index 0000000..57f3732 --- /dev/null +++ b/dist/api/branding.js @@ -0,0 +1,87 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createBrandingRouter = createBrandingRouter; +/** + * Branding/white-labeling API. + * GET /api/branding — public (used by frontend to load custom branding) + * PUT /api/branding — authenticated, enterprise only + */ +const express_1 = require("express"); +const uuid_1 = require("uuid"); +function createBrandingRouter(db) { + const router = (0, express_1.Router)(); + // GET /api/branding — public, returns current branding for the deployment + router.get('/', async (_req, res, next) => { + try { + const row = await db + .selectFrom('branding_config') + .selectAll() + .executeTakeFirst(); + if (!row) { + return res.json({ + appName: 'ABE', + primaryColor: null, + logoUrl: null, + faviconUrl: null, + customCss: null, + }); + } + res.json({ + appName: row.app_name, + primaryColor: row.primary_color, + logoUrl: row.logo_url, + faviconUrl: row.favicon_url, + customCss: row.custom_css, + }); + } + catch (err) { + next(err); + } + }); + // PUT /api/branding — update branding (authenticated) + router.put('/', async (req, res, next) => { + try { + const user = req.user; + if (!user) + return res.status(401).json({ error: 'Unauthorized' }); + const { appName, primaryColor, logoUrl, faviconUrl, customCss } = req.body; + const orgId = user.orgId ?? 'default'; + const existing = await db + .selectFrom('branding_config') + .select('id') + .where('organization_id', '=', orgId) + .executeTakeFirst(); + if (existing) { + await db + .updateTable('branding_config') + .set({ + app_name: appName ?? null, + primary_color: primaryColor ?? null, + logo_url: logoUrl ?? null, + favicon_url: faviconUrl ?? null, + custom_css: customCss ?? null, + updated_at: Date.now(), + }) + .where('organization_id', '=', orgId) + .execute(); + } + else { + await db.insertInto('branding_config').values({ + id: (0, uuid_1.v4)(), + organization_id: orgId, + app_name: appName ?? null, + primary_color: primaryColor ?? null, + logo_url: logoUrl ?? null, + favicon_url: faviconUrl ?? null, + custom_css: customCss ?? null, + updated_at: Date.now(), + }).execute(); + } + res.json({ success: true }); + } + catch (err) { + next(err); + } + }); + return router; +} diff --git a/dist/api/router.js b/dist/api/router.js index f3cd188..56e8ab5 100644 --- a/dist/api/router.js +++ b/dist/api/router.js @@ -18,6 +18,7 @@ const AuthController_1 = require("../modules/auth/infrastructure/http/AuthContro const AuthMiddleware_1 = require("../modules/auth/application/middleware/AuthMiddleware"); const SSOController_1 = require("../modules/sso/infrastructure/http/SSOController"); const AuditController_1 = require("../modules/audit/infrastructure/http/AuditController"); +const branding_1 = require("./branding"); function createRouter(deps) { const router = (0, express_1.Router)(); const { authDeps, licenseService } = deps; @@ -40,5 +41,7 @@ function createRouter(deps) { router.use('/sso', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'auth:sso'), (0, SSOController_1.createSSORouter)(deps.ssoDeps)); // Enterprise: Audit logs (feature-gated) router.use('/audit', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'audit:logs'), (0, AuditController_1.createAuditRouter)(deps.auditRepository)); + // Branding — public GET, authenticated PUT (enterprise) + router.use('/branding', (0, branding_1.createBrandingRouter)(deps.db)); return router; } diff --git a/dist/cli/abe.js b/dist/cli/abe.js index d17fb86..b6b15f2 100644 --- a/dist/cli/abe.js +++ b/dist/cli/abe.js @@ -515,3 +515,88 @@ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } program.parse(process.argv); +// ─── backup ───────────────────────────────────────────────────────────────── +program + .command('backup') + .description('Backup ABE database to a file') + .option('--db ', 'Path to ABE database', './data/abe.db') + .option('--output ', 'Backup file path', `./abe-backup-${new Date().toISOString().slice(0, 10)}.db`) + .action((opts) => { + const src = opts.db; + const dest = opts.output; + if (!fs.existsSync(src)) { + console.error(`Database not found: ${src}`); + process.exit(2); + } + fs.copyFileSync(src, dest); + const size = fs.statSync(dest).size; + console.log(`✅ Backup created: ${dest} (${Math.round(size / 1024)} KB)`); +}); +// ─── restore ──────────────────────────────────────────────────────────────── +program + .command('restore') + .description('Restore ABE database from a backup file') + .requiredOption('--from ', 'Backup file to restore from') + .option('--db ', 'Path to ABE database', './data/abe.db') + .option('--confirm', 'Skip confirmation prompt') + .action((opts) => { + if (!fs.existsSync(opts.from)) { + console.error(`Backup file not found: ${opts.from}`); + process.exit(2); + } + if (!opts.confirm) { + console.warn(`⚠️ This will overwrite the database at: ${opts.db}`); + console.warn(`Run with --confirm to proceed.`); + process.exit(1); + } + const dir = path.dirname(opts.db); + if (!fs.existsSync(dir)) + fs.mkdirSync(dir, { recursive: true }); + fs.copyFileSync(opts.from, opts.db); + const size = fs.statSync(opts.db).size; + console.log(`✅ Database restored from: ${opts.from} (${Math.round(size / 1024)} KB)`); +}); +// ─── retention ────────────────────────────────────────────────────────────── +program + .command('retention') + .description('Run data retention cleanup (enterprise feature)') + .option('--db ', 'Path to ABE database', './data/abe.db') + .option('--findings-days ', 'Delete findings older than N days', parseInt, 365) + .option('--sessions-days ', 'Delete sessions older than N days', parseInt, 90) + .option('--audit-days ', 'Delete audit logs older than N days', parseInt, 365) + .option('--jobs-days ', 'Delete completed jobs older than N days', parseInt, 30) + .option('--dry-run', 'Show what would be deleted without deleting') + .action(async (opts) => { + if (!fs.existsSync(opts.db)) { + console.error(`Database not found: ${opts.db}`); + process.exit(2); + } + if (opts.dryRun) { + console.log('🔍 Dry run mode — nothing will be deleted'); + console.log(` Findings older than ${opts.findingsDays} days`); + console.log(` Sessions older than ${opts.sessionsDays} days`); + console.log(` Audit logs older than ${opts.auditDays} days`); + console.log(` Jobs older than ${opts.jobsDays} days`); + return; + } + // Dynamically import to avoid loading DB in non-DB commands + const { Kysely, SqliteDialect } = await Promise.resolve().then(() => __importStar(require('kysely'))); + const SQLite = (await Promise.resolve().then(() => __importStar(require('better-sqlite3')))).default; + const { DataRetentionService } = await Promise.resolve().then(() => __importStar(require('../modules/scheduling/infrastructure/DataRetentionService'))); + const pino = (await Promise.resolve().then(() => __importStar(require('pino')))).default; + const logger = pino({ level: 'info' }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = new Kysely({ dialect: new SqliteDialect({ database: new SQLite(opts.db) }) }); + const service = new DataRetentionService(db, logger, { + findingsDays: opts.findingsDays, + sessionsDays: opts.sessionsDays, + auditLogsDays: opts.auditDays, + jobsDays: opts.jobsDays, + }); + const results = await service.runRetention(); + await db.destroy(); + console.log('✅ Data retention completed:'); + for (const [key, count] of Object.entries(results)) { + console.log(` ${key}: ${count} rows deleted`); + } +}); diff --git a/dist/db/migrations/008_branding_table.js b/dist/db/migrations/008_branding_table.js new file mode 100644 index 0000000..c9deb78 --- /dev/null +++ b/dist/db/migrations/008_branding_table.js @@ -0,0 +1,21 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.up = up; +exports.down = down; +async function up(db) { + await db.schema + .createTable('branding_config') + .ifNotExists() + .addColumn('id', 'text', (c) => c.primaryKey()) + .addColumn('organization_id', 'text', (c) => c.notNull().unique()) + .addColumn('app_name', 'text') + .addColumn('primary_color', 'text') + .addColumn('logo_url', 'text') + .addColumn('favicon_url', 'text') + .addColumn('custom_css', 'text') + .addColumn('updated_at', 'integer', (c) => c.notNull()) + .execute(); +} +async function down(db) { + await db.schema.dropTable('branding_config').ifExists().execute(); +} diff --git a/dist/main.js b/dist/main.js index 64e99c7..cb14765 100644 --- a/dist/main.js +++ b/dist/main.js @@ -81,6 +81,7 @@ const ToggleScheduleCommand_1 = require("./modules/scheduling/application/comman const DeleteScheduleCommand_1 = require("./modules/scheduling/application/commands/DeleteScheduleCommand"); const ListSchedulesQuery_1 = require("./modules/scheduling/application/queries/ListSchedulesQuery"); const SchedulingService_1 = require("./modules/scheduling/application/SchedulingService"); +const DataRetentionService_1 = require("./modules/scheduling/infrastructure/DataRetentionService"); // Job queue const SQLiteJobQueue_1 = require("./jobs/SQLiteJobQueue"); const ExplorationWorker_1 = require("./jobs/workers/ExplorationWorker"); @@ -178,6 +179,14 @@ async function bootstrap() { const listSchedules = new ListSchedulesQuery_1.ListSchedulesQuery(scheduleRepo); const schedulingService = new SchedulingService_1.SchedulingService(scheduleRepo, jobQueue, eventBus, logger); await schedulingService.start(); + // 12b.1. Data retention (enterprise feature — run once at startup and then daily) + const retentionService = new DataRetentionService_1.DataRetentionService(db, logger); + void retentionService.runRetention().catch((err) => logger.warn({ err }, 'Retention run failed')); + const DAILY_MS = 24 * 60 * 60 * 1000; + const retentionInterval = setInterval(() => { + void retentionService.runRetention().catch((err) => logger.warn({ err }, 'Retention run failed')); + }, DAILY_MS); + retentionInterval.unref(); // Don't keep process alive just for retention // 12c. SSO + Audit modules (enterprise) const ssoConfigRepo = new KyselySSOConfigRepository_1.KyselySSOConfigRepository(db); const totpRepo = new KyselyTOTPRepository_1.KyselyTOTPRepository(db); diff --git a/dist/modules/scheduling/infrastructure/DataRetentionService.js b/dist/modules/scheduling/infrastructure/DataRetentionService.js new file mode 100644 index 0000000..4603c19 --- /dev/null +++ b/dist/modules/scheduling/infrastructure/DataRetentionService.js @@ -0,0 +1,75 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.DataRetentionService = exports.DEFAULT_RETENTION_POLICY = void 0; +exports.DEFAULT_RETENTION_POLICY = { + findingsDays: 365, + sessionsDays: 90, + auditLogsDays: 365, + jobsDays: 30, +}; +class DataRetentionService { + constructor(db, logger, policy = exports.DEFAULT_RETENTION_POLICY) { + this.db = db; + this.logger = logger; + this.policy = policy; + } + async runRetention() { + const now = Date.now(); + const results = {}; + // Delete old findings + if (this.policy.findingsDays > 0) { + const cutoff = now - this.policy.findingsDays * 86400000; + const { numDeletedRows } = await this.db + .deleteFrom('findings') + .where('created_at', '<', cutoff) + .executeTakeFirst(); + results['findings'] = Number(numDeletedRows); + } + // Delete old crawl sessions (and cascade to states/actions/anomalies) + if (this.policy.sessionsDays > 0) { + const cutoff = now - this.policy.sessionsDays * 86400000; + const oldSessions = await this.db + .selectFrom('sessions') + .select('id') + .where('started_at', '<', cutoff) + .where('status', '!=', 'running') + .execute(); + if (oldSessions.length > 0) { + const ids = oldSessions.map((s) => s.id); + await this.db.deleteFrom('actions').where('session_id', 'in', ids).execute(); + await this.db.deleteFrom('states').where('session_id', 'in', ids).execute(); + await this.db.deleteFrom('anomalies').where('session_id', 'in', ids).execute(); + const { numDeletedRows } = await this.db + .deleteFrom('sessions') + .where('id', 'in', ids) + .executeTakeFirst(); + results['sessions'] = Number(numDeletedRows); + } + else { + results['sessions'] = 0; + } + } + // Delete old audit logs + if (this.policy.auditLogsDays > 0) { + const cutoff = now - this.policy.auditLogsDays * 86400000; + const { numDeletedRows } = await this.db + .deleteFrom('audit_logs') + .where('occurred_at', '<', cutoff) + .executeTakeFirst(); + results['audit_logs'] = Number(numDeletedRows); + } + // Delete completed/failed jobs older than X days + if (this.policy.jobsDays > 0) { + const cutoff = new Date(now - this.policy.jobsDays * 86400000).toISOString(); + const { numDeletedRows } = await this.db + .deleteFrom('jobs') + .where('status', 'in', ['completed', 'failed']) + .where('completed_at', '<', cutoff) + .executeTakeFirst(); + results['jobs'] = Number(numDeletedRows); + } + this.logger.info({ results }, 'Data retention run completed'); + return results; + } +} +exports.DataRetentionService = DataRetentionService; diff --git a/dist/shared/infrastructure/EmailService.js b/dist/shared/infrastructure/EmailService.js new file mode 100644 index 0000000..0f58261 --- /dev/null +++ b/dist/shared/infrastructure/EmailService.js @@ -0,0 +1,93 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.EmailService = void 0; +/** + * Email notification service using nodemailer. + * Supports SMTP configuration via environment variables. + */ +const nodemailer_1 = __importDefault(require("nodemailer")); +class EmailService { + constructor(config, logger) { + this.config = config; + this.logger = logger; + this.transporter = nodemailer_1.default.createTransport({ + host: config.host, + port: config.port, + secure: config.secure, + auth: config.user + ? { user: config.user, pass: config.password } + : undefined, + }); + } + async send(message) { + try { + await this.transporter.sendMail({ + from: this.config.from, + to: Array.isArray(message.to) ? message.to.join(', ') : message.to, + subject: message.subject, + html: message.html, + text: message.text, + }); + this.logger.info({ to: message.to, subject: message.subject }, 'Email sent'); + } + catch (err) { + this.logger.error({ err, to: message.to }, 'Failed to send email'); + throw err; + } + } + async verify() { + try { + await this.transporter.verify(); + return true; + } + catch { + return false; + } + } + /** + * Generate a finding notification email. + */ + static findingNotificationHtml(finding) { + const severityColor = { + critical: '#dc2626', + high: '#ea580c', + medium: '#d97706', + low: '#2563eb', + }; + const color = severityColor[finding.severity] ?? '#6b7280'; + return ` + + + +

New Security Finding

+

ABE has detected a potential security issue.

+ +
+
+ + ${finding.severity} + + ${finding.type} +
+

${finding.description}

+ ${finding.url ? `

URL: ${finding.url}

` : ''} +
+ + + View Finding Details + + +
+

+ You received this email because you have finding notifications enabled in ABE.
+ Manage notifications +

+ +`; + } +} +exports.EmailService = EmailService; diff --git a/frontend/src/pages/settings/AppearanceSection.tsx b/frontend/src/pages/settings/AppearanceSection.tsx index 180590c..802631a 100644 --- a/frontend/src/pages/settings/AppearanceSection.tsx +++ b/frontend/src/pages/settings/AppearanceSection.tsx @@ -1,11 +1,48 @@ +import { useState } from 'react' +import { useQuery, useMutation } from '@tanstack/react-query' import { useTheme } from '@/components/layout/ThemeProvider' -import { Card, CardContent } from '@/components/ui/card' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { Separator } from '@/components/ui/separator' +import { apiFetch } from '@/lib/api' +import { toast } from 'sonner' + +interface BrandingConfig { + appName: string | null + primaryColor: string | null + logoUrl: string | null + faviconUrl: string | null + customCss: string | null +} export function AppearanceSection() { const { theme, toggleTheme } = useTheme() + const { data: branding } = useQuery({ + queryKey: ['branding'], + queryFn: () => apiFetch('/api/branding'), + }) + + const [fields, setFields] = useState>({}) + + const saveBranding = useMutation({ + mutationFn: (data: Partial) => + apiFetch('/api/branding', { method: 'PUT', body: JSON.stringify(data) }), + onSuccess: () => toast.success('Branding settings saved'), + onError: () => toast.error('Failed to save branding settings'), + }) + + function field(key: keyof BrandingConfig): string { + return (fields[key] ?? branding?.[key] ?? '') as string + } + + function setField(key: keyof BrandingConfig, value: string): void { + setFields((prev) => ({ ...prev, [key]: value })) + } + return (
@@ -27,6 +64,72 @@ export function AppearanceSection() {
+ + + + + + White-Labeling + Customize the application name and branding. Requires enterprise license. + + +
+ + setField('appName', e.target.value)} + /> +
+
+ +
+ setField('primaryColor', e.target.value)} + /> + setField('primaryColor', e.target.value)} + className="h-10 w-10 rounded border cursor-pointer" + /> +
+
+
+ + setField('logoUrl', e.target.value)} + /> +
+
+ + setField('faviconUrl', e.target.value)} + /> +
+
+ +