Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement logging for user actions #605

Merged
merged 13 commits into from
Sep 27, 2024
155 changes: 129 additions & 26 deletions backend/src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as search from './search';
import * as vulnerabilities from './vulnerabilities';
import * as organizations from './organizations';
import * as scans from './scans';
import * as logs from './logs';
import * as users from './users';
import * as scanTasks from './scan-tasks';
import * as stats from './stats';
Expand All @@ -22,12 +23,13 @@ import * as reports from './reports';
import * as savedSearches from './saved-searches';
import rateLimit from 'express-rate-limit';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { User, UserType, connectToDatabase } from '../models';
import { Organization, User, UserType, connectToDatabase } from '../models';
import * as assessments from './assessments';
import * as jwt from 'jsonwebtoken';
import { Request, Response, NextFunction } from 'express';
import fetch from 'node-fetch';
import * as searchOrganizations from './organizationSearch';
import { Logger, RecordMessage } from '../tools/logger';

const sanitizer = require('sanitizer');

Expand All @@ -43,27 +45,41 @@ if (
setInterval(() => scheduler({}, {} as any, () => null), 30000);
}

const handlerToExpress = (handler) => async (req, res) => {
const { statusCode, body } = await handler(
{
pathParameters: req.params,
query: req.query,
requestContext: req.requestContext,
body: JSON.stringify(req.body || '{}'),
headers: req.headers,
path: req.originalUrl
},
{}
);
try {
const parsedBody = JSON.parse(sanitizer.sanitize(body));
res.status(statusCode).json(parsedBody);
} catch (e) {
// Not a JSON body
res.setHeader('content-type', 'text/plain');
res.status(statusCode).send(sanitizer.sanitize(body));
}
};
const handlerToExpress =
(handler, message?: RecordMessage, action?: string) => async (req, res) => {
const logger = new Logger(req);
const { statusCode, body } = await handler(
{
pathParameters: req.params,
query: req.query,
requestContext: req.requestContext,
body: JSON.stringify(req.body || '{}'),
headers: req.headers,
path: req.originalUrl
},
{}
);
// Add additional status codes that we may return for succesfull requests
if (statusCode === 200) {
if (message && action) {
logger.record(action, 'success', message, body);
}
} else {
if (message && action) {
logger.record(action, 'fail', message, body);
}
}

try {
const parsedBody = JSON.parse(sanitizer.sanitize(body));
res.status(200).json(parsedBody);
} catch (e) {
// Not valid JSON - may be a string response.
console.log('Error?', e);
res.setHeader('content-type', 'text/plain');
res.status(statusCode).send(sanitizer.sanitize(body));
}
};

const app = express();

Expand Down Expand Up @@ -561,6 +577,7 @@ authenticatedRoute.delete(
handlerToExpress(savedSearches.del)
);
authenticatedRoute.get('/scans', handlerToExpress(scans.list));
authenticatedRoute.post('/logs/search', handlerToExpress(logs.list));
authenticatedRoute.get('/granularScans', handlerToExpress(scans.listGranular));
authenticatedRoute.post('/scans', handlerToExpress(scans.create));
authenticatedRoute.get('/scans/:scanId', handlerToExpress(scans.get));
Expand Down Expand Up @@ -618,12 +635,39 @@ authenticatedRoute.delete(
);
authenticatedRoute.post(
'/v2/organizations/:organizationId/users',
handlerToExpress(organizations.addUserV2)
handlerToExpress(
organizations.addUserV2,
async (req, user) => {
const orgId = req?.params?.organizationId;
const userId = req?.body?.userId;
const role = req?.body?.role;
if (orgId && userId) {
const orgRecord = await Organization.findOne({ where: { id: orgId } });
const userRecord = await User.findOne({ where: { id: userId } });
return {
timestamp: new Date(),
userPerformedAssignment: user?.data?.id,
organization: orgRecord,
role: role,
user: userRecord
};
}
return {
timestamp: new Date(),
userId: user?.data?.id,
updatePayload: req.body
};
},
'USER ASSIGNED'
)
);

authenticatedRoute.post(
'/organizations/:organizationId/roles/:roleId/approve',
handlerToExpress(organizations.approveRole)
);

