diff --git a/.env.example b/.env.example index ef95f7528..b05f70716 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,9 @@ NEXTAUTH_ADMIN_CREDENTIALS= RETRACED_HOST_URL= RETRACED_EXTERNAL_URL= RETRACED_ADMIN_ROOT_TOKEN= +RETRACED_API_KEY= +RETRACED_PROJECT_ID= +AUDIT_LOG_TEAMS= # Admin Portal for Terminus (Privacy Vault) TERMINUS_PROXY_HOST_URL= diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f81102179..fda6443af 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -458,3 +458,20 @@ jobs: working-directory: ./${{ matrix.package }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - run: npm install + working-directory: ./ee/security-sinks + + - name: Publish Security_Sink + if: github.ref == 'refs/heads/release' || contains(github.ref, 'refs/tags/beta-v') + run: | + npm install -g json + JACKSON_VERSION=${{ needs.ci.outputs.NPM_VERSION }} + json -I -f package.json -e "this.main=\"dist/index.js\"" + json -I -f package.json -e "this.types=\"dist/index.d.ts\"" + json -I -f package.json -e "this.version=\"${JACKSON_VERSION}\"" + + npm publish --tag ${{ needs.ci.outputs.PUBLISH_TAG }} --access public + working-directory: ./ee/security-sinks + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/Dockerfile b/Dockerfile index a7da2cf63..f32b81ee8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ WORKDIR /app COPY package.json package-lock.json ./ COPY npm npm COPY internal-ui internal-ui +COPY ee/security-sinks ee/security-sinks COPY migrate.sh prebuild.ts ./ RUN npm install RUN npm rebuild --arch=x64 --platform=linux --libc=musl sharp @@ -22,6 +23,7 @@ WORKDIR /app COPY --from=deps /app/npm ./npm COPY --from=deps /app/internal-ui ./internal-ui +COPY --from=deps /app/ee/security-sinks ./ee/security-sinks COPY --from=deps /app/node_modules ./node_modules COPY . . diff --git a/check-locale.js b/check-locale.js index df7339e92..2efe2c9aa 100644 --- a/check-locale.js +++ b/check-locale.js @@ -2,6 +2,12 @@ const fs = require('fs'); const path = require('path'); const regExp = /\bt\('(.*?)'/gm; const altRegExp = /\bi18nKey='(.*?)'/gm; +const exceptionList = [ + 'bui-default-token', + 'bui-default-token-placeholder', + 'bui-splunk-collector-url', + 'bui-splunk-hec-endpoint-placeholder', +]; const allStrings = {}; @@ -43,7 +49,7 @@ files.forEach((file) => { }); Object.keys(localeFile).forEach((key) => { - if (!allStrings[key]) { + if (!allStrings[key] && !exceptionList.includes(key)) { error = true; console.error(`Unused key: ${key}`); } diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 825a6e301..d952d5bff 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -128,6 +128,11 @@ export const Sidebar = ({ isOpen, setIsOpen }: SidebarProps) => { text: 'Branding', active: asPath.includes('/admin/settings/branding'), }, + { + href: '/admin/settings/security-logs', + text: 'Security Logs', + active: asPath.includes('/admin/settings/security-logs'), + }, ], }, ]; diff --git a/ee/branding/api/admin/index.ts b/ee/branding/api/admin/index.ts index 9bb7af4b9..6d409425a 100644 --- a/ee/branding/api/admin/index.ts +++ b/ee/branding/api/admin/index.ts @@ -1,6 +1,7 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import jackson from '@lib/jackson'; +import retraced from '@ee/retraced'; import { defaultHandler } from '@lib/api'; const handler = async (req: NextApiRequest, res: NextApiResponse) => { @@ -15,6 +16,12 @@ const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => { const { logoUrl, faviconUrl, companyName, primaryColor } = req.body; + retraced.reportAdminPortalEvent({ + action: 'portal.branding.update', + crud: 'u', + req, + }); + res.json({ data: await brandingController.update({ logoUrl, faviconUrl, companyName, primaryColor }), }); diff --git a/ee/federated-saml/api/admin/[id]/index.ts b/ee/federated-saml/api/admin/[id]/index.ts index 884e35692..6925d8fd2 100644 --- a/ee/federated-saml/api/admin/[id]/index.ts +++ b/ee/federated-saml/api/admin/[id]/index.ts @@ -1,6 +1,7 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import jackson from '@lib/jackson'; +import retraced from '@ee/retraced'; import { defaultHandler } from '@lib/api'; const handler = async (req: NextApiRequest, res: NextApiResponse) => { @@ -34,6 +35,15 @@ const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => { const updatedApp = await samlFederatedController.app.update(req.body); + retraced.reportAdminPortalEvent({ + action: 'federation.app.update', + crud: 'u', + req, + target: { + id: updatedApp.id, + }, + }); + res.json({ data: updatedApp }); }; @@ -45,6 +55,15 @@ const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => { await samlFederatedController.app.delete({ id }); + retraced.reportAdminPortalEvent({ + action: 'federation.app.delete', + crud: 'd', + req, + target: { + id, + }, + }); + res.json({ data: null }); }; diff --git a/ee/federated-saml/api/admin/index.ts b/ee/federated-saml/api/admin/index.ts index 8ce5883f9..2aabfc39a 100644 --- a/ee/federated-saml/api/admin/index.ts +++ b/ee/federated-saml/api/admin/index.ts @@ -1,6 +1,7 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import jackson from '@lib/jackson'; +import retraced from '@ee/retraced'; import { defaultHandler } from '@lib/api'; import { parsePaginateApiParams } from '@lib/utils'; @@ -17,6 +18,15 @@ const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => { const app = await samlFederatedController.app.create(req.body); + retraced.reportAdminPortalEvent({ + action: 'federation.app.create', + crud: 'c', + req, + target: { + id: app.id, + }, + }); + res.status(201).json({ data: app }); }; diff --git a/ee/retraced/index.ts b/ee/retraced/index.ts new file mode 100644 index 000000000..6717e1b95 --- /dev/null +++ b/ee/retraced/index.ts @@ -0,0 +1,273 @@ +import * as Retraced from '@retracedhq/retraced'; +import type { Event } from '@retracedhq/retraced'; +import type { NextApiRequest } from 'next'; +import { getToken as getNextAuthToken } from 'next-auth/jwt'; +import requestIp from 'request-ip'; + +import jackson from '@lib/jackson'; +import { auditLogEnabledGroup, retracedOptions } from '@lib/env'; +import { sessionName } from '@lib/constants'; +import { sendSecurityLogs } from '@ee/security-logs-config'; +import { extractAuthToken, validateApiKey } from '@lib/auth'; + +type AuditEventType = + | 'sso.user.login' + + // Single Sign On + | 'sso.connection.create' + | 'sso.connection.update' + | 'sso.connection.delete' + + // Directory Sync + | 'dsync.connection.create' + | 'dsync.connection.update' + | 'dsync.connection.delete' + | 'dsync.webhook_event.delete' + + // Setup Link + | 'sso.setuplink.create' + | 'sso.setuplink.update' + | 'sso.setuplink.delete' + | 'dsync.setuplink.create' + | 'dsync.setuplink.update' + | 'dsync.setuplink.delete' + + // Federated SAML + | 'federation.app.create' + | 'federation.app.update' + | 'federation.app.delete' + + // Retraced + | 'retraced.project.create' + + // Admin settings + | 'portal.branding.update' + | 'portal.user.login' + + // Security Logs Config + | 'security.logs.config.create' + | 'security.logs.config.update' + | 'security.logs.config.delete' + + // SaaS app + | 'member.invitation.create' + | 'member.invitation.delete' + | 'member.remove' + | 'member.update' + | 'sso.connection.create' + | 'sso.connection.patch' + | 'sso.connection.delete' + | 'dsync.connection.create' + | 'dsync.connection.delete' + | 'webhook.create' + | 'webhook.delete' + | 'webhook.update' + | 'team.create' + | 'team.update' + | 'team.delete' + | 'audit_log.splunk_connection.create' + | 'audit_log.splunk_connection.delete' + | 'audit_log.splunk_connection.update' + | 'api_key.create' + | 'api_key.delete'; + +interface ReportAdminEventParams { + action: AuditEventType; + crud: Retraced.CRUD; + target?: Retraced.Target; + group?: Retraced.Group; + actor?: Retraced.Actor; + req?: NextApiRequest; +} + +interface ReportEventParams { + action: AuditEventType; + crud: Retraced.CRUD; + actor: Retraced.Actor; + req: NextApiRequest; + group?: Retraced.Group; + target?: Retraced.Target; + sourceIp?: string; + productId?: string; +} + +const adminPortalGroup = { + id: 'boxyhq-admin-portal', + name: 'BoxyHQ Admin Portal', +}; + +let client: Retraced.Client | null = null; + +// Check if audit log is enabled for a given group +// const auditLogEnabledFor = (groupId: string) => { +// return auditLogEnabledGroup.includes(groupId); +// }; + +// Create a Retraced client +const getClient = async () => { + const { checkLicense } = await jackson(); + + if (!(await checkLicense())) { + return; + } + + if (!retracedOptions.hostUrl || !retracedOptions.apiKey || !retracedOptions.projectId) { + return; + } + + if (client) { + return client; + } + + client = new Retraced.Client({ + endpoint: retracedOptions.hostUrl, + apiKey: retracedOptions.apiKey, + projectId: retracedOptions.projectId, + }); + + return client; +}; + +// Report events to Retraced +const reportEvent = async (params: ReportEventParams) => { + const { action, crud, actor, sourceIp, req } = params; + try { + const retracedClient = await getClient(); + + const retracedEvent: Event = { + action, + crud, + actor, + created: new Date(), + source_ip: sourceIp || getClientIp(req), + }; + + if ('group' in params && params.group) { + retracedEvent.group = params.group; + } + + if ('target' in params && params.target) { + retracedEvent.target = params.target; + } + + // Find team info if productId is provided + if ('productId' in params && params.productId) { + const { productController } = await jackson(); + + const product = await productController.get(params.productId); + + if (!product) { + console.error(`Can't find product info for productId ${params.productId}`); + return; + } + + if (product.teamId && product.teamName) { + retracedEvent.group = { + id: product.teamId, + name: product.teamName, + }; + } + + if (product.id && product.name) { + retracedEvent.target = { + id: product.id, + name: product.name, + }; + } + } + + if (!retracedEvent.group?.id) { + return; + } + + if (auditLogEnabledGroup.length && !auditLogEnabledGroup.includes(retracedEvent.group?.id)) { + return; + } + + if (retracedClient) { + await retracedClient.reportEvent(retracedEvent); + } + + await sendSecurityLogs(retracedEvent, retracedEvent.group?.id); + } catch (error: any) { + console.error('Error reporting event to Retraced', error); + } +}; + +// Report Admin portal events to Retraced +export const reportAdminPortalEvent = async (params: ReportAdminEventParams) => { + const { action, crud, target, actor, group, req } = params; + + try { + const retracedClient = await getClient(); + + const retracedEvent: Event = { + action, + crud, + target, + actor: actor ?? (await getAdminUser(req)), + group: group || adminPortalGroup, + created: new Date(), + }; + const ip = getClientIp(req); + + if (ip) { + retracedEvent['source_ip'] = ip; + } + if (retracedClient) { + await retracedClient.reportEvent(retracedEvent); + } + await sendSecurityLogs(retracedEvent); + } catch (error: any) { + console.error('Error reporting event to Retraced', error); + } +}; + +// Find admin actor info from NextAuth token +const getAdminUser = async (req: NextApiRequest | undefined) => { + if (!req) { + throw new Error(`NextApiRequest is required to get actor info for Retraced event.`); + } + + // API keys used for admin portal routes + if (validateApiKey(extractAuthToken(req))) { + return { + id: 'API', + name: 'API', + }; + } else { + const user = await getNextAuthToken({ + req, + cookieName: sessionName, + }); + + if (!user || !user.email || !user.name) { + throw new Error(`Can't find actor info from the NextAuth token.`); + } + + return { + id: user.email, + name: user.name, + }; + } +}; + +// Find Ip from request +const getClientIp = (req: NextApiRequest | undefined) => { + if (!req) { + return; + } + + const sourceIp = requestIp.getClientIp(req); + + if (!sourceIp.startsWith('::')) { + return sourceIp as string; + } +}; + +const retraced = { + reportEvent, + reportAdminPortalEvent, +}; + +export default retraced; diff --git a/ee/security-logs-config/api/[id]/index.ts b/ee/security-logs-config/api/[id]/index.ts new file mode 100644 index 000000000..9f87c7c1f --- /dev/null +++ b/ee/security-logs-config/api/[id]/index.ts @@ -0,0 +1,92 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + +import jackson from '@lib/jackson'; +import retraced from '@ee/retraced'; +import { ApiError } from '@lib/error'; + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const { method } = req; + + try { + switch (method) { + case 'GET': + return await handleGET(req, res); + case 'PUT': + return await handlePUT(req, res); + case 'DELETE': + return await handleDELETE(req, res); + default: + res.setHeader('Allow', 'GET, PUT, DELETE'); + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }); + } + } catch (error: any) { + const { message, statusCode = 500 } = error; + + return res.status(statusCode).json({ + error: { message }, + }); + } +}; + +// Get Security Logs config by id +const handleGET = async (req: NextApiRequest, res: NextApiResponse) => { + const { securityLogsConfigController } = await jackson(); + + const { id } = req.query as { id: string }; + + const config = await securityLogsConfigController.get(id); + + if (!config) { + throw new ApiError(`Security Logs Config with id ${id} not found`, 404); + } + + return res.status(200).json({ + data: { + ...config, + }, + }); +}; + +// Update Security Logs config +const handlePUT = async (req: NextApiRequest, res: NextApiResponse) => { + const { securityLogsConfigController } = await jackson(); + + const { id } = req.query as { id: string }; + + const { config, name } = req.body as { config: any; name?: string }; + + const updatedApp = await securityLogsConfigController.update(id, config, name); + + retraced.reportAdminPortalEvent({ + action: 'security.logs.config.update', + crud: 'u', + req, + target: { + id: updatedApp.id, + }, + }); + + return res.status(200).json({ data: updatedApp }); +}; + +// Delete the Security Logs config +const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => { + const { securityLogsConfigController } = await jackson(); + + const { id } = req.query as { id: string }; + + await securityLogsConfigController.delete(id); + + retraced.reportAdminPortalEvent({ + action: 'security.logs.config.delete', + crud: 'd', + req, + target: { + id, + }, + }); + + return res.status(200).json({ data: {} }); +}; + +export default handler; diff --git a/ee/security-logs-config/api/index.ts b/ee/security-logs-config/api/index.ts new file mode 100644 index 000000000..b04c47440 --- /dev/null +++ b/ee/security-logs-config/api/index.ts @@ -0,0 +1,84 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + +import jackson from '@lib/jackson'; +import retraced from '@ee/retraced'; +import { adminPortalSSODefaults, boxyhqHosted } from '@lib/env'; + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const { method } = req; + + try { + switch (method) { + case 'POST': + return await handlePOST(req, res); + case 'GET': + return await handleGET(req, res); + default: + res.setHeader('Allow', 'POST, GET'); + res.status(405).json({ error: { message: `Method ${method} Not Allowed` } }); + } + } catch (error: any) { + const { message, statusCode = 500 } = error; + + return res.status(statusCode).json({ error: { message } }); + } +}; + +// Create new Security Logs Config +const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => { + const { securityLogsConfigController } = await jackson(); + + const { tenant, type, config, name } = req.body as { + tenant: string; + type: string; + config: any; + name?: string; + }; + + const id = await securityLogsConfigController.createSecurityLogsConfig({ + tenant: boxyhqHosted && tenant ? tenant : adminPortalSSODefaults.tenant, + type, + config, + name, + }); + + retraced.reportAdminPortalEvent({ + action: 'security.logs.config.create', + crud: 'c', + req, + target: { + id, + }, + }); + + return res.status(201).json({ data: id }); +}; + +// Get Security Logs Configs +const handleGET = async (req: NextApiRequest, res: NextApiResponse) => { + const { securityLogsConfigController } = await jackson(); + + const { pageOffset, pageLimit, pageToken, tenant } = req.query as { + pageOffset: string; + pageLimit: string; + pageToken?: string; + tenant: string; + }; + + const offset = parseInt(pageOffset); + const limit = parseInt(pageLimit); + + const configs = await securityLogsConfigController.getAll( + boxyhqHosted ? tenant : adminPortalSSODefaults.tenant, + offset, + limit, + pageToken + ); + if (configs.pageToken) { + res.setHeader('jackson-pagetoken', configs.pageToken); + } + + return res.json({ data: configs.data }); +}; + +export default handler; diff --git a/ee/security-logs-config/index.ts b/ee/security-logs-config/index.ts new file mode 100644 index 000000000..807c7e256 --- /dev/null +++ b/ee/security-logs-config/index.ts @@ -0,0 +1,17 @@ +import { adminPortalSSODefaults, boxyhqHosted } from '@lib/env'; +import jackson from '@lib/jackson'; +import getSinkInstance from '@boxyhq/security-logs-sink'; + +export const sendSecurityLogs = async (event: any, tenant?: string) => { + const { securityLogsConfigController } = await jackson(); + const tenantToUse = boxyhqHosted && tenant ? tenant : adminPortalSSODefaults.tenant; + + const configs = tenantToUse ? await securityLogsConfigController.getAll(tenantToUse) : { data: [] }; + for (const config of configs.data) { + const sink = getSinkInstance({ + type: config.type, + ...config.config, + }); + await sink.sendEvent(event); + } +}; diff --git a/ee/security-logs-config/pages/edit.tsx b/ee/security-logs-config/pages/edit.tsx new file mode 100644 index 000000000..fe9eaa924 --- /dev/null +++ b/ee/security-logs-config/pages/edit.tsx @@ -0,0 +1,28 @@ +import { errorToast, successToast } from '@components/Toaster'; +import { useRouter } from 'next/router'; +import LicenseRequired from '@components/LicenseRequired'; +import { SecurityLogsConfigEdit } from '@boxyhq/internal-ui'; + +const UpdateConfig = ({ hasValidLicense }: { hasValidLicense: boolean }) => { + const router = useRouter(); + const { id } = router.query as { id: string }; + + if (!hasValidLicense) { + return ; + } + + const urls = { + getById: (id: string) => `/api/admin/security-logs-config/${id}`, + updateById: (id: string) => `/api/admin/security-logs-config/${id}`, + deleteById: (id: string) => `/api/admin/security-logs-config/${id}`, + listConfigs: '/admin/settings/security-logs', + }; + + return ( + <> + + > + ); +}; + +export default UpdateConfig; diff --git a/ee/security-logs-config/pages/index.tsx b/ee/security-logs-config/pages/index.tsx new file mode 100644 index 000000000..ae9646a62 --- /dev/null +++ b/ee/security-logs-config/pages/index.tsx @@ -0,0 +1,26 @@ +import LicenseRequired from '@components/LicenseRequired'; +import { errorToast, successToast } from '@components/Toaster'; +import { SecurityLogsConfigs } from '@boxyhq/internal-ui'; + +const ConfigList = ({ hasValidLicense }: { hasValidLicense: boolean }) => { + if (!hasValidLicense) { + return ; + } + + const urls = { + listConfigs: '/api/admin/security-logs-config', + createConfig: '/admin/settings/security-logs/new', + editById: (id) => `/admin/settings/security-logs/${id}`, + deleteById: (id: string) => `/api/admin/security-logs-config/${id}`, + }; + return ( + + ); +}; + +export default ConfigList; diff --git a/ee/security-logs-config/pages/new.tsx b/ee/security-logs-config/pages/new.tsx new file mode 100644 index 000000000..48359da94 --- /dev/null +++ b/ee/security-logs-config/pages/new.tsx @@ -0,0 +1,18 @@ +import { successToast, errorToast } from '@components/Toaster'; +import LicenseRequired from '@components/LicenseRequired'; +import { SecurityLogsConfigCreate } from '@boxyhq/internal-ui'; + +const NewConfiguration = ({ hasValidLicense }: { hasValidLicense: boolean }) => { + if (!hasValidLicense) { + return ; + } + + const urls = { + createConfig: '/api/admin/security-logs-config', + listConfigs: '/admin/settings/security-logs', + }; + + return ; +}; + +export default NewConfiguration; diff --git a/ee/security-logs/api/index.ts b/ee/security-logs/api/index.ts new file mode 100644 index 000000000..68743938e --- /dev/null +++ b/ee/security-logs/api/index.ts @@ -0,0 +1,45 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import retraced from '@ee/retraced'; + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const { method } = req; + + try { + switch (method) { + case 'POST': + return await handlePOST(req, res); + default: + res.setHeader('Allow', 'POST'); + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }); + } + } catch (error: any) { + const { message, statusCode = 500 } = error; + + return res.status(statusCode).json({ + error: { message }, + }); + } +}; + +// Get Security Logs config by id +const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => { + const { action, crud, actor, sourceIp, group, target, productId } = req.body; + + await retraced.reportEvent({ + action: action, + crud: crud, + actor: actor, + req, + group: group, + target: target, + sourceIp: sourceIp, + productId: productId, + }); + res.json({ + data: { + success: true, + }, + }); +}; + +export default handler; diff --git a/ee/security-sinks/.gitignore b/ee/security-sinks/.gitignore new file mode 100644 index 000000000..ac0995787 --- /dev/null +++ b/ee/security-sinks/.gitignore @@ -0,0 +1,9 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +.vscode +.nyc_output +_config +dist +.DS_Store +/node_modules +**/node_modules/** diff --git a/ee/security-sinks/ENTERPRISE.md b/ee/security-sinks/ENTERPRISE.md new file mode 100644 index 000000000..db3375d8d --- /dev/null +++ b/ee/security-sinks/ENTERPRISE.md @@ -0,0 +1,7 @@ +# Enterprise Edition + +Welcome to the Enterprise Edition ("/ee") of BoxyHQ. + +The [/ee](https://github.com/boxyhq/jackson/tree/main/ee) subfolder is the place for all the **Enterprise** features for this repository. + +> _❗ NOTE: This section is copyrighted (unlike the rest of our [repository](https://github.com/boxyhq/jackson)). You are not allowed to use this code without obtaining a proper [license](https://boxyhq.com/pricing) first.❗_ diff --git a/ee/security-sinks/LICENSE b/ee/security-sinks/LICENSE new file mode 100644 index 000000000..e315ec834 --- /dev/null +++ b/ee/security-sinks/LICENSE @@ -0,0 +1 @@ +The BoxyHQ Enterprise Edition (EE) license (the “EE License”) diff --git a/ee/security-sinks/dev/verdaccio.sh b/ee/security-sinks/dev/verdaccio.sh new file mode 100755 index 000000000..bcb62b8d6 --- /dev/null +++ b/ee/security-sinks/dev/verdaccio.sh @@ -0,0 +1,15 @@ +#!/bin/zsh -ex + +# TODO: Make this generic so everyone can run it + +VERSION=0.0.0 + +# Unpublish the current version +npm unpublish --registry http://localhost:4873/ @boxyhq/security-sinks@$VERSION --force + +# Build the package +rm -rf dist +npm run build + +# Publish +npm publish --registry http://localhost:4873/ \ No newline at end of file diff --git a/ee/security-sinks/package-lock.json b/ee/security-sinks/package-lock.json new file mode 100644 index 000000000..60d963d93 --- /dev/null +++ b/ee/security-sinks/package-lock.json @@ -0,0 +1,110 @@ +{ + "name": "@boxyhq/security-sinks", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@boxyhq/security-sinks", + "version": "0.0.0", + "license": "ISC", + "dependencies": { + "axios": "1.6.8" + }, + "engines": { + "node": ">=16", + "npm": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", + "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + } + } +} diff --git a/ee/security-sinks/package.json b/ee/security-sinks/package.json new file mode 100644 index 000000000..54a92c278 --- /dev/null +++ b/ee/security-sinks/package.json @@ -0,0 +1,34 @@ +{ + "name": "@boxyhq/security-sinks", + "version": "0.0.0", + "description": "Package to deliver security logs to different SIEMs and other destination", + "scripts": { + "build": "tsc -p tsconfig.build.json", + "prepublishOnly": "npm run build", + "sort": "npx sort-package-json" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/boxyhq/jackson.git" + }, + "main": "src/index.ts", + "typings": "src/index.ts", + "files": [ + "dist", + "ENTERPRISE.md", + "LICENSE" + ], + "keywords": [ + "SIEM", + "Sinks", + "Splunk" + ], + "license": "ISC", + "engines": { + "node": ">=16", + "npm": ">=8" + }, + "dependencies": { + "axios": "1.6.8" + } +} diff --git a/ee/security-sinks/src/classes.ts b/ee/security-sinks/src/classes.ts new file mode 100644 index 000000000..55fb99334 --- /dev/null +++ b/ee/security-sinks/src/classes.ts @@ -0,0 +1,11 @@ +export class WithExponentialBackoff { + /** + * Calculates the next timeout value for exponential backoff. + * @param waitFor The current timeout value. + * @returns The next timeout value. + */ + public getNextExponentialBackoff(waitFor: number): number { + // Double the wait time until it reaches 60 seconds + return waitFor * 2 > 60000 ? 60000 : waitFor * 2; + } +} diff --git a/ee/security-sinks/src/helper.ts b/ee/security-sinks/src/helper.ts new file mode 100644 index 000000000..8425be724 --- /dev/null +++ b/ee/security-sinks/src/helper.ts @@ -0,0 +1,3 @@ +export const sleep = (ms: number) => { + return new Promise((resolve) => setTimeout(resolve, ms)); +}; diff --git a/ee/security-sinks/src/index.ts b/ee/security-sinks/src/index.ts new file mode 100644 index 000000000..42d8706a4 --- /dev/null +++ b/ee/security-sinks/src/index.ts @@ -0,0 +1,20 @@ +import { Logger, Sink } from './interfaces'; +import { SplunkHecLogs } from './splunk_hec_logs'; + +const getSinkInstance = (sinkConfig: any, customLogger?: Logger): Sink => { + switch (sinkConfig.type) { + case 'splunk_hec_logs': + return new SplunkHecLogs( + { + defaultToken: sinkConfig.default_token, + endpoint: sinkConfig.endpoint, + indexingAckEnabled: sinkConfig?.acknowledgements?.indexer_acknowledgements_enabled, + }, + customLogger + ); + default: + throw new Error(`unknown sink type: ${sinkConfig.type}`); + } +}; + +export default getSinkInstance; diff --git a/ee/security-sinks/src/interfaces.ts b/ee/security-sinks/src/interfaces.ts new file mode 100644 index 000000000..725309ed9 --- /dev/null +++ b/ee/security-sinks/src/interfaces.ts @@ -0,0 +1,16 @@ +// Interface for a Sink +export interface Sink { + // HealthCheck returns true if the sink is healthy + healthCheck(): Promise; + // TransformEvent transforms an event before sending it to the sink + transformEvent(event: any): any; + // SendEvent sends an event to the sink + sendEvent(event: any): Promise; + // SendEvents sends events to the sink + sendEvents(events: any[], batchSize: number): Promise; +} + +export interface Logger { + info: (message: string) => void; + error: (message: string) => void; +} diff --git a/ee/security-sinks/src/splunk_hec_logs/index.ts b/ee/security-sinks/src/splunk_hec_logs/index.ts new file mode 100644 index 000000000..ed16dff35 --- /dev/null +++ b/ee/security-sinks/src/splunk_hec_logs/index.ts @@ -0,0 +1,178 @@ +import axios from 'axios'; +import { randomUUID } from 'crypto'; +import { WithExponentialBackoff } from '../classes'; +import { Logger, Sink } from '../interfaces'; +import { sleep } from '../helper'; + +export class SplunkHecLogs extends WithExponentialBackoff implements Sink { + private endpoint: string; + private defaultToken: string; + private indexingAckEnabled: boolean; + private logger: Logger = console; + + constructor( + opts: { defaultToken: string; endpoint: string; indexingAckEnabled?: boolean }, + customLogger?: Logger + ) { + super(); + if (customLogger) { + this.logger = customLogger; + } + if (!opts.endpoint) { + throw new Error('endpoint is required'); + } + if (!opts.defaultToken) { + throw new Error('defaultToken is required'); + } + this.indexingAckEnabled = opts.indexingAckEnabled ? opts.indexingAckEnabled : false; + this.endpoint = opts.endpoint; + this.defaultToken = opts.defaultToken; + } + + /** + * Checks the health of the Splunk HEC endpoint. + * @returns A promise that resolves to true if the health check is successful, or rejects with an error if it fails. + */ + public async healthCheck(): Promise { + try { + const config = { + method: 'get', + maxBodyLength: Infinity, + url: `${this.endpoint}/services/collector/health/1.0`, + }; + const response = await axios.request(config); + + if (response.status === 200) { + return true; + } else { + return false; + } + } catch (error: any) { + this.logger.info(error ? error.message : 'Something went wrong with the health check'); + return false; + } + } + + /** + * Transforms a single event into the expected format. + * @param event The event to transform. + * @param batched Indicates whether the event is part of a batch. + * @returns The transformed event. + */ + public transformEvent(event: any, batched = false): any { + if (batched) { + return event.map((e) => JSON.stringify({ event: e })).join('\n'); + } else { + return { + event, + }; + } + } + + /** + * Sends a single event to the Splunk HEC endpoint. + * @param event The event to send. + * @returns A promise that resolves to true if the event is successfully sent and indexed, or false otherwise. + */ + public async sendEvent(event: any): Promise { + if (!event) { + throw new Error('event is required'); + } + let backoff = 100; + const retry = true; + do { + try { + const transformedEvent = this.transformEvent(event); + const channel = this.indexingAckEnabled ? randomUUID() : undefined; + const headers = { + 'Content-Type': 'application/json', + Authorization: `Splunk ${this.defaultToken}`, + 'X-Splunk-Request-Channel': channel, + }; + const response = await axios.post(`${this.endpoint}/services/collector/event`, transformedEvent, { + headers, + }); + if (response.status === 200) { + return true; + } else { + await sleep(backoff); + backoff = this.getNextExponentialBackoff(backoff); + } + } catch (ex) { + await sleep(backoff); + backoff = this.getNextExponentialBackoff(backoff); + } + } while (retry); + return false; + } + + /** + * Sends multiple events to the Splunk HEC endpoint. + * @param events The events to send. + * @returns A promise that resolves to true if all events are successfully sent and indexed, or false otherwise. + */ + public async sendEvents(events: any[], batchSize = 100): Promise { + if (!events || !Array.isArray(events)) { + throw new Error('events must be an array'); + } + if (events.length === 0) { + return true; + } + const batchedEvents = this.getBatchedEvents(events, batchSize); + let index = 0; + const channel = this.indexingAckEnabled ? randomUUID() : undefined; + const headers = { + Authorization: `Splunk ${this.defaultToken}`, + 'X-Splunk-Request-Channel': channel, + }; + let backoff = 100; + this.logger.info( + `Sending batch${this.indexingAckEnabled ? `(${channel})` : ''} of ${batchedEvents.length} batches and ${ + events.length + } events to Splunk` + ); + do { + try { + const transformedEvent = this.transformEvent(batchedEvents[index], true); + const response = await axios.post(`${this.endpoint}/services/collector/event`, transformedEvent, { + headers, + }); + + if (response.status === 200) { + this.logger.info(`Batch ${index} of ${batchedEvents[index].length} events sent to Splunk`); + backoff = 100; + await sleep(backoff); + index++; + } else { + this.logger.info(`Splunk HEC returned status code ${response.status}. Retrying in ${backoff}ms...`); + await sleep(backoff); + backoff = this.getNextExponentialBackoff(backoff); + } + } catch (ex: any) { + this.logger.info( + `Splunk HEC returned status code ${ex.response.status}. Retrying in ${backoff}ms...` + ); + await sleep(backoff); + backoff = this.getNextExponentialBackoff(backoff); + } + } while (index < batchedEvents.length); + this.logger.info( + `Batch${this.indexingAckEnabled ? `(${channel})` : ''} of ${events.length} events sent to Splunk` + ); + return true; + } + + /** + * Splits an array of events into batches of a specified size. + * @param {any[]} events - The array of events to be batched. + * @param {number} batchSize - The size of each batch. + * @returns {any[]} - An array of batches. + */ + private getBatchedEvents(events: any[], batchSize: number): any[] { + const batchedEvents: any[] = []; + for (let i = 0; i < events.length; i += batchSize) { + batchedEvents.push(events.slice(i, i + batchSize)); + } + return batchedEvents; + } +} diff --git a/ee/security-sinks/tsconfig.build.json b/ee/security-sinks/tsconfig.build.json new file mode 100644 index 000000000..f507e4fa2 --- /dev/null +++ b/ee/security-sinks/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules"] +} diff --git a/ee/security-sinks/tsconfig.json b/ee/security-sinks/tsconfig.json new file mode 100644 index 000000000..ef29a73c9 --- /dev/null +++ b/ee/security-sinks/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "sourceMap": true, + "outDir": "dist", + "allowJs": true, + "skipLibCheck": true, + "module": "CommonJS", + "target": "es6", //same as es2015 + "forceConsistentCasingInFileNames": true, + "noImplicitAny": false, + "strict": true, + "noImplicitThis": false, + "resolveJsonModule": true, + "esModuleInterop": true, + "declaration": true, + "noEmitOnError": false, + "noUnusedParameters": true, + "removeComments": false, + "strictNullChecks": true, + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "downlevelIteration": true, + }, + "include": ["./src/**/*"], + "exclude": ["node_modules"], + "ts-node": { + "files": true, + }, +} diff --git a/internal-ui/dev/verdaccio.sh b/internal-ui/dev/verdaccio.sh index 0c2316a4e..d282f2dff 100755 --- a/internal-ui/dev/verdaccio.sh +++ b/internal-ui/dev/verdaccio.sh @@ -9,6 +9,10 @@ npm unpublish --registry http://localhost:4873/ @boxyhq/internal-ui@$VERSION # Build the package rm -rf dist + +# Update package.json main and types +jq '.main = "./dist/index.js" | .types = "./dist/index.d.ts"' package.json > tmp.json && mv tmp.json package.json + npm run build # Publish @@ -20,6 +24,9 @@ npm publish --registry http://localhost:4873/ # npm i --save-exact --registry http://localhost:4873/ @boxyhq/internal-ui@$VERSION # rm -rf .next +# revert package.json main and types +jq '.main = "./src/index.ts" | .types = "./src/index.d.ts"' package.json > tmp.json && mv tmp.json package.json + # Install the published version in `boxyhq/saas-app` cd ../../saas-app npm uninstall @boxyhq/internal-ui diff --git a/internal-ui/src/index.ts b/internal-ui/src/index.ts index e80ccd01f..ad137ae3c 100644 --- a/internal-ui/src/index.ts +++ b/internal-ui/src/index.ts @@ -5,3 +5,4 @@ export * from './dsync'; export * from './provider'; export * from './sso-tracer'; export * from './setup-link'; +export * from './security-logs-config'; diff --git a/internal-ui/src/security-logs-config/SecurityLogsConfigCreate.tsx b/internal-ui/src/security-logs-config/SecurityLogsConfigCreate.tsx new file mode 100644 index 000000000..d3eec89fc --- /dev/null +++ b/internal-ui/src/security-logs-config/SecurityLogsConfigCreate.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; +import { useTranslation } from 'next-i18next'; + +import { useRouter } from '../hooks'; +import { configMap } from './lib'; +import { ButtonPrimary, LinkBack } from '../shared'; + +export const SecurityLogsConfigCreate = ({ + urls, + onSuccess, + onError, +}: { + urls: { + createConfig: string; + listConfigs: string; + }; + onSuccess: (message: string) => void; + onError: (message: string) => void; +}) => { + const { t } = useTranslation('common'); + const { router } = useRouter(); + + const [loading, setLoading] = useState(false); + const [config, setConfig] = useState({}); + const [name, setName] = useState(''); + const [type, setType] = useState(t('bui-shared-select-type')); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + setLoading(true); + + const rawResponse = await fetch(urls.createConfig, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name, type: configMap[type].type, config }), + }); + + setLoading(false); + + const response = await rawResponse.json(); + + if ('error' in response) { + if (response?.error?.message) { + onError(response.error.message); + return; + } + } + + if ('data' in response) { + onSuccess(t('bui-slc-new-success')); + router?.replace(urls.listConfigs); + } + }; + + const onChange = (event: React.ChangeEvent) => { + const target = event.target as HTMLInputElement; + + setConfig({ + ...config, + [target.id]: target.value, + }); + }; + + return ( + <> + + {t('bui-slc-add')} + + + + + + {t('bui-shared-type')} + + { + setType(e.target.value); + }} + required> + {[t('bui-shared-select-type'), ...Object.keys(configMap)].map((key) => { + return ( + + {key} + + ); + })} + + + + + {t('bui-shared-name')} + + setName(e.target.value)} + placeholder={t('bui-shared-name')} + /> + + {type && ( + <> + {configMap[type] && + configMap[type].fields.map((field) => { + return ( + + + {t(field.label)} + + + + ); + })} + > + )} + + {t('bui-slc-create-config')} + + + + + > + ); +}; diff --git a/internal-ui/src/security-logs-config/SecurityLogsConfigDelete.tsx b/internal-ui/src/security-logs-config/SecurityLogsConfigDelete.tsx new file mode 100644 index 000000000..9b8c6828d --- /dev/null +++ b/internal-ui/src/security-logs-config/SecurityLogsConfigDelete.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react'; +import { useTranslation } from 'next-i18next'; + +import { ButtonDanger, ConfirmationModal } from '../shared'; +import { useRouter } from '../hooks'; +import { ApiResponse } from '../types'; + +export const SecurityLogsConfigDelete = ({ + id, + urls, + onError, + onSuccess, +}: { + id: string; + urls: { deleteById: (id: string) => string; listConfigs: string }; + onError: (string) => void; + onSuccess: (string) => void; +}) => { + const { t } = useTranslation('common'); + const { router } = useRouter(); + + const [delModalVisible, setDelModalVisible] = useState(false); + + const deleteApp = async () => { + const rawResponse = await fetch(urls.deleteById(id), { + method: 'DELETE', + }); + + const response: ApiResponse = await rawResponse.json(); + + if ('error' in response) { + onError(response.error.message); + return; + } + + if ('data' in response) { + onSuccess(t('bui-slc-delete-success')); + router?.replace(urls.listConfigs); + } + }; + + return ( + <> + + + {t('bui-slc-delete-confirmation')} + {t('bui-slc-logs-noop')} + + { + setDelModalVisible(true); + }}> + {t('bui-shared-delete')} + + + { + setDelModalVisible(false); + }} + /> + > + ); +}; diff --git a/internal-ui/src/security-logs-config/SecurityLogsConfigEdit.tsx b/internal-ui/src/security-logs-config/SecurityLogsConfigEdit.tsx new file mode 100644 index 000000000..ea2fa2bb4 --- /dev/null +++ b/internal-ui/src/security-logs-config/SecurityLogsConfigEdit.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { useTranslation } from 'next-i18next'; +import type { SecurityLogsConfig } from '@boxyhq/saml-jackson'; + +import { fetcher } from '../utils'; +import { ButtonPrimary, LinkBack, Loading } from '../shared'; +import { SinkConfigMapField, getFieldsFromSinkType } from './lib'; +import { Error } from '../shared'; +import { ApiError, ApiResponse, ApiSuccess } from '../types'; +import { useRouter } from '../hooks'; +import { SecurityLogsConfigDelete } from './SecurityLogsConfigDelete'; + +export const SecurityLogsConfigEdit = ({ + id, + urls, + onError, + onSuccess, +}: { + id: string; + urls: { + getById: (id: string) => string; + updateById: (id: string) => string; + deleteById: (id: string) => string; + listConfigs: string; + }; + onError: (error: string) => void; + onSuccess: (message: string) => void; +}) => { + const { t } = useTranslation('common'); + const { router } = useRouter(); + + const [loading, setLoading] = useState(false); + const [config, setConfig] = useState({}); + const [fields, setFields] = useState([]); + + const { data, error, isLoading } = useSWR, ApiError>( + urls.getById(id), + fetcher, + { + revalidateOnFocus: false, + } + ); + + useEffect(() => { + if (data) { + setConfig(data.data?.config); + setFields(getFieldsFromSinkType(data.data?.type) || []); + } + }, [data]); + + if (error) { + ; + return null; + } + + if (isLoading) { + return ; + } + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + setLoading(true); + + const rawResponse = await fetch(urls.updateById(id), { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + config, + type: data?.data?.type, + }), + }); + + setLoading(false); + + const response: ApiResponse = await rawResponse.json(); + + if ('error' in response) { + if (response?.error?.message) { + onError(response.error.message); + return; + } + } + + if ('data' in response) { + onSuccess(t('bui-slc-update-success')); + router?.replace(urls.listConfigs); + } + }; + + const onChange = (event: React.ChangeEvent) => { + const target = event.target as HTMLInputElement; + + setConfig({ + ...config, + [target.id]: target.value, + }); + }; + + return ( + <> + + + {t('bui-slc-update')} + + + + + {fields.map((field) => { + return ( + + + {t(field.label)} + + + + ); + })} + + + {t('bui-shared-save-changes')} + + + + + + + > + ); +}; diff --git a/internal-ui/src/security-logs-config/SecurityLogsConfigs.tsx b/internal-ui/src/security-logs-config/SecurityLogsConfigs.tsx new file mode 100644 index 000000000..00b4e7e62 --- /dev/null +++ b/internal-ui/src/security-logs-config/SecurityLogsConfigs.tsx @@ -0,0 +1,233 @@ +import { useEffect, useState } from 'react'; +import type { SecurityLogsConfig } from '@boxyhq/saml-jackson'; +import useSWR from 'swr'; +import { ConfirmationModal, EmptyState, LinkPrimary, Loading, pageLimit, Pagination, Table } from '../shared'; +import { useTranslation } from 'next-i18next'; +import PencilIcon from '@heroicons/react/24/outline/PencilIcon'; +import PlusIcon from '@heroicons/react/24/outline/PlusIcon'; + +import { addQueryParamsToPath, fetcher } from '../utils'; +import { usePaginate, useRouter } from '../hooks'; +import { TableBodyType } from '../shared/Table'; +import { ApiError, ApiSuccess } from '../types'; +import { getDisplayTypeFromSinkType } from './lib'; +import { Error } from '../shared'; +import TrashIcon from '@heroicons/react/24/outline/TrashIcon'; + +export const SecurityLogsConfigs = ({ + urls, + skipColumns, + onError, + onSuccess, +}: { + urls: { + listConfigs: string; + createConfig: string; + editById: (id: string) => string; + deleteById?: (id: string) => string; + }; + onSuccess?: (id: string) => void; + onError?: (message: string) => void; + skipColumns?: string[]; +}) => { + const { t } = useTranslation('common'); + const { router } = useRouter(); + + const [connection, setConnection] = useState(null); + const [delModalVisible, setDelModalVisible] = useState(false); + const { paginate, setPaginate, pageTokenMap, setPageTokenMap } = usePaginate(router!); + + const params = { + pageOffset: paginate.offset, + pageLimit: pageLimit, + }; + + // For DynamoDB + if (paginate.offset > 0 && pageTokenMap[paginate.offset - pageLimit]) { + params['pageToken'] = pageTokenMap[paginate.offset - pageLimit]; + } + + const getConfigsUrl = addQueryParamsToPath(urls.listConfigs, params); + + const { data, error, isLoading, mutate } = useSWR, ApiError>( + getConfigsUrl, + fetcher + ); + + // Delete Splunk Connection + const deleteSplunkConnection = async () => { + const response = await fetch(urls.deleteById!(connection!), { + method: 'DELETE', + }); + const data = await response.json(); + if (data?.data) { + mutate(); + setConnection(null); + setDelModalVisible(false); + onSuccess!(t('bui-slc-delete-success')); + } else { + onError!(data?.error); + } + }; + + const nextPageToken = data?.pageToken; + + // store the nextPageToken against the pageOffset + useEffect(() => { + if (nextPageToken) { + setPageTokenMap((tokenMap) => ({ ...tokenMap, [paginate.offset]: nextPageToken })); + } + }, [nextPageToken, paginate.offset]); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + const configs = data?.data || []; + const noConfigs = configs.length === 0 && paginate.offset === 0; + const noMoreResults = configs.length === 0 && paginate.offset > 0; + + let columns = [ + { + key: 'name', + label: t('bui-shared-name'), + wrap: true, + dataIndex: 'name', + }, + { + key: 'type', + label: t('bui-shared-type'), + wrap: true, + dataIndex: 'type', + }, + { + key: 'tenant', + label: t('bui-shared-tenant'), + wrap: true, + dataIndex: 'tenant', + }, + { + key: 'endpoint', + label: t('bui-shared-endpoint'), + wrap: true, + dataIndex: 'config.endpoint', + }, + { + key: 'actions', + label: t('bui-shared-actions'), + wrap: true, + dataIndex: null, + }, + ]; + + if (skipColumns) { + columns = columns.filter((column) => !skipColumns.includes(column.key)); + } + + const cols = columns.map(({ label }) => label); + + const body: TableBodyType[] = configs.map((config) => { + return { + id: config.id, + cells: columns.map((column) => { + const dataIndex = column.dataIndex as string; + + if (dataIndex === null) { + return { + actions: urls.deleteById + ? [ + { + text: t('bui-shared-edit'), + onClick: () => router?.replace(urls.editById(config.id)), + icon: , + }, + { + color: 'error', + text: t('bui-shared-delete'), + icon: , + onClick: () => { + setConnection(config.id); + setDelModalVisible(true); + }, + }, + ] + : [ + { + text: t('bui-shared-edit'), + onClick: () => router?.replace(urls.editById(config.id)), + icon: , + }, + ], + }; + } else if (dataIndex.indexOf('.') !== -1) { + const keys = dataIndex.split('.'); + const retValue = { + wrap: column.wrap, + text: config, + }; + for (let i = 0; i < keys.length; i++) { + retValue.text = retValue.text ? retValue.text[keys[i]] : ''; + } + return retValue; + } else if (dataIndex === 'type') { + return { + wrap: column.wrap, + text: getDisplayTypeFromSinkType(config.type), + }; + } else { + return { + wrap: column.wrap, + text: config[dataIndex], + }; + } + }), + }; + }); + + return ( + <> + + {t('bui-slc')} + + + {t('bui-slc-new')} + + + + {noConfigs ? ( + <> + + > + ) : ( + <> + + { + setPaginate({ + offset: paginate.offset - pageLimit, + }); + }} + onNextClick={() => { + setPaginate({ + offset: paginate.offset + pageLimit, + }); + }} + /> + deleteSplunkConnection()} + onCancel={() => setDelModalVisible(false)} + /> + > + )} + > + ); +}; diff --git a/internal-ui/src/security-logs-config/index.ts b/internal-ui/src/security-logs-config/index.ts new file mode 100644 index 000000000..406a48c50 --- /dev/null +++ b/internal-ui/src/security-logs-config/index.ts @@ -0,0 +1,10 @@ +export { SecurityLogsConfigs } from './SecurityLogsConfigs'; +export { SecurityLogsConfigCreate } from './SecurityLogsConfigCreate'; +export { SecurityLogsConfigEdit } from './SecurityLogsConfigEdit'; +export { + configMap, + getDisplayTypeFromSinkType, + getFieldsFromSinkType, + getSecurityLogsConfigTypes, +} from './lib'; +export type { SinkConfigMap, SinkConfigMapField, SecurityLogsType } from './lib'; diff --git a/internal-ui/src/security-logs-config/lib.ts b/internal-ui/src/security-logs-config/lib.ts new file mode 100644 index 000000000..726021332 --- /dev/null +++ b/internal-ui/src/security-logs-config/lib.ts @@ -0,0 +1,59 @@ +export type SecurityLogsType = 'splunk_hec_logs'; + +export type SinkConfigMapField = { + index: number; + label: string; + name: string; + type: string; + placeholder: string; +}; + +export type SinkConfigMap = { + [key: string]: { + type: SecurityLogsType; + fields: SinkConfigMapField[]; + }; +}; + +export const configMap: { + [key: string]: { + type: SecurityLogsType; + fields: SinkConfigMapField[]; + }; +} = { + Splunk: { + type: 'splunk_hec_logs', + fields: [ + { + index: 1, + label: 'bui-splunk-collector-url', + name: 'endpoint', + type: 'string', + placeholder: 'bui-splunk-hec-endpoint-placeholder', + }, + { + index: 2, + label: 'bui-default-token', + name: 'default_token', + type: 'string', + placeholder: 'bui-default-token-placeholder', + }, + ], + }, +}; + +export const getDisplayTypeFromSinkType = (type: string): string | undefined => { + return Object.keys(configMap).find((key) => configMap[key].type === type); +}; + +export const getFieldsFromSinkType = (type: string): SinkConfigMapField[] | undefined => { + const key = getDisplayTypeFromSinkType(type); + if (!key) { + return undefined; + } + return configMap[key].fields; +}; + +export const getSecurityLogsConfigTypes = (): string[] => { + return Object.keys(configMap).map((key) => configMap[key].type); +}; diff --git a/internal-ui/src/types.ts b/internal-ui/src/types.ts index 6bb953f79..6695bc9c4 100644 --- a/internal-ui/src/types.ts +++ b/internal-ui/src/types.ts @@ -5,6 +5,8 @@ export interface ApiError extends Error { status: number; } +export type ApiResponse = ApiSuccess | { error: ApiError }; + enum DirectorySyncProviders { 'azure-scim-v2' = 'Azure SCIM v2.0', 'onelogin-scim-v2' = 'OneLogin SCIM v2.0', diff --git a/lib/env.ts b/lib/env.ts index 80aa50aec..3f6ccc597 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -23,6 +23,8 @@ const retraced = { hostUrl: process.env.RETRACED_HOST_URL, externalUrl: process.env.RETRACED_EXTERNAL_URL || process.env.RETRACED_HOST_URL, adminToken: process.env.RETRACED_ADMIN_ROOT_TOKEN, + apiKey: process.env.RETRACED_API_KEY, + projectId: process.env.RETRACED_PROJECT_ID, }; // Terminus @@ -129,3 +131,5 @@ export { retraced as retracedOptions }; export { terminus as terminusOptions }; export { apiKeys }; export { jacksonOptions }; + +export const auditLogEnabledGroup = process.env.AUDIT_LOG_TEAMS ? process.env.AUDIT_LOG_TEAMS.split(',') : []; diff --git a/locales/en/common.json b/locales/en/common.json index c229b9b44..f25bcb206 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -115,11 +115,14 @@ "setup-link-regenerated": "The setup link regenerated.", "setup-link-copied": "The setup link copied to the clipboard.", "setup-link-deleted": "The setup link deleted.", + "bui-default-token": "Default Token", + "bui-default-token-placeholder": "Token generated by splunk for HEC", "bui-shared-name": "Name", + "bui-shared-select-type": "Select a type", + "bui-shared-type": "Type", "bui-shared-tenant": "Tenant", "bui-shared-product": "Product", "bui-shared-actions": "Actions", - "bui-shared-type": "Type", "bui-shared-edit": "Edit", "bui-shared-save-changes": "Save Changes", "bui-shared-no-more-results": "No more results found", @@ -138,6 +141,7 @@ "bui-shared-close": "Close", "bui-shared-copy": "Copy", "bui-shared-active": "Active", + "bui-shared-endpoint": "Endpoint", "bui-shared-email": "Email", "bui-shared-logo-url": "Logo URL", "bui-shared-logo-url-desc": "Provide a URL to your logo. Recommend PNG or SVG formats. The image will be capped to a maximum height of 56px.", @@ -145,6 +149,21 @@ "bui-shared-favicon-url-desc": "Provide a URL to your favicon. Recommend PNG, SVG, or ICO formats.", "bui-shared-primary-color": "Primary Color", "bui-shared-primary-color-desc": "Primary color will be applied to buttons, links, and other elements.", + "bui-slc": "Security Logs Configurations", + "bui-slc-add": "Add Security Logs Configuration", + "bui-slc-create-config": "Create Configuration", + "bui-slc-delete": "Delete the Security logs config?", + "bui-slc-delete-confirmation": "Delete this Security Logs Configuration", + "bui-slc-delete-modal-confirmation": "This action cannot be undone. This will permanently delete the Configuration.", + "bui-slc-delete-success": "Security Logs Configuration deleted successfully", + "bui-slc-empty": "No Security Logs Configuration found.", + "bui-slc-logs-noop": "Security logs won't be sent to this destination.", + "bui-slc-new": "New Configuration", + "bui-slc-new-success": "Security logs config created successfully.", + "bui-slc-update": "Update Security Logs Configuration", + "bui-slc-update-success": "Security logs configuration updated successfully.", + "bui-splunk-collector-url": "Splunk HTTP Event Collector Url", + "bui-splunk-hec-endpoint-placeholder": "https://splunk.example.com:8088", "bui-wku-heading": "Here are the set of URIs you would need access to:", "bui-wku-idp-configuration-links": "Identity Provider Configuration links", "bui-wku-desc-idp-configuration": "Links for SAML/OIDC IdP setup", diff --git a/npm/src/controller/oauth.ts b/npm/src/controller/oauth.ts index 882d89c6b..cc75b9bad 100644 --- a/npm/src/controller/oauth.ts +++ b/npm/src/controller/oauth.ts @@ -18,6 +18,7 @@ import type { Storable, SAMLSSORecord, OIDCSSORecord, + FederatedSAMLProfile, SSOTracerInstance, OAuthErrorHandlerParams, OIDCAuthzResponsePayload, @@ -556,9 +557,12 @@ export class OAuthController implements IOAuthController { } } - public async samlResponse( - body: SAMLResponsePayload - ): Promise<{ redirect_url?: string; app_select_form?: string; response_form?: string }> { + public async samlResponse(body: SAMLResponsePayload): Promise<{ + redirect_url?: string; + app_select_form?: string; + response_form?: string; + profile?: FederatedSAMLProfile; + }> { let connection: SAMLSSORecord | undefined; let rawResponse: string | undefined; let sessionId: string | undefined; @@ -716,11 +720,18 @@ export class OAuthController implements IOAuthController { // This is a federated SAML flow, let's create a new SAMLResponse and POST it to the SP if (isSAMLFederated) { + const userProfile = { + email: profile.claims.email, + firstName: profile.claims.firstName, + lastName: profile.claims.lastName, + requested: session.requested, + }; + const { responseForm } = await this.ssoHandler.createSAMLResponse({ profile, session }); await this.sessionStore.delete(sessionId); - return { response_form: responseForm }; + return { response_form: responseForm, profile: userProfile }; } const code = await this._buildAuthorizationCode(connection, profile, session, isIdPFlow); diff --git a/npm/src/controller/utils.ts b/npm/src/controller/utils.ts index 4a74bf92f..06322dc35 100644 --- a/npm/src/controller/utils.ts +++ b/npm/src/controller/utils.ts @@ -31,6 +31,9 @@ export enum IndexNames { SetupToken = 'token', ProductService = 'productService', TenantProductService = 'tenantProductService', + + // For Security Logs Config + Tenant = 'tenant', } // The namespace prefix for the database store diff --git a/npm/src/ee/federated-saml/types.ts b/npm/src/ee/federated-saml/types.ts index a7c1c251c..009c3bde0 100644 --- a/npm/src/ee/federated-saml/types.ts +++ b/npm/src/ee/federated-saml/types.ts @@ -43,3 +43,10 @@ export type AppRequestParams = product: string; type?: string; }; + +export type FederatedSAMLProfile = { + email: string; + firstName: string; + lastName: string; + requested: Record; +}; diff --git a/npm/src/ee/security-logs/index.ts b/npm/src/ee/security-logs/index.ts new file mode 100644 index 000000000..ef21b3907 --- /dev/null +++ b/npm/src/ee/security-logs/index.ts @@ -0,0 +1,92 @@ +import { IndexNames } from '../../controller/utils'; +import type { Storable, JacksonOption } from '../../typings'; +import { throwIfInvalidLicense } from '../common/checkLicense'; +import { randomUUID } from 'crypto'; +import { SecurityLogsConfig, SecurityLogsConfigCreate } from './types'; + +export class SecurityLogsConfigController { + private store: Storable; + private opts: JacksonOption; + + constructor({ store, opts }: { store: Storable; opts: JacksonOption }) { + this.store = store; + this.opts = opts; + } + + public async createSecurityLogsConfig(params: SecurityLogsConfigCreate): Promise { + await throwIfInvalidLicense(this.opts.boxyhqLicenseKey); + + const id = randomUUID(); + const record = { + id, + name: params.name, + tenant: params.tenant, + type: params.type, + config: params.config, + }; + + await this.store.put(id, record, { + name: IndexNames.Tenant, + value: params.tenant, + }); + + return id; + } + + public async getAll(tenant: string, pageOffset?: number, pageLimit?: number, pageToken?: string) { + await throwIfInvalidLicense(this.opts.boxyhqLicenseKey); + + return tenant + ? await this.store.getByIndex( + { + name: IndexNames.Tenant, + value: tenant, + }, + pageOffset, + pageLimit, + pageToken + ) + : await this.store.getAll(pageOffset, pageLimit, pageToken); + } + + public async get(id: string): Promise { + await throwIfInvalidLicense(this.opts.boxyhqLicenseKey); + + return await this.store.get(id); + } + + public async update(id: string, config: any, name?: string): Promise { + await throwIfInvalidLicense(this.opts.boxyhqLicenseKey); + + const currentConfig = await this.get(id); + + if (!currentConfig) { + throw new Error(`Security logs config with id ${id} not found`); + } + + const newConfig = { + type: currentConfig.type, + tenant: currentConfig.tenant, + config: config ?? currentConfig.config, + name: name ?? currentConfig.name, + }; + + const updatedConfig = { + ...currentConfig, + ...newConfig, + }; + + await this.store.put(id, updatedConfig, { + name: IndexNames.Tenant, + value: updatedConfig.tenant, + }); + + return updatedConfig; + } + + public async delete(id: string): Promise { + await throwIfInvalidLicense(this.opts.boxyhqLicenseKey); + + await this.store.delete(id); + } +} diff --git a/npm/src/ee/security-logs/types.ts b/npm/src/ee/security-logs/types.ts new file mode 100644 index 000000000..63853f815 --- /dev/null +++ b/npm/src/ee/security-logs/types.ts @@ -0,0 +1,14 @@ +export type SecurityLogsConfigCreate = { + tenant: string; + name?: string; + config: any; + type: string; +}; + +export type SecurityLogsConfig = { + id: string; + name?: string; + tenant: string; + config: any; + type: string; +}; diff --git a/npm/src/index.ts b/npm/src/index.ts index 20579acc8..42bbee671 100644 --- a/npm/src/index.ts +++ b/npm/src/index.ts @@ -19,6 +19,7 @@ import { BrandingController } from './ee/branding'; import SSOTracer from './sso-tracer'; import EventController from './event'; import { ProductController } from './ee/product'; +import { SecurityLogsConfigController } from './ee/security-logs'; import { OryController } from './ee/ory/ory'; const tracerTTL = 7 * 24 * 60 * 60; @@ -73,6 +74,7 @@ export const controllers = async ( spConfig: SPSSOConfig; samlFederatedController: ISAMLFederationController; brandingController: IBrandingController; + securityLogsConfigController: ISecurityLogsConfigController; checkLicense: () => Promise; productController: ProductController; close: () => Promise; @@ -89,6 +91,7 @@ export const controllers = async ( const setupLinkStore = db.store('setup:link'); const certificateStore = db.store('x509:certificates'); const settingsStore = db.store('portal:settings'); + const securityLogsConfigStore = db.store('security:logs:config'); const productStore = db.store('product:config'); const tracerStore = db.store('saml:tracer', tracerTTL); @@ -114,6 +117,7 @@ export const controllers = async ( // Enterprise Features const samlFederatedController = await initFederatedSAML({ db, opts, ssoTracer }); const brandingController = new BrandingController({ store: settingsStore, opts }); + const securityLogsConfig = new SecurityLogsConfigController({ store: securityLogsConfigStore, opts }); const oauthController = new OAuthController({ connectionStore, @@ -185,6 +189,7 @@ export const controllers = async ( return checkLicense(opts.boxyhqLicenseKey); }, productController, + securityLogsConfigController: securityLogsConfig, close: async () => { await db.close(); }, @@ -195,6 +200,8 @@ export default controllers; export * from './typings'; export * from './ee/federated-saml/types'; +export * from './ee/security-logs/types'; export type SAMLJackson = Awaited>; export type ISetupLinkController = InstanceType; export type IBrandingController = InstanceType; +export type ISecurityLogsConfigController = InstanceType; diff --git a/package-lock.json b/package-lock.json index 90896edff..1b616aca4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@boxyhq/metrics": "0.2.6", "@boxyhq/react-ui": "3.3.42", "@boxyhq/saml-jackson": "file:npm", + "@boxyhq/security-logs-sink": "file:./ee/security-sinks", "@heroicons/react": "2.1.3", "@retracedhq/logs-viewer": "2.7.1", "@retracedhq/retraced": "0.7.8", @@ -42,6 +43,7 @@ "react-syntax-highlighter": "15.5.0", "react-tagsinput": "3.20.3", "remark-gfm": "3.0.1", + "request-ip": "3.3.0", "sharp": "0.33.3", "swr": "2.2.5" }, @@ -75,6 +77,18 @@ "npm": ">=10" } }, + "ee/security-sinks": { + "name": "@boxyhq/security-sinks", + "version": "0.0.0", + "license": "ISC", + "dependencies": { + "axios": "1.6.8" + }, + "engines": { + "node": ">=16", + "npm": ">=8" + } + }, "internal-ui": { "name": "@boxyhq/internal-ui", "version": "0.0.0", @@ -2082,6 +2096,10 @@ "xmlbuilder": "15.1.1" } }, + "node_modules/@boxyhq/security-logs-sink": { + "resolved": "ee/security-sinks", + "link": true + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -6340,17 +6358,6 @@ "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" }, - "node_modules/@types/whatwg-url": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", - "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", - "devOptional": true, - "peer": true, - "dependencies": { - "@types/node": "*", - "@types/webidl-conversions": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.3.1.tgz", @@ -7301,16 +7308,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/bson": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/bson/-/bson-5.5.1.tgz", - "integrity": "sha512-ix0EwukN2EpC0SRWIj/7B5+A6uQMQy6KMREI9qQqvgpkV2frH63T0UDVd1SYedL6dNCmDBYB3QtXi4ISk9YT+g==", - "devOptional": true, - "peer": true, - "engines": { - "node": ">=14.20.1" - } - }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -15938,86 +15935,6 @@ "obliterator": "^1.6.1" } }, - "node_modules/mongodb": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz", - "integrity": "sha512-H60HecKO4Bc+7dhOv4sJlgvenK4fQNqqUIlXxZYQNbfEWSALGAwGoyJd/0Qwk4TttFXUOHJ2ZJQe/52ScaUwtQ==", - "devOptional": true, - "peer": true, - "dependencies": { - "bson": "^5.5.0", - "mongodb-connection-string-url": "^2.6.0", - "socks": "^2.7.1" - }, - "engines": { - "node": ">=14.20.1" - }, - "optionalDependencies": { - "@mongodb-js/saslprep": "^1.1.0" - }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.188.0", - "@mongodb-js/zstd": "^1.0.0", - "kerberos": "^1.0.0 || ^2.0.0", - "mongodb-client-encryption": ">=2.3.0 <3", - "snappy": "^7.2.2" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "snappy": { - "optional": true - } - } - }, - "node_modules/mongodb-connection-string-url": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", - "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", - "devOptional": true, - "peer": true, - "dependencies": { - "@types/whatwg-url": "^8.2.1", - "whatwg-url": "^11.0.0" - } - }, - "node_modules/mongodb-connection-string-url/node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "devOptional": true, - "peer": true, - "dependencies": { - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/mongodb-connection-string-url/node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "devOptional": true, - "peer": true, - "dependencies": { - "tr46": "^3.0.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -19755,6 +19672,11 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/request-ip": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/request-ip/-/request-ip-3.3.0.tgz", + "integrity": "sha512-cA6Xh6e0fDBBBwH77SLJaJPBmD3nWVAcF9/XAcsrIHdjhFzFiB5aNQFytdjCGPezU3ROwrR11IddKAM08vohxA==" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/package.json b/package.json index e636826c8..26b1fe2bc 100644 --- a/package.json +++ b/package.json @@ -42,9 +42,10 @@ "start": "cross-env PORT=5225 NODE_OPTIONS=--dns-result-order=ipv4first node .next/standalone/server.js", "swagger-jsdoc": "swagger-jsdoc -d swagger/swagger-definition.js npm/src/**/*.ts npm/src/**/**/*.ts -o swagger/swagger.json", "redis": "cross-env JACKSON_API_KEYS=secret DB_ENGINE=redis DB_TYPE=redis DB_URL=redis://localhost:6379/redis npm run dev", - "prepare": "npm run prepare:npm && npm run prepare:internal-ui", + "prepare": "npm run prepare:npm && npm run prepare:internal-ui && npm run prepare:security-sinks", "prepare:npm": "cd npm && npm install --legacy-peer-deps", "prepare:internal-ui": "cd internal-ui && npm install --legacy-peer-deps", + "prepare:security-sinks": "cd ee/security-sinks && npm install", "pretest:e2e": "env-cmd -f .env.test.local ts-node --logError e2e/support/pretest.ts", "test:e2e": "env-cmd -f .env.test.local playwright test", "test": "cd npm && npm run test", @@ -65,6 +66,7 @@ "@boxyhq/metrics": "0.2.6", "@boxyhq/react-ui": "3.3.42", "@boxyhq/saml-jackson": "file:npm", + "@boxyhq/security-logs-sink": "file:./ee/security-sinks", "@heroicons/react": "2.1.3", "@retracedhq/logs-viewer": "2.7.1", "@retracedhq/retraced": "0.7.8", @@ -93,6 +95,7 @@ "react-syntax-highlighter": "15.5.0", "react-tagsinput": "3.20.3", "remark-gfm": "3.0.1", + "request-ip": "3.3.0", "sharp": "0.33.3", "swr": "2.2.5" }, diff --git a/pages/admin/settings/security-logs/[id].tsx b/pages/admin/settings/security-logs/[id].tsx new file mode 100644 index 000000000..738d7ca3f --- /dev/null +++ b/pages/admin/settings/security-logs/[id].tsx @@ -0,0 +1,16 @@ +import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; + +import jackson from '@lib/jackson'; + +export { default } from 'ee/security-logs-config/pages/edit'; + +export async function getServerSideProps({ locale }) { + const { checkLicense } = await jackson(); + + return { + props: { + ...(await serverSideTranslations(locale, ['common'])), + hasValidLicense: await checkLicense(), + }, + }; +} diff --git a/pages/admin/settings/security-logs/index.tsx b/pages/admin/settings/security-logs/index.tsx new file mode 100644 index 000000000..afaba3dc4 --- /dev/null +++ b/pages/admin/settings/security-logs/index.tsx @@ -0,0 +1,16 @@ +import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; + +import jackson from '@lib/jackson'; + +export { default } from 'ee/security-logs-config/pages/index'; + +export async function getServerSideProps({ locale }) { + const { checkLicense } = await jackson(); + + return { + props: { + ...(await serverSideTranslations(locale, ['common'])), + hasValidLicense: await checkLicense(), + }, + }; +} diff --git a/pages/admin/settings/security-logs/new.tsx b/pages/admin/settings/security-logs/new.tsx new file mode 100644 index 000000000..dc3f1651e --- /dev/null +++ b/pages/admin/settings/security-logs/new.tsx @@ -0,0 +1,16 @@ +import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; + +import jackson from '@lib/jackson'; + +export { default } from 'ee/security-logs-config/pages/new'; + +export async function getServerSideProps({ locale }) { + const { checkLicense } = await jackson(); + + return { + props: { + ...(await serverSideTranslations(locale, ['common'])), + hasValidLicense: await checkLicense(), + }, + }; +} diff --git a/pages/api/admin/connections/index.ts b/pages/api/admin/connections/index.ts index b23bc4b11..3ae7b9c01 100644 --- a/pages/api/admin/connections/index.ts +++ b/pages/api/admin/connections/index.ts @@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import jackson from '@lib/jackson'; import { oidcMetadataParse, parsePaginateApiParams, strategyChecker } from '@lib/utils'; import { adminPortalSSODefaults } from '@lib/env'; +import retraced from '@ee/retraced'; import { defaultHandler } from '@lib/api'; import { ApiError } from '@lib/error'; @@ -62,12 +63,33 @@ const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => { // Create SAML connection if (isSAML) { const connection = await connectionAPIController.createSAMLConnection(req.body); + + retraced.reportAdminPortalEvent({ + action: 'sso.connection.create', + crud: 'c', + req, + target: { + id: connection.clientID, + type: 'SAML Connection', + }, + }); + res.status(201).json({ data: connection }); } - // Create OIDC connection else { const connection = await connectionAPIController.createOIDCConnection(oidcMetadataParse(req.body)); + + retraced.reportAdminPortalEvent({ + action: 'sso.connection.create', + crud: 'c', + req, + target: { + id: connection.clientID, + type: 'OIDC Connection', + }, + }); + res.status(201).json({ data: connection }); } }; @@ -85,12 +107,33 @@ const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => { // Update SAML connection if (isSAML) { await connectionAPIController.updateSAMLConnection(req.body); + + retraced.reportAdminPortalEvent({ + action: 'sso.connection.update', + crud: 'u', + req, + target: { + id: req.body.clientID, + type: 'SAML Connection', + }, + }); + res.status(204).end(); } - // Update OIDC connection else { await connectionAPIController.updateOIDCConnection(oidcMetadataParse(req.body)); + + retraced.reportAdminPortalEvent({ + action: 'sso.connection.update', + crud: 'u', + req, + target: { + id: req.body.clientID, + type: 'OIDC Connection', + }, + }); + res.status(204).end(); } }; @@ -106,6 +149,15 @@ const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => { await connectionAPIController.deleteConnections({ clientID, clientSecret }); + retraced.reportAdminPortalEvent({ + action: 'sso.connection.delete', + crud: 'd', + req, + target: { + id: clientID, + }, + }); + res.json({ data: null }); }; diff --git a/pages/api/admin/directory-sync/[directoryId]/events/index.ts b/pages/api/admin/directory-sync/[directoryId]/events/index.ts index 2789328fb..14fd0b24a 100644 --- a/pages/api/admin/directory-sync/[directoryId]/events/index.ts +++ b/pages/api/admin/directory-sync/[directoryId]/events/index.ts @@ -1,5 +1,6 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import jackson from '@lib/jackson'; +import retraced from '@ee/retraced'; import { defaultHandler } from '@lib/api'; import { ApiError } from '@lib/error'; import { parsePaginateApiParams } from '@lib/utils'; @@ -57,6 +58,15 @@ const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => { .setTenantAndProduct(directory.tenant, directory.product) .deleteAll(directoryId); + retraced.reportAdminPortalEvent({ + action: 'dsync.webhook_event.delete', + crud: 'd', + req, + target: { + id: directoryId, + }, + }); + res.json({ data: null }); }; diff --git a/pages/api/admin/directory-sync/[directoryId]/index.ts b/pages/api/admin/directory-sync/[directoryId]/index.ts index 417df9f69..f4e208d7f 100644 --- a/pages/api/admin/directory-sync/[directoryId]/index.ts +++ b/pages/api/admin/directory-sync/[directoryId]/index.ts @@ -1,5 +1,6 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import jackson from '@lib/jackson'; +import retraced from '@ee/retraced'; import { defaultHandler } from '@lib/api'; import { ApiError } from '@lib/error'; @@ -19,6 +20,17 @@ const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => { const { data, error } = await directorySyncController.directories.update(directoryId, req.body); + if (data) { + retraced.reportAdminPortalEvent({ + action: 'dsync.connection.update', + crud: 'u', + req, + target: { + id: directoryId, + }, + }); + } + if (error) { throw new ApiError(error.message, error.code); } @@ -53,6 +65,15 @@ const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => { throw new ApiError(error.message, error.code); } + retraced.reportAdminPortalEvent({ + action: 'dsync.connection.delete', + crud: 'd', + req, + target: { + id: directoryId, + }, + }); + res.json({ data: null }); }; diff --git a/pages/api/admin/directory-sync/index.ts b/pages/api/admin/directory-sync/index.ts index bca06281f..c8c604b56 100644 --- a/pages/api/admin/directory-sync/index.ts +++ b/pages/api/admin/directory-sync/index.ts @@ -1,6 +1,7 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import type { DirectoryType } from '@boxyhq/saml-jackson'; import jackson from '@lib/jackson'; +import retraced from '@ee/retraced'; import { defaultHandler } from '@lib/api'; import { ApiError } from '@lib/error'; import { parsePaginateApiParams } from '@lib/utils'; @@ -27,6 +28,16 @@ const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => { google_domain, }); + if (data) { + retraced.reportAdminPortalEvent({ + action: 'dsync.connection.create', + crud: 'c', + req, + target: { + id: data.id, + }, + }); + } if (error) { throw new ApiError(error.message, error.code); } diff --git a/pages/api/admin/retraced/projects/index.ts b/pages/api/admin/retraced/projects/index.ts index 4d7658a3f..c1363f7fe 100644 --- a/pages/api/admin/retraced/projects/index.ts +++ b/pages/api/admin/retraced/projects/index.ts @@ -4,6 +4,7 @@ import axios from 'axios'; import type { Project } from 'types/retraced'; import { getToken } from '@lib/retraced'; import { retracedOptions } from '@lib/env'; +import retraced from '@ee/retraced'; import { defaultHandler } from '@lib/api'; async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -30,6 +31,15 @@ const createProject = async (req: NextApiRequest, res: NextApiResponse) => { } ); + retraced.reportAdminPortalEvent({ + action: 'retraced.project.create', + crud: 'c', + req, + target: { + id: data.project.id, + }, + }); + res.status(201).json({ data, }); diff --git a/pages/api/admin/security-logs-config/[id]/index.ts b/pages/api/admin/security-logs-config/[id]/index.ts new file mode 100644 index 000000000..c52395238 --- /dev/null +++ b/pages/api/admin/security-logs-config/[id]/index.ts @@ -0,0 +1 @@ +export { default } from 'ee/security-logs-config/api/[id]/index'; diff --git a/pages/api/admin/security-logs-config/index.ts b/pages/api/admin/security-logs-config/index.ts new file mode 100644 index 000000000..9667fd641 --- /dev/null +++ b/pages/api/admin/security-logs-config/index.ts @@ -0,0 +1 @@ +export { default } from 'ee/security-logs-config/api/index'; diff --git a/pages/api/admin/setup-links/index.ts b/pages/api/admin/setup-links/index.ts index 798433c96..92db85b4e 100644 --- a/pages/api/admin/setup-links/index.ts +++ b/pages/api/admin/setup-links/index.ts @@ -1,5 +1,7 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import jackson from '@lib/jackson'; +import retraced from '@ee/retraced'; +import type { SetupLinkService } from '@boxyhq/saml-jackson'; import { defaultHandler } from '@lib/api'; import { ApiError } from '@lib/error'; import { parsePaginateApiParams } from '@lib/utils'; @@ -18,6 +20,17 @@ const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => { const setupLink = await setupLinkController.create(req.body); + const { service } = req.body as { service: SetupLinkService }; + + retraced.reportAdminPortalEvent({ + action: `${service}.setuplink.create`, + crud: 'c', + req, + target: { + id: setupLink.setupID, + }, + }); + res.status(201).json({ data: setupLink }); }; @@ -26,8 +39,18 @@ const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => { const { id } = req.query as { id: string }; + const setupLink = await setupLinkController.get(id); await setupLinkController.remove({ id }); + retraced.reportAdminPortalEvent({ + action: `${setupLink.service}.setuplink.delete`, + crud: 'd', + req, + target: { + id, + }, + }); + res.json({ data: {} }); }; diff --git a/pages/api/auth/[...nextauth].ts b/pages/api/auth/[...nextauth].ts index 553448f34..e17bd8477 100644 --- a/pages/api/auth/[...nextauth].ts +++ b/pages/api/auth/[...nextauth].ts @@ -7,6 +7,7 @@ import jackson from '@lib/jackson'; import { validateEmailWithACL } from '@lib/utils'; import { jacksonOptions as env } from '@lib/env'; import { sessionName } from '@lib/constants'; +import retraced from '@ee/retraced'; export default NextAuth({ theme: { @@ -168,4 +169,16 @@ export default NextAuth({ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore adapter: Adapter(), + events: { + async signIn({ user }): Promise { + retraced.reportAdminPortalEvent({ + action: 'portal.user.login', + crud: 'c', + actor: { + id: `${user.email}`, + name: `${user.name}`, + }, + }); + }, + }, }); diff --git a/pages/api/internals/product/[productId].ts b/pages/api/internals/product/[productId].ts index 0caf96e5f..56cac5113 100644 --- a/pages/api/internals/product/[productId].ts +++ b/pages/api/internals/product/[productId].ts @@ -1 +1 @@ -export { default } from 'ee/product/api/[productId]'; +export { default } from '@ee/product/api/[productId]'; diff --git a/pages/api/oauth/saml.ts b/pages/api/oauth/saml.ts index be0a861dc..77b6d71ac 100644 --- a/pages/api/oauth/saml.ts +++ b/pages/api/oauth/saml.ts @@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import jackson from '@lib/jackson'; import { setErrorCookie } from '@lib/utils'; +import retraced from '@ee/retraced'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { method } = req; @@ -20,7 +21,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }; // Handle SAML Response generated by IdP - const { redirect_url, app_select_form, response_form } = await oauthController.samlResponse({ + const { redirect_url, app_select_form, response_form, profile } = await oauthController.samlResponse({ SAMLResponse, RelayState, idp_hint, @@ -36,6 +37,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } if (response_form) { + if (profile) { + retraced.reportEvent({ + action: 'sso.user.login', + crud: 'c', + actor: { + id: profile.email, + name: `${profile.firstName} ${profile.lastName}`, + fields: { + tenant: profile.requested.tenant, + }, + }, + productId: profile.requested.product, + req, + }); + } + res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.send(response_form); } diff --git a/pages/api/oauth/userinfo.ts b/pages/api/oauth/userinfo.ts index fd1425872..37b01c4e8 100644 --- a/pages/api/oauth/userinfo.ts +++ b/pages/api/oauth/userinfo.ts @@ -1,6 +1,7 @@ import { NextApiRequest, NextApiResponse } from 'next'; import jackson from '@lib/jackson'; import { extractAuthToken } from '@lib/auth'; +import retraced from '@ee/retraced'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { try { @@ -27,6 +28,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const profile = await oauthController.userInfo(token); + retraced.reportEvent({ + action: 'sso.user.login', + crud: 'c', + actor: { + id: profile.email, + name: `${profile.firstName} ${profile.lastName}`, + fields: { + tenant: profile.requested.tenant, + }, + }, + productId: profile.requested.product, + req, + }); + res.json(profile); } catch (err: any) { console.error('userinfo error:', err); diff --git a/pages/api/v1/security-logs-config/[id]/index.ts b/pages/api/v1/security-logs-config/[id]/index.ts new file mode 100644 index 000000000..d630a0858 --- /dev/null +++ b/pages/api/v1/security-logs-config/[id]/index.ts @@ -0,0 +1 @@ +export { default } from '@ee/security-logs-config/api/[id]/index'; diff --git a/pages/api/v1/security-logs-config/index.ts b/pages/api/v1/security-logs-config/index.ts new file mode 100644 index 000000000..aebb37c6a --- /dev/null +++ b/pages/api/v1/security-logs-config/index.ts @@ -0,0 +1 @@ +export { default } from '@ee/security-logs-config/api/index'; diff --git a/pages/api/v1/security-logs/index.ts b/pages/api/v1/security-logs/index.ts new file mode 100644 index 000000000..5d8a41ca2 --- /dev/null +++ b/pages/api/v1/security-logs/index.ts @@ -0,0 +1 @@ +export { default } from '@ee/security-logs/api/index';
{t('bui-slc-logs-noop')}