Compare commits

...

93 Commits

Author SHA1 Message Date
Utkarsh Mehta aa361f6f22
Merge 6665967f20 into 5000983a36 2024-05-01 10:47:24 +05:30
Deepak Prabhakara 5000983a36 Release 1.23.6 2024-04-30 22:32:49 +01:00
ukrocks007 6665967f20 Merge branch 'main' into feature/splunk-direct-delivery 2024-04-17 14:32:46 +05:30
ukrocks007 e56c6ea43a Refactor security logs configuration imports and components 2024-04-15 16:32:12 +05:30
ukrocks007 0ec7b654db Refactor security logs configuration imports and components, and add new types and functions 2024-04-15 15:13:50 +05:30
ukrocks007 7964460084 Refactor security logs configuration imports and components 2024-04-15 15:07:02 +05:30
ukrocks007 60e7429f73 Fix error handling in SecurityLogsConfigCreate component and refactor security logs configuration imports and components 2024-04-11 17:00:56 +05:30
ukrocks007 da43d4781f Refactor security logs configuration URLs and functions 2024-04-11 16:43:54 +05:30
ukrocks007 08bbf80397 Refactor security logs configuration imports and components in SecurityLogsConfigEdit and index.tsx 2024-04-11 16:21:28 +05:30
ukrocks007 511bc19ce2 Fix error handling in SecurityLogsConfigCreate component 2024-04-10 23:55:28 +05:30
ukrocks007 e8b00acc64 Refactor security logs configuration imports and components 2024-04-10 19:14:48 +05:30
ukrocks007 418e41017e Refactor security logs configuration imports and components 2024-04-09 17:36:02 +05:30
ukrocks007 0e87280e09 Merge branch 'main' into feature/splunk-direct-delivery 2024-04-09 17:14:07 +05:30
ukrocks007 e2eaff0daa Update security logs configuration imports 2024-04-09 17:11:33 +05:30
ukrocks007 8c0c79478e Update exception list in check-locale.js 2024-04-09 16:49:43 +05:30
ukrocks007 4bc8d3d7b9 Refactor security logs configuration components and types 2024-04-09 16:45:38 +05:30
ukrocks007 5882bb8309 Refactor security logs configuration components and types 2024-04-09 16:36:43 +05:30
Deepak Prabhakara 508eec557b Merge branch 'release' into feature/splunk-direct-delivery
# Conflicts:
#	package-lock.json
2024-04-03 00:11:48 +01:00
ukrocks007 43a0d5534d Refactor code to use API key for admin portal routes and handle missing actor info in Retraced event 2024-04-02 18:39:58 +05:30
ukrocks007 1f51c3e561 Update dependencies and package.json configuration 2024-04-02 15:55:33 +05:30
ukrocks007 aeb2b441e7 Merge branch 'main' into feature/splunk-direct-delivery 2024-04-02 15:24:22 +05:30
ukrocks007 babfcbbf27 Update package.json to use src/index.ts instead of dist/index.js for main and typings 2024-04-01 22:02:06 +05:30
ukrocks007 d499887f7a Merge branch 'main' into feature/splunk-direct-delivery 2024-04-01 16:15:00 +05:30
ukrocks007 ffa506c41f Add API key creation and deletion functionality 2024-04-01 16:13:28 +05:30
ukrocks007 bf25eada7a Update axios dependency to version 1.6.8 2024-03-29 13:10:10 +05:30
ukrocks007 7746cd47aa Add verdaccio.sh script for publishing package to local registry 2024-03-28 23:13:26 +05:30
ukrocks007 cb23941914 Refactor UI components and update dependencies 2024-03-28 21:40:14 +05:30
ukrocks007 184851c1de Update dependencies and cleanup duplicate code 2024-03-28 21:09:41 +05:30
ukrocks007 4b872939e4 Merge branch 'main' into feature/splunk-direct-delivery 2024-03-28 21:09:19 +05:30
ukrocks007 62f9fcf78d Update import paths for product and security logs config APIs 2024-03-19 19:58:51 +05:30
ukrocks007 884995be71 Merge branch 'main' into feature/splunk-direct-delivery 2024-03-19 15:38:49 +05:30
Deepak Prabhakara a1951065a0 fixed docker build 2024-03-18 15:23:49 +00:00
Deepak Prabhakara 30e500329e fixed locale-check 2024-03-18 14:27:21 +00:00
Deepak Prabhakara 4688dfd87b locale strings fix 2024-03-18 12:07:28 +00:00
Deepak Prabhakara 828e5b2ea3 fixed merge issue related to oauth.ts 2024-03-18 12:02:24 +00:00
Deepak Prabhakara be0db6334b updated package-lock 2024-03-18 11:24:32 +00:00
ukrocks007 7826824b2d format 2024-03-18 15:43:33 +05:30
ukrocks007 b66da67a34 Refactor security logs configuration page to use internal UI Table component 2024-03-18 15:15:24 +05:30
ukrocks007 9f16beec05 Merge branch 'main' into feature/splunk-direct-delivery 2024-03-18 14:54:59 +05:30
ukrocks007 84b9b568df Merge branch main in feature/splunk-direct-delivery 2024-03-18 14:46:23 +05:30
ukrocks007 18aab06524 Update package.json build and main paths 2024-02-09 18:14:48 +05:30
ukrocks007 5b9d91b5ea Add prepare:security-sinks script to package.json 2024-02-09 15:38:42 +05:30
ukrocks007 ef2aebf3af Remove unused variable in OAuthController 2024-02-09 15:31:47 +05:30
ukrocks007 7b946146f2 Update package name in package-lock.json 2024-02-09 15:31:02 +05:30
ukrocks007 39ad0e44af Update tsconfig.json with downlevelIteration and ts-node configuration 2024-02-09 15:22:24 +05:30
ukrocks007 09879bb5c4 Add retry logic for sending events to Splunk HEC 2024-02-09 15:18:58 +05:30
ukrocks007 f8427d001d Merge branch 'main' into feature/splunk-direct-delivery 2024-02-09 15:13:58 +05:30
ukrocks007 0dbacae55a Update package.json paths for distribution files 2024-02-09 15:05:55 +05:30
ukrocks007 eb723cb39e Update npm install and publish for security sinks 2024-02-07 18:42:11 +05:30
ukrocks007 4c23cf8513 security-sinks package related changes 2024-02-06 20:22:12 +05:30
ukrocks007 a05f9b18e7 added package configs and sinks 2024-02-02 18:29:53 +05:30
ukrocks007 b67f9f3dbc Refactor import statements in security-logs/index.ts 2024-01-29 18:29:57 +05:30
ukrocks007 4b11c48e96 added pages and made the flow working 2024-01-29 18:26:25 +05:30
ukrocks007 8b2ce8cee9 added apis, controller, store for security logs config 2024-01-25 16:48:48 +05:30
Kiran K a79cf7c117 Remove console.log 2024-01-04 16:17:21 +05:30
Kiran K e0c7592f33 Merge branch 'main' into feat/audit-logs-saas-app 2024-01-04 16:11:18 +05:30
Kiran K 93e1199d87 Fix the build 2024-01-04 10:22:09 +05:30
Kiran K de01aed345 Merge branch 'main' into feat/audit-logs-saas-app 2024-01-04 10:14:55 +05:30
Kiran K bb119bbbb3 Add audit log teams configuration 2024-01-03 17:35:19 +05:30
Kiran K 48e731416f Merge branch 'main' into feat/audit-logs-saas-app 2024-01-03 16:55:06 +05:30
Kiran K ddb51c1b35 Remove console.log 2023-11-30 19:16:07 +05:30
Kiran K 71c66626a7 Update package-lock.json 2023-11-30 19:12:39 +05:30
Kiran K 726b7e79dc Merge branch 'main' into feat/audit-logs-saas-app 2023-11-30 19:12:34 +05:30
Kiran K 0b0b23e4a2 Add FederatedSAMLProfile to samlResponse method 2023-11-30 19:09:41 +05:30
Kiran K 116ad0da19 Fix import statement and update event CRUD 2023-11-29 12:43:22 +05:30
Kiran K 5d1c5cdf51 Fix optional parameters in reportAdminPortalEvent
function
2023-11-28 16:51:09 +05:30
Kiran K e498c9aa4c Add branding update event to retraced 2023-11-28 16:43:33 +05:30
Kiran K b88ca2eb04 Fix track events for API events 2023-11-28 16:20:05 +05:30
Kiran K 6228d71c7f Add retraced event logging for federated SAML API 2023-11-28 14:58:28 +05:30
Kiran K 1a623647cb Add retraced event tracking for setup link
operations
2023-11-28 14:52:43 +05:30
Kiran K 0097cfe9b0 Add retraced event to dsync 2023-11-28 14:34:04 +05:30
Kiran K 70d7a21a0f Merge branch 'feat/audit-logs-saas-app' of https://github.com/boxyhq/jackson into feat/audit-logs-saas-app 2023-11-28 14:25:51 +05:30
Kiran K f3bdf88d5b
Merge branch 'main' into feat/audit-logs-saas-app 2023-11-28 14:25:27 +05:30
Kiran K d2cd190ce1 Event reporting for
connections
2023-11-28 12:54:42 +05:30
Kiran K 681cf9537e Error handling 2023-11-27 13:17:09 +05:30
Kiran K e08671161d Add target and tenant fields 2023-11-27 12:49:41 +05:30
Kiran K 303dc81727
Merge branch 'main' into feat/audit-logs-saas-app 2023-11-27 11:13:55 +05:30
Kiran K ae1ee8343f Merge branch 'feat/audit-logs-saas-app' of https://github.com/boxyhq/jackson into feat/audit-logs-saas-app 2023-11-24 13:42:31 +05:30
Kiran K a06c76a2e5 Fix error handling in reportEvent function 2023-11-24 13:42:17 +05:30
Kiran K 141d91d620
Merge branch 'main' into feat/audit-logs-saas-app 2023-11-24 12:12:13 +05:30
Kiran K 85050d5adb Remove unused import 2023-11-24 12:10:16 +05:30
Kiran K aa9562a473 Add description to reportEvent function 2023-11-24 12:09:15 +05:30
Kiran K 1867c6e7f7 Cleanup type 2023-11-23 21:22:47 +05:30
Kiran K 533c7449a8 Audit Logs for Jackson + SaaS App 2023-11-23 21:22:35 +05:30
Kiran K add539869f
Merge branch 'main' into feat/product-config 2023-11-23 12:18:16 +05:30
Kiran K eda43bb740 Refactor product fetching 2023-11-23 12:05:39 +05:30
Kiran K 837be177c7 Show friendly product name instead of id 2023-11-23 11:56:39 +05:30
Kiran K f23eb97161 Refactor API route validation in middleware.ts 2023-11-21 10:06:02 +05:30
Kiran K 8be2e20f90 Merge branch 'main' into feat/product-config 2023-11-21 10:03:10 +05:30
Kiran K fc0a32da90 Cleanup 2023-11-20 10:56:27 +05:30
Kiran K 7cae8439eb Product id is required 2023-11-20 10:53:30 +05:30
Kiran K cf9a051085 Handle Product not found. 2023-11-20 10:40:47 +05:30
Kiran K 60cd162672 Store product config 2023-11-17 17:15:29 +05:30
66 changed files with 2171 additions and 115 deletions

