From 376b7e4be5707d958081c9ea50d5b61770eec6c9 Mon Sep 17 00:00:00 2001 From: Nico Hagelberg <16757571+nicou@users.noreply.github.com> Date: Sun, 17 Dec 2023 12:07:13 +0200 Subject: [PATCH] Hacking improvements (#14) * Add more "hacking detected" messages. One will be picked by random instead of always using the same one. * Instead of posting the "hacking detected" message immediately after login, post it after a set period of time based on the hacker's skill level. * On hacker login API call return the seconds until detection, so that a progress bar can be shown in the frontend. --- .eslintrc.cjs | 6 +++- db/redux/box/{index.js => index.ts} | 15 +++++---- db/redux/misc/{index.js => index.ts} | 14 +++++++- package-lock.json | 16 ++++++++- package.json | 3 +- src/index.ts | 13 +++++--- src/routes/person.js | 48 +++++++++++++++++--------- src/store/store.ts | 1 - src/store/types.ts | 17 ++++++++++ src/utils/groups.ts | 27 +++++++++++++++ src/utils/hacking.ts | 50 ++++++++++++++++++++++++++++ 11 files changed, 179 insertions(+), 31 deletions(-) rename db/redux/box/{index.js => index.ts} (60%) rename db/redux/misc/{index.js => index.ts} (78%) create mode 100644 src/store/types.ts create mode 100644 src/utils/groups.ts create mode 100644 src/utils/hacking.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 0e0e296..53716e8 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -21,7 +21,7 @@ module.exports = { 'object-shorthand': 1, 'prefer-arrow-callback': 1, 'spaced-comment': 1, - 'no-redeclare': 2, + 'no-redeclare': [2, { ignoreDeclarationMerge: true }], 'no-throw-literal': 2, 'no-useless-concat': 2, 'no-void': 2, @@ -100,5 +100,9 @@ module.exports = { ], 'dot-location': [2, 'property'], '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [2, { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }], }, }; diff --git a/db/redux/box/index.js b/db/redux/box/index.ts similarity index 60% rename from db/redux/box/index.js rename to db/redux/box/index.ts index b9f1b26..5bcf3ec 100644 --- a/db/redux/box/index.js +++ b/db/redux/box/index.ts @@ -6,9 +6,12 @@ import driftingValueBlobs from './driftingValue'; import buttonboard from './buttonboard'; import reactorWiring from './reactorWiring'; -airlock.forEach(saveBlob); -fuseboxes.forEach(saveBlob); -jumpdriveBlobs.forEach(saveBlob); -driftingValueBlobs.forEach(saveBlob); -buttonboard.forEach(saveBlob); -reactorWiring.forEach(saveBlob); +const blobs = [ + ...airlock, + ...fuseboxes, + ...jumpdriveBlobs, + ...driftingValueBlobs, + ...buttonboard, + ...reactorWiring, +]; +blobs.forEach(saveBlob); diff --git a/db/redux/misc/index.js b/db/redux/misc/index.ts similarity index 78% rename from db/redux/misc/index.js rename to db/redux/misc/index.ts index bbd4f89..11c5f2c 100644 --- a/db/redux/misc/index.js +++ b/db/redux/misc/index.ts @@ -1,6 +1,9 @@ import { saveBlob } from '../helpers'; +import { Stores } from '../../../src/store/types'; +import { SkillLevels } from '../../../src/utils/groups'; +import { Duration } from '../../../src/utils/time'; -const blobs = [ +const blobs: unknown[] = [ // Used for the once-per-game Velian minigame thingy { type: 'misc', @@ -55,6 +58,15 @@ sunt eaque dolor id nisi magni.` show_20110_tumor: true, show_20070_alien: false, }, + { + type: 'misc', + id: Stores.HackerDetectionTimes, + detection_times: { + [SkillLevels.Novice]: Duration.minutes(1), + [SkillLevels.Master]: Duration.minutes(2), + [SkillLevels.Expert]: Duration.minutes(5), + } + }, ]; blobs.forEach(saveBlob); diff --git a/package-lock.json b/package-lock.json index 7b301d2..9c83b8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,8 @@ "shelljs": "^0.8.3", "signale": "^1.4.0", "socket.io": "^2.1.1", - "socket.io-prometheus": "^0.2.1" + "socket.io-prometheus": "^0.2.1", + "zod": "^3.22.4" }, "devDependencies": { "@types/body-parser": "^1.19.2", @@ -11147,6 +11148,14 @@ "optionalDependencies": { "commander": "^2.7.1" } + }, + "node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } }, "dependencies": { @@ -19742,6 +19751,11 @@ "lodash.isequal": "^4.0.0", "validator": "^10.0.0" } + }, + "zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==" } } } diff --git a/package.json b/package.json index 750fcee..2b5eaa5 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "shelljs": "^0.8.3", "signale": "^1.4.0", "socket.io": "^2.1.1", - "socket.io-prometheus": "^0.2.1" + "socket.io-prometheus": "^0.2.1", + "zod": "^3.22.4" }, "devDependencies": { "@types/body-parser": "^1.19.2", diff --git a/src/index.ts b/src/index.ts index 50f2f28..7f9b6e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import 'dotenv/config'; import { Server } from 'http'; -import express from 'express'; +import { HttpError } from 'http-errors'; +import express, { NextFunction, Request, Response } from 'express'; import bodyParser from 'body-parser'; import socketIo from 'socket.io'; import { logger, loggerMiddleware } from './logger'; @@ -67,7 +68,7 @@ app.use((req, res, next) => { }); // Setup routes -app.get('/', (req, res) => res.redirect('/api-docs')); +app.get('/', (req: Request, res: Response) => res.redirect('/api-docs')); app.use('/fleet', fleet); app.use('/starmap', starmap); app.use('/person', person); @@ -116,9 +117,13 @@ app.post('/emit/:eventName', (req, res) => { }); // Error handling middleware -app.use(async (err, req, res, next) => { +app.use(async (err: HttpError, req: Request, res: Response, _next: NextFunction) => { + let status = 500; + if ('statusCode' in err) { + status = err.statusCode; + } logger.error(err.message); - return res.status(err.status || 500).json({ error: err.message }); + return res.status(status).json({ error: err.message }); }); // Setup Socket.IO diff --git a/src/routes/person.js b/src/routes/person.js index f32dd86..31e8e6a 100644 --- a/src/routes/person.js +++ b/src/routes/person.js @@ -1,10 +1,12 @@ import { Router } from 'express'; -import { Person, Entry, Group, getFilterableValues, setPersonsVisible } from '../models/person'; -import { addShipLogEntry, AuditLogEntry } from '../models/log'; +import { Person, Entry, Group, getFilterableValues, setPersonsVisible } from '@/models/person'; +import { addShipLogEntry, AuditLogEntry } from '@/models/log'; import { handleAsyncErrors } from './helpers'; import { get, pick, mapKeys, snakeCase } from 'lodash'; import { NotFound, BadRequest } from 'http-errors'; -import { logger } from '../logger'; +import { logger } from '@/logger'; +import { getHackingDetectionTime, getRandomHackingIntrustionDetectionMessage } from '@/utils/hacking'; +import { Duration, getRandomNumberBetween } from '@/utils/time'; const router = new Router(); const DEFAULT_PERSON_PAGE = 1; @@ -81,20 +83,34 @@ router.get('/groups', handleAsyncErrors(async (req, res) => { */ router.get('/:id', handleAsyncErrors(async (req, res) => { const isLogin = req.query.login === 'true'; - const person_id = req.params.id; - const person = await Person.forge({ id: person_id }).fetchWithRelated(); - if (isLogin && person) { - const hacker_id = get(req.query, 'hacker_id', null); - if (!hacker_id) { - logger.warn(`Social Hub login to /person/${person_id} with no hacker_id in request`); - } - AuditLogEntry.forge().save({ - person_id, - hacker_id, - type: 'HACKER_LOGIN' - }); - addShipLogEntry('WARNING', 'Intrusion detection system has detected malicious activity'); + const hackerId = req.query.hacker_id; + const personId = req.params.id; + const person = await Person.forge({ id: personId }).fetchWithRelated(); + + const isHackerLogin = isLogin && person && hackerId; + if (isHackerLogin) { + const hacker = await Person.forge({ id: hackerId }).fetchWithRelated(); + // Get the hacking detection time based on the hacker's skill level + const [detectionTimeMs] = await Promise.all([ + await getHackingDetectionTime(hacker), + AuditLogEntry.forge().save({ + person_id: personId, + hacker_id: hackerId, + type: 'HACKER_LOGIN' + }), + ]); + + const intrusionDetectedMessage = getRandomHackingIntrustionDetectionMessage(); + + // TODO: If the hacker logs out, we should be able to match to this timeout, cancel it, + // and run the function immediately + setTimeout(() => { + addShipLogEntry('WARNING', intrusionDetectedMessage); + }, detectionTimeMs); + + return res.json({ ...person, hacker: { detectionTimeMs, intrusionDetectedMessage } }); } + res.json(person); })); diff --git a/src/store/store.ts b/src/store/store.ts index c04a797..0571b00 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -75,4 +75,3 @@ export function initState(state: Record) { } export default store; - diff --git a/src/store/types.ts b/src/store/types.ts new file mode 100644 index 0000000..6bb87dd --- /dev/null +++ b/src/store/types.ts @@ -0,0 +1,17 @@ +import { SkillLevels } from '@/utils/groups'; +import { z } from 'zod'; + +export const Stores = { + HackerDetectionTimes: 'hacker_detection_times', +} as const; + +export const HackerDetectionTimes = z.object({ + type: z.literal('misc'), + id: z.literal(Stores.HackerDetectionTimes), + detection_times: z.object({ + [SkillLevels.Novice]: z.number().min(0).int(), + [SkillLevels.Master]: z.number().min(0).int(), + [SkillLevels.Expert]: z.number().min(0).int(), + }), +}); +export type HackerDetectionTimes = z.infer; diff --git a/src/utils/groups.ts b/src/utils/groups.ts new file mode 100644 index 0000000..a66fc53 --- /dev/null +++ b/src/utils/groups.ts @@ -0,0 +1,27 @@ +export const SkillLevels = { + Novice: 'skill:novice', + Master: 'skill:master', + Expert: 'skill:expert', +} as const; +export type SkillLevel = typeof SkillLevels[keyof typeof SkillLevels]; + +export function getHighestSkillLevel(person: unknown): SkillLevel { + if (typeof person !== 'object' || person === null || !('groups' in person)) { + return SkillLevels.Novice; + } + + // Dumb hack to work around Bookshelf models returning relations in a dumb way + const { groups }: { groups: string[] } = JSON.parse(JSON.stringify(person)); + + if (!Array.isArray(groups)) { + return SkillLevels.Novice; + } + const skillLevels = groups.filter((group: string) => typeof group === 'string' && group.startsWith('skill:')); + if (skillLevels.length === 0) { + return SkillLevels.Novice; + } + + if (skillLevels.includes(SkillLevels.Expert)) return SkillLevels.Expert; + if (skillLevels.includes(SkillLevels.Master)) return SkillLevels.Master; + return SkillLevels.Novice; +} diff --git a/src/utils/hacking.ts b/src/utils/hacking.ts new file mode 100644 index 0000000..343fbe5 --- /dev/null +++ b/src/utils/hacking.ts @@ -0,0 +1,50 @@ +import { getPath } from '@/store/store'; +import { SkillLevels, getHighestSkillLevel } from './groups'; +import { Duration } from './time'; +import { HackerDetectionTimes, Stores } from '@/store/types'; +import { logger } from '@/logger'; + +const hackingIntrustionDetectionMessages = [ + 'Intrusion detection system has detected malicious activity', + 'System has detected unusual network patterns', + 'Unusual activity recognized within the system', + 'System analysis reveals potential illicit entry', + 'Inconsistent data patterns suggest possible intrusion', + 'System surveillance notes suspicious activity', + 'Potential intrusion detected by the system\'s defense mechanism', + 'Cyber defense system has detected irregular activity', + 'Unusual system access patterns detected', + 'Abnormal network behavior suggests possible intrusion', + 'Inconsistent network patterns hinting at potential breach', + 'System reports unusual activity, possible intrusion', + 'System has detected a potential security breach', + 'System\'s defense mechanism notes potential intrusion', + 'System analysis records irregular access patterns', + 'Network surveillance system detects potential malicious activity', +]; + +export const getRandomHackingIntrustionDetectionMessage = () => { + const randomIndex = Math.floor(Math.random() * hackingIntrustionDetectionMessages.length); + return hackingIntrustionDetectionMessages[randomIndex]; +}; + +export const getHackingDetectionTime = async (hackerPerson: unknown): Promise => { + const skillLevel = getHighestSkillLevel(hackerPerson); + const detectionTimesBlob = await getPath(['data', 'misc', Stores.HackerDetectionTimes]); + const detectionTimes = HackerDetectionTimes.safeParse(detectionTimesBlob); + if (!detectionTimes.success) { + logger.error('Failed to parse detection times blob, returning 1min', detectionTimesBlob); + return Duration.minutes(1); + } + + switch (skillLevel) { + case SkillLevels.Expert: + return detectionTimes.data.detection_times[SkillLevels.Expert]; + case SkillLevels.Master: + return detectionTimes.data.detection_times[SkillLevels.Master]; + case SkillLevels.Novice: + return detectionTimes.data.detection_times[SkillLevels.Novice]; + default: + return Duration.minutes(1); + } +};