// TO-DO Add logging => /users => user has an org and you change them to a new organization
authenticatedRoute.post(
'/organizations/:organizationId/roles/:roleId/remove',
handlerToExpress(organizations.removeRole)
Expand All @@ -641,9 +685,58 @@ authenticatedRoute.post(
handlerToExpress(organizations.checkDomainVerification)
);
authenticatedRoute.post('/stats', handlerToExpress(stats.get));
authenticatedRoute.post('/users', handlerToExpress(users.invite));
authenticatedRoute.post(
'/users',
handlerToExpress(
users.invite,
async (req, user, responseBody) => {
const userId = user?.data?.id;
if (userId) {
const userRecord = await User.findOne({ where: { id: userId } });
return {
timestamp: new Date(),
userPerformedInvite: userRecord,
invitePayload: req.body,
createdUserRecord: responseBody
};
}
return {
timestamp: new Date(),
userId: user.data?.id,
invitePayload: req.body,
createdUserRecord: responseBody
};
},
'USER INVITE'
)
);
authenticatedRoute.get('/users', handlerToExpress(users.list));
authenticatedRoute.delete('/users/:userId', handlerToExpress(users.del));
authenticatedRoute.delete(
'/users/:userId',
handlerToExpress(
users.del,
async (req, user, res) => {
const userId = req?.params?.userId;
const userPerformedRemovalId = user?.data?.id;
if (userId && userPerformedRemovalId) {
const userPerformdRemovalRecord = await User.findOne({
where: { id: userPerformedRemovalId }
});
return {
timestamp: new Date(),
userPerformedRemoval: userPerformdRemovalRecord,
userRemoved: userId
};
}
return {
timestamp: new Date(),
userPerformedRemoval: user.data?.id,
userRemoved: req.params.userId
};
},
'USER DENY/REMOVE'
)
);
authenticatedRoute.get(
'/users/state/:state',
handlerToExpress(users.getByState)
Expand All @@ -668,7 +761,17 @@ authenticatedRoute.post(
authenticatedRoute.put(
'/users/:userId/register/approve',
checkGlobalAdminOrRegionAdmin,
handlerToExpress(users.registrationApproval)
handlerToExpress(
users.registrationApproval,
async (req, user) => {
return {
timestamp: new Date(),
userId: user?.data?.id,
userToApprove: req.params.userId
};
},
'USER APPROVE'
)
);

authenticatedRoute.put(
Expand Down
175 changes: 175 additions & 0 deletions backend/src/api/logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { SelectQueryBuilder } from 'typeorm';
import { Log } from '../models';
import { validateBody, wrapHandler } from './helpers';
import { IsDate, IsOptional, IsString } from 'class-validator';

type ParsedQuery = {
[key: string]: string | ParsedQuery;
};

const parseQueryString = (query: string): ParsedQuery => {
// Parses a query string that is used to search the JSON payload of a record
// Example => createdUserPayload.userId: 123124121424
const result: ParsedQuery = {};

const parts = query.match(/(\w+(\.\w+)*):\s*[^:]+/g);

if (!parts) {
return result;
}

parts.forEach((part) => {
const [key, value] = part.split(/:(.+)/);

if (!key || value === undefined) return;

const keyParts = key.trim().split('.');
let current = result;

keyParts.forEach((part, index) => {
if (index === keyParts.length - 1) {
current[part] = value.trim();
} else {
if (!current[part]) {
current[part] = {};
}
current = current[part] as ParsedQuery;
}
});
});

return result;
};

const generateSqlConditions = (
parsedQuery: ParsedQuery,
jsonPath: string[] = []
): string[] => {
const conditions: string[] = [];

for (const [key, value] of Object.entries(parsedQuery)) {
if (typeof value === 'object') {
const newPath = [...jsonPath, key];
conditions.push(...generateSqlConditions(value, newPath));
} else {
const jsonField =
jsonPath.length > 0
? `${jsonPath.map((path) => `'${path}'`).join('->')}->>'${key}'`
: `'${key}'`;
conditions.push(
`payload ${
jsonPath.length > 0 ? '->' : '->>'
} ${jsonField} = '${value}'`
);
}
}

return conditions;
};
class Filter {
@IsString()
value: string;

@IsString()
operator?: string;
}

class DateFilter {
@IsDate()
value: string;

@IsString()
operator:
| 'is'
| 'not'
| 'after'
| 'onOrAfter'
| 'before'
| 'onOrBefore'
| 'empty'
| 'notEmpty';
}
class LogSearch {
@IsOptional()
eventType?: Filter;
@IsOptional()
result?: Filter;
@IsOptional()
timestamp?: Filter;
@IsOptional()
payload?: Filter;
}

const generateDateCondition = (filter: DateFilter): string => {
const { operator } = filter;

switch (operator) {
case 'is':
return `log.createdAt = :timestamp`;
case 'not':
return `log.createdAt != :timestamp`;
case 'after':
return `log.createdAt > :timestamp`;
case 'onOrAfter':
return `log.createdAt >= :timestamp`;
case 'before':
return `log.createdAt < :timestamp`;
case 'onOrBefore':
return `log.createdAt <= :timestamp`;
case 'empty':
return `log.createdAt IS NULL`;
case 'notEmpty':
return `log.createdAt IS NOT NULL`;
default:
throw new Error('Invalid operator');
}
};

const filterResultQueryset = async (qs: SelectQueryBuilder<Log>, filters) => {
if (filters?.eventType) {
qs.andWhere('log.eventType ILIKE :eventType', {
eventType: `%${filters?.eventType?.value}%`
});
}
if (filters?.result) {
qs.andWhere('log.result ILIKE :result', {
result: `%${filters?.result?.value}%`
});
}
if (filters?.payload) {
try {
const parsedQuery = parseQueryString(filters?.payload?.value);
const conditions = generateSqlConditions(parsedQuery);
qs.andWhere(conditions[0]);
} catch (error) {}
}

if (filters?.timestamp) {
const timestampCondition = generateDateCondition(filters?.timestamp);
try {
} catch (error) {}
qs.andWhere(timestampCondition, {
timestamp: new Date(filters?.timestamp?.value)
});
}

return qs;
};

export const list = wrapHandler(async (event) => {
const search = await validateBody(LogSearch, event.body);

const qs = Log.createQueryBuilder('log');

const filterQs = await filterResultQueryset(qs, search);

const [results, resultsCount] = await filterQs.getManyAndCount();

return {
statusCode: 200,
body: JSON.stringify({
result: results,
count: resultsCount
})
};
});
Loading
Loading