View File

@ -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=

View File

@ -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 }}

View File

@ -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 . .

View File

@ -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}`);
}

View File

@ -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'),
},
],
},
];

View File

@ -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 }),
});

View File

@ -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 });
};

View File

@ -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';
import { validateDevelopmentModeLimits } from '@lib/development-mode';
@ -24,6 +25,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 });
};

273
ee/retraced/index.ts Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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);
}
};

View File

@ -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 <LicenseRequired />;
}
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 (
<>
<SecurityLogsConfigEdit id={id} urls={urls} onSuccess={successToast} onError={errorToast} />
</>
);
};
export default UpdateConfig;

View File

@ -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 <LicenseRequired />;
}
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 (
<SecurityLogsConfigs
urls={urls}
skipColumns={['endpoint']}
onSuccess={successToast}
onError={errorToast}
/>
);
};
export default ConfigList;

View File

@ -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 <LicenseRequired />;
}
const urls = {
createConfig: '/api/admin/security-logs-config',
listConfigs: '/admin/settings/security-logs',
};
return <SecurityLogsConfigCreate urls={urls} onSuccess={successToast} onError={errorToast} />;
};
export default NewConfiguration;

View File

@ -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;

9
ee/security-sinks/.gitignore vendored Normal file
View File

@ -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/**

View File

@ -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.❗_

View File

@ -0,0 +1 @@
The BoxyHQ Enterprise Edition (EE) license (the “EE License”)

View File

@ -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/

110
ee/security-sinks/package-lock.json generated Normal file
View File

@ -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=="
}
}
}

View File

@ -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"
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,3 @@
export const sleep = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};

View File

@ -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;

View File

@ -0,0 +1,16 @@
// Interface for a Sink
export interface Sink {
// HealthCheck returns true if the sink is healthy
healthCheck(): Promise<boolean>;
// 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<any>;
// SendEvents sends events to the sink
sendEvents(events: any[], batchSize: number): Promise<any>;
}
export interface Logger {
info: (message: string) => void;
error: (message: string) => void;
}

View File

@ -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<boolean> {
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<boolean> {
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<boolean> {
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;
}
}

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules"]
}

View File

@ -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,
},
}

View File

@ -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

View File

@ -5,3 +5,4 @@ export * from './dsync';
export * from './provider';
export * from './sso-tracer';
export * from './setup-link';
export * from './security-logs-config';

View File

@ -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<HTMLInputElement | HTMLSelectElement>) => {
const target = event.target as HTMLInputElement;
setConfig({
...config,
[target.id]: target.value,
});
};
return (
<>
<LinkBack href={urls.listConfigs} />
<h2 className='mb-5 mt-5 font-bold text-gray-700 md:text-xl'>{t('bui-slc-add')}</h2>
<div className='rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
<form onSubmit={onSubmit}>
<div className='flex flex-col space-y-3'>
<div className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t('bui-shared-type')}</span>
</label>
<select
className='select-bordered select w-full'
id='type'
value={type}
onChange={(e) => {
setType(e.target.value);
}}
required>
{[t('bui-shared-select-type'), ...Object.keys(configMap)].map((key) => {
return (
<option key={key} value={key}>
{key}
</option>
);
})}
</select>
</div>
<div className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t('bui-shared-name')}</span>
</label>
<input
type='text'
id='name'
className='input-bordered input'
value={name}
required={false}
onChange={(e) => setName(e.target.value)}
placeholder={t('bui-shared-name')}
/>
</div>
{type && (
<>
{configMap[type] &&
configMap[type].fields.map((field) => {
return (
<div key={field.index} className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t(field.label)}</span>
</label>
<input
type={field.type}
id={field.name}
className='input-bordered input'
required
onChange={onChange}
placeholder={t(field.placeholder)}
/>
</div>
);
})}
</>
)}
<div>
<ButtonPrimary loading={loading}>{t('bui-slc-create-config')}</ButtonPrimary>
</div>
</div>
</form>
</div>
</>
);
};

View File

@ -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<unknown> = 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 (
<>
<section className='mt-5 flex items-center rounded bg-red-100 p-6 text-red-900'>
<div className='flex-1'>
<h6 className='mb-1 font-medium'>{t('bui-slc-delete-confirmation')}</h6>
<p className='font-light'>{t('bui-slc-logs-noop')}</p>
</div>
<ButtonDanger
type='button'
data-modal-toggle='popup-modal'
onClick={() => {
setDelModalVisible(true);
}}>
{t('bui-shared-delete')}
</ButtonDanger>
</section>
<ConfirmationModal
title={t('bui-slc-delete')}
description={t('bui-slc-delete-modal-confirmation')}
visible={delModalVisible}
onConfirm={deleteApp}
onCancel={() => {
setDelModalVisible(false);
}}
/>
</>
);
};

View File

@ -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<any>({});
const [fields, setFields] = useState<SinkConfigMapField[]>([]);
const { data, error, isLoading } = useSWR<ApiSuccess<SecurityLogsConfig>, ApiError>(
urls.getById(id),
fetcher,
{
revalidateOnFocus: false,
}
);
useEffect(() => {
if (data) {
setConfig(data.data?.config);
setFields(getFieldsFromSinkType(data.data?.type) || []);
}
}, [data]);
if (error) {
<Error message={error.message} />;
return null;
}
if (isLoading) {
return <Loading />;
}
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<SecurityLogsConfig> = 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<HTMLInputElement | HTMLSelectElement>) => {
const target = event.target as HTMLInputElement;
setConfig({
...config,
[target.id]: target.value,
});
};
return (
<>
<LinkBack href={urls.listConfigs} />
<div className='mb-5 flex items-center justify-between'>
<h2 className='mt-5 font-bold text-gray-700 md:text-xl'>{t('bui-slc-update')}</h2>
</div>
<div className='rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
<form onSubmit={onSubmit}>
<div className='space-y-3'>
{fields.map((field) => {
return (
<div className='form-control w-full md:w-1/2' key={field.index}>
<label className='label'>
<span className='label-text'>{t(field.label)}</span>
</label>
<input
type={field.type}
className='input-bordered input'
id={field.name}
placeholder={t(field.placeholder)}
value={config[field.name]}
onChange={onChange}
/>
</div>
);
})}
<div>
<ButtonPrimary type='submit' loading={loading}>
{t('bui-shared-save-changes')}
</ButtonPrimary>
</div>
</div>
</form>
</div>
<SecurityLogsConfigDelete id={id} urls={urls} onSuccess={onSuccess} onError={onError} />
</>
);
};

View File

@ -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<string | null>(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<ApiSuccess<SecurityLogsConfig[]>, 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 <Loading />;
}
if (error) {
return <Error message={error.message} />;
}
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: <PencilIcon className='w-5' />,
},
{
color: 'error',
text: t('bui-shared-delete'),
icon: <TrashIcon className='h-5 w-5' />,
onClick: () => {
setConnection(config.id);
setDelModalVisible(true);
},
},
]
: [
{
text: t('bui-shared-edit'),
onClick: () => router?.replace(urls.editById(config.id)),
icon: <PencilIcon className='w-5' />,
},
],
};
} 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 (
<>
<div className='mb-5 flex items-center justify-between'>
<h2 className='font-bold text-gray-700 dark:text-white md:text-xl'>{t('bui-slc')}</h2>
<div className='flex'>
<LinkPrimary className='m-2' Icon={PlusIcon} href={urls.createConfig}>
{t('bui-slc-new')}
</LinkPrimary>
</div>
</div>
{noConfigs ? (
<>
<EmptyState title={t('bui-slc-empty')} />
</>
) : (
<>
<Table noMoreResults={noMoreResults} cols={cols} body={body} />
<Pagination
itemsCount={configs.length}
offset={paginate.offset}
onPrevClick={() => {
setPaginate({
offset: paginate.offset - pageLimit,
});
}}
onNextClick={() => {
setPaginate({
offset: paginate.offset + pageLimit,
});
}}
/>
<ConfirmationModal
title='Delete Splunk Connection'
visible={delModalVisible}
description={t('bui-slc-delete-modal-confirmation')}
onConfirm={() => deleteSplunkConnection()}
onCancel={() => setDelModalVisible(false)}
/>
</>
)}
</>
);
};

View File

@ -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';

View File

@ -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);
};

View File

@ -5,6 +5,8 @@ export interface ApiError extends Error {
status: number;
}
export type ApiResponse<T = any> = ApiSuccess<T> | { error: ApiError };
enum DirectorySyncProviders {
'azure-scim-v2' = 'Azure SCIM v2.0',
'onelogin-scim-v2' = 'OneLogin SCIM v2.0',

View File

@ -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(',') : [];

View File

@ -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",

View File

@ -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);

View File

@ -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

View File

@ -43,3 +43,10 @@ export type AppRequestParams =
product: string;
type?: string;
};
export type FederatedSAMLProfile = {
email: string;
firstName: string;
lastName: string;
requested: Record<string, string>;
};

View File

@ -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<string> {
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<SecurityLogsConfig | undefined> {
await throwIfInvalidLicense(this.opts.boxyhqLicenseKey);
return await this.store.get(id);
}
public async update(id: string, config: any, name?: string): Promise<SecurityLogsConfig> {
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<void> {
await throwIfInvalidLicense(this.opts.boxyhqLicenseKey);
await this.store.delete(id);
}
}

View File

@ -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;
};

View File

@ -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<boolean>;
productController: ProductController;
close: () => Promise<void>;
@ -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<ReturnType<typeof controllers>>;
export type ISetupLinkController = InstanceType<typeof SetupLinkController>;
export type IBrandingController = InstanceType<typeof BrandingController>;
export type ISecurityLogsConfigController = InstanceType<typeof SecurityLogsConfigController>;

128
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "jackson",
"version": "1.23.5",
"version": "1.23.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jackson",
"version": "1.23.5",
"version": "1.23.6",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@ -14,6 +14,7 @@
"@boxyhq/metrics": "0.2.6",
"@boxyhq/react-ui": "3.3.43",
"@boxyhq/saml-jackson": "file:npm",
"@boxyhq/security-logs-sink": "file:./ee/security-sinks",
"@heroicons/react": "2.1.3",
"@retracedhq/logs-viewer": "2.7.3",
"@retracedhq/retraced": "0.7.9",
@ -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",
@ -1701,6 +1715,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",
@ -6004,17 +6022,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.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.8.0.tgz",
@ -6985,16 +6992,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",
@ -15930,86 +15927,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",
@ -19724,6 +19641,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",

View File

@ -1,6 +1,6 @@
{
"name": "jackson",
"version": "1.23.5",
"version": "1.23.6",
"private": true,
"description": "SAML 2.0 service",
"keywords": [
@ -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.43",
"@boxyhq/saml-jackson": "file:npm",
"@boxyhq/security-logs-sink": "file:./ee/security-sinks",
"@heroicons/react": "2.1.3",
"@retracedhq/logs-viewer": "2.7.3",
"@retracedhq/retraced": "0.7.9",
@ -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"
},

View File

@ -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(),
},
};
}

View File

@ -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(),
},
};
}

View File

@ -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(),
},
};
}

View File

@ -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';
import { validateDevelopmentModeLimits } from '@lib/development-mode';
@ -65,12 +66,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 });
}
};
@ -88,12 +110,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();
}
};
@ -109,6 +152,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 });
};

View File

@ -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 });
};

View File

@ -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 });
};

View File

@ -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';
@ -30,6 +31,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);
}

View File

@ -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,
});

View File

@ -0,0 +1 @@
export { default } from 'ee/security-logs-config/api/[id]/index';

View File

@ -0,0 +1 @@
export { default } from 'ee/security-logs-config/api/index';

View File

@ -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: {} });
};

View File

@ -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<void> {
retraced.reportAdminPortalEvent({
action: 'portal.user.login',
crud: 'c',
actor: {
id: `${user.email}`,
name: `${user.name}`,
},
});
},
},
});

View File

@ -1 +1 @@
export { default } from 'ee/product/api/[productId]';
export { default } from '@ee/product/api/[productId]';

View File

@ -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);
}

View File

@ -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);

View File

@ -0,0 +1 @@
export { default } from '@ee/security-logs-config/api/[id]/index';

View File

@ -0,0 +1 @@
export { default } from '@ee/security-logs-config/api/index';

View File

@ -0,0 +1 @@
export { default } from '@ee/security-logs/api/